Files
Pete/internal/web/games_uno_table.go
prosolis 927ed84163 games: UNO becomes a table you sit at, and the pot that pays whoever goes out first
Phase D backend: UNO is now a session like hold'em, not a single stake. You sit
with a buy-in stack, ante into a pot each hand, and leave with what's in front of
you. The engine lost its `You` constant and its measured multiples: ApplyMove
takes the acting seat, New takes a seat list, a Tier carries an ante instead of a
Base, and a hand settles by moving the pot to the winner (less rake, and never
when a bot takes it) rather than paying a multiple. A mercy kill puts a seat out
of the hand, not out of the game — the last one standing takes the pot.

The redaction moved to the web layer, where hold'em's already lives: the engine
now stamps every seat's hand onto its events, and viewUno/viewUnoEvents strip
everything that isn't the viewer's own. TestUnoViewNeverLeaksAnotherSeatsCards is
the wall. unoTable implements tableGame; /uno/{sit,move,leave,tables,stream,chat,
say} mirror hold'em, with stream/chat/say now shared game-agnostic handlers.

The frontend is not done: uno.js still calls the retired solo endpoint, so the
page renders but is not yet playable. All engine and web tests are green.

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

188 lines
5.8 KiB
Go

package web
import (
"encoding/json"
"time"
"pete/internal/games/uno"
"pete/internal/storage"
)
// UNO as a shared table: the seam the runtime drives it through, and the two
// facts about a hand 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* UNO is still in the engine. This file is the
// translation layer between a uno.State and the game-agnostic table runtime.
// unoTable is the tableGame for UNO. It holds no state — the table's state is the
// blob in game_tables — so a single value serves every felt in the room.
type unoTable struct{}
func (unoTable) name() string { return gameUno }
// timeout plays a passive move for the human whose clock ran out and marks them
// away, so the runtime auto-acts them on sight after this rather than waiting a
// full clock every orbit. The move is the gentlest one that still advances the
// turn: give in to a stack, pass a drawn card (or play it where the rules force
// it), otherwise play a legal card if there is one, or draw.
//
// 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 (unoTable) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
var g uno.State
if err := json.Unmarshal(state, &g); err != nil {
return step{}, nil, err
}
if !unoPlaying(g) {
return step{}, seats, errNotDue
}
seat := g.Turn
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
return step{}, seats, errNotDue
}
move := unoIdleMove(g, seat)
next, evs, err := uno.ApplyMove(g, seat, move)
if err != nil {
return step{}, nil, err
}
changed := markAway(seats, seat)
st, err := unoStep(next, evs, seats)
return st, changed, err
}
// unoIdleMove is the move the clock makes for a walked-away seat: the most passive
// legal one that still hands the turn on.
func unoIdleMove(g uno.State, seat int) uno.Move {
switch g.Phase {
case uno.PhaseStack:
return uno.Move{Kind: uno.MoveTake}
case uno.PhaseDrawn:
if g.Tier.NoMercy {
// No Mercy makes you play the card you drew; it is the last in the hand.
return uno.Move{Kind: uno.MovePlay, Index: len(g.Hands[seat]) - 1, Color: uno.Red}
}
return uno.Move{Kind: uno.MovePass}
default:
if p := g.Playable(seat); len(p) > 0 {
return uno.Move{Kind: uno.MovePlay, Index: p[0], Color: uno.Red}
}
return uno.Move{Kind: uno.MoveDraw}
}
}
// stacks reports the chips in front of each seat, index-aligned with the table's
// seat rows, so the abandoned-table reaper can cash out a walked-away human.
func (unoTable) stacks(state []byte) ([]int64, error) {
var g uno.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
}
// unoStep 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.
func unoStep(next uno.State, evs []uno.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: unoDeadline(next, seats),
Audit: unoAudit(next, evs, seats),
}
return st, nil
}
// unoPlaying reports whether a hand is in progress — the only phase a turn clock
// has anything to do in.
func unoPlaying(g uno.State) bool {
switch g.Phase {
case uno.PhasePlay, uno.PhaseDrawn, uno.PhaseStack:
return true
}
return false
}
// unoDeadline is when the clock must next act, or 0 for never. A clock is only set
// on a *present* human whose turn it is: a bot resolves inside the move, an away
// human is auto-acted on sight, and between hands there is no per-seat clock —
// dealing the next hand is a seated player's call, and an abandoned table is the
// reaper's job.
func unoDeadline(g uno.State, seats []storage.Seat) int64 {
if !unoPlaying(g) {
return 0
}
seat := g.Turn
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
return 0
}
if seatAway(seats, seat) {
return 0
}
return time.Now().Unix() + turnSeconds
}
// unoAudit 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 a settle beat is
// emitted.
//
// The rake rides on the winner's row alone, and every other seat carries zero, so
// game_hands.rake (which HouseTake sums) records a pot's rake once and once only.
// A seat's ante is its bet; a refunded tie pays each seat its ante straight back.
func unoAudit(g uno.State, evs []uno.Event, seats []storage.Seat) []storage.Hand {
if !unoHandEnded(evs) {
return nil
}
var audit []storage.Hand
for i := range g.Seats {
p := g.Seats[i]
if p.Bot || i >= len(seats) || seats[i].MatrixUser == "" {
continue
}
if p.Ante == 0 {
continue // sat this hand out — nothing to record
}
outcome, payout, rake := "lost", int64(0), int64(0)
switch {
case i == g.Winner:
outcome, payout, rake = "won", p.Won, g.Rake
case g.Outcome == uno.OutcomeTie:
outcome, payout = "push", p.Ante // the ante came straight back
}
audit = append(audit, storage.Hand{
MatrixUser: seats[i].MatrixUser,
Game: gameUno,
Bet: p.Ante,
Payout: payout,
Rake: rake,
Outcome: outcome,
Seed1: g.Seed1,
Seed2: g.Seed2,
})
}
return audit
}
// unoHandEnded reports whether a hand finished in this batch of events. A settle
// beat is emitted exactly once, when a hand ends, so it is the clean signal that
// the seats' Ante/Won are this hand's final numbers.
func unoHandEnded(evs []uno.Event) bool {
for _, e := range evs {
if e.Kind == uno.EvSettle {
return true
}
}
return false
}