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
This commit is contained in:
187
internal/web/games_uno_table.go
Normal file
187
internal/web/games_uno_table.go
Normal file
@@ -0,0 +1,187 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user