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
188 lines
5.8 KiB
Go
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
|
|
}
|