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:
prosolis
2026-07-14 18:37:51 -07:00
parent f8b07d8e6c
commit 927ed84163
15 changed files with 1799 additions and 1208 deletions

View File

@@ -124,16 +124,21 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart)
mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer)
mux.HandleFunc("POST /api/games/uno/start", s.handleUnoStart)
mux.HandleFunc("POST /api/games/uno/sit", s.handleUnoSit)
mux.HandleFunc("POST /api/games/uno/move", s.handleUnoMove)
mux.HandleFunc("POST /api/games/uno/leave", s.handleUnoLeave)
mux.HandleFunc("GET /api/games/uno/tables", s.handleUnoLobby)
mux.HandleFunc("GET /api/games/uno/stream", s.handleTableStream)
mux.HandleFunc("GET /api/games/uno/chat", s.handleTableChat)
mux.HandleFunc("POST /api/games/uno/say", s.handleTableSay)
mux.HandleFunc("POST /api/games/holdem/sit", s.handleHoldemSit)
mux.HandleFunc("POST /api/games/holdem/move", s.handleHoldemMove)
mux.HandleFunc("POST /api/games/holdem/leave", s.handleHoldemLeave)
mux.HandleFunc("GET /api/games/holdem/tables", s.handleHoldemLobby)
mux.HandleFunc("GET /api/games/holdem/stream", s.handleHoldemStream)
mux.HandleFunc("GET /api/games/holdem/chat", s.handleHoldemChat)
mux.HandleFunc("POST /api/games/holdem/say", s.handleHoldemSay)
mux.HandleFunc("GET /api/games/holdem/stream", s.handleTableStream)
mux.HandleFunc("GET /api/games/holdem/chat", s.handleTableChat)
mux.HandleFunc("POST /api/games/holdem/say", s.handleTableSay)
}
// requirePlayer sends an anonymous visitor to sign in and comes back here after.

View File

@@ -290,11 +290,33 @@ func (s *Server) table(user string) (tableView, error) {
tv := viewTrivia(g, time.Now())
v.Trivia = &tv
case gameUno:
// A seated UNO player's cards are in game_tables, not here — this row is only
// their occupancy claim. Load the table and render it as their own seat sees it.
if live.TableID == "" {
return s.dropUnreadable(user, v, fmt.Errorf("uno row with no table"))
}
t, tableSeats, err := storage.LoadTable(live.TableID)
if errors.Is(err, storage.ErrNoSuchTable) {
return s.dropUnreadable(user, v, fmt.Errorf("uno table %s gone", live.TableID))
}
if err != nil {
return tableView{}, err
}
_, seat, err := storage.PlayerSeat(user)
if err != nil {
return tableView{}, err
}
var g uno.State
if err := json.Unmarshal(live.State, &g); err != nil {
if err := json.Unmarshal(t.State, &g); err != nil {
return s.dropUnreadable(user, v, err)
}
uv := viewUno(g)
uv := viewUno(g, seat)
for _, ts := range tableSeats {
if ts.Seat == seat {
uv.BoughtIn = ts.Staked
break
}
}
v.Uno = &uv
case gameHoldem:
// A seated hold'em player's cards are in game_tables, not here — this row is

View File

@@ -19,17 +19,25 @@ import (
// ---- the lobby -------------------------------------------------------------
// handleHoldemLobby lists the hold'em tables with a seat going spare. A table
// with every chair taken is not shown, because a lobby you cannot join from is
// just a list; bots keep every open table populated, so there is always
// something to sit down at.
// handleHoldemLobby and handleUnoLobby list the tables of their game with a seat
// going spare. A table with every chair taken is not shown, because a lobby you
// cannot join from is just a list; bots keep every open table populated, so there
// is always something to sit down at.
func (s *Server) handleHoldemLobby(w http.ResponseWriter, r *http.Request) {
s.lobby(w, r, gameHoldem)
}
func (s *Server) handleUnoLobby(w http.ResponseWriter, r *http.Request) {
s.lobby(w, r, gameUno)
}
func (s *Server) lobby(w http.ResponseWriter, r *http.Request, game string) {
if _, ok := s.player(w, r); !ok {
return
}
tables, err := storage.LobbyTables(gameHoldem, 50)
tables, err := storage.LobbyTables(game, 50)
if err != nil {
slog.Error("games: holdem lobby", "err", err)
slog.Error("games: lobby", "game", game, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
@@ -58,7 +66,7 @@ const streamPing = 25 * time.Second
// table the player is at. After that it only ever reads its channel. Holding a
// query open for the life of a stream would hold the single pooled connection for
// the life of a stream, and one idle subscriber would take the whole site down.
func (s *Server) handleHoldemStream(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleTableStream(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
@@ -117,7 +125,7 @@ func (s *Server) handleHoldemStream(w http.ResponseWriter, r *http.Request) {
// handleHoldemChat reads the recent rail of a player's table, oldest first, with
// their own lines flagged so the felt can lay them out on the right.
func (s *Server) handleHoldemChat(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleTableChat(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
@@ -152,7 +160,7 @@ func (s *Server) handleHoldemChat(w http.ResponseWriter, r *http.Request) {
// stamped with the hand it was said during — the one question chat at a money
// table ever really raises — and pushed to every open stream so it lands on the
// rail in real time.
func (s *Server) handleHoldemSay(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleTableSay(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return

View File

@@ -5,58 +5,59 @@ import (
"errors"
"log/slog"
"net/http"
"time"
"pete/internal/games/blackjack"
"pete/internal/games/uno"
"pete/internal/storage"
)
// UNO, played for chips against bots.
// UNO, played for chips at a shared table.
//
// The seam is the same as every other table, but there is one thing here that no
// other table has: opponents. The obvious way to give a browser opponents is a
// socket, and the plan says solo UNO must not need one — so it doesn't. A move
// goes up, and what comes back is the player's move *plus every bot turn it
// handed off to*, as a script of events. One request, one round of the table.
// Like hold'em, this is a session: you sit down with a stack, ante into a pot each
// hand, and leave with what is in front of you. Chips cross the border twice —
// sit-down and get-up — and every ante and pot in between moves inside the state
// blob. Solo play is just a table nobody else has joined.
//
// What the browser is allowed to see: its own hand, the card in play, the colour
// in play, and how many cards each bot is holding. Not the deck, not a bot's
// hand, not even the face of a card a bot drew. That last one is most of the
// deck, and it is the thing that would turn a game of counting cards into a game
// of reading the network tab.
// The seam is the same as hold'em: one request plays a human's move plus every
// bot turn it hands off to, returned as a script the felt animates. What a viewer
// is allowed to see is their own hand, the card and colour in play, the pot, and
// how many cards each other seat holds — never the deck, another seat's hand, or
// the face of a card a bot drew. The engine emits every seat's hand (a shared
// stream cannot know who is watching); the redaction that keeps a hand private is
// here, and a missed case fans it to every subscriber.
// unoCardView is one card, ready to draw. The browser gets the colour and the
// face as words, not as the engine's integers — the same bargain the blackjack
// table makes, and for the same reason: the browser draws faces, not logic.
// unoCardView is one card, ready to draw: colour and face as words, not the
// engine's integers.
type unoCardView struct {
Color string `json:"color"` // "red" | "blue" | "yellow" | "green" | "wild"
Value string `json:"value"` // "0"…"9" | "skip" | "reverse" | "+2" | "wild" | "+4"
Wild bool `json:"wild"` // it's a wild, whatever colour it was played as
Color string `json:"color"`
Value string `json:"value"`
Wild bool `json:"wild"`
}
func viewUnoCard(c uno.Card) unoCardView {
return unoCardView{
Color: c.Color.String(),
Value: c.Value.String(),
Wild: c.IsWild(),
}
return unoCardView{Color: c.Color.String(), Value: c.Value.String(), Wild: c.IsWild()}
}
// unoSeatView is one seat at the table: a name, and a number of cards. A bot's
// cards are a *count*. There is no field here for what they are.
// unoSeatView is one seat at the table: a name, a card count, and a stack. A
// seat's cards are a *count* — there is no field here for what they are.
type unoSeatView struct {
Name string `json:"name"`
Cards int `json:"cards"`
Bot bool `json:"bot"`
You bool `json:"you"`
Cards int `json:"cards"`
Stack int64 `json:"stack"`
Ante int64 `json:"ante,omitempty"`
Uno bool `json:"uno"` // down to one card
Called bool `json:"called"` // …and said so. A seat where Uno is true and this isn't is a seat you can catch
Out bool `json:"out"` // No Mercy: buried at twenty-five, and not coming back
Called bool `json:"called"` // …and said so. Uno true and this false is a seat you can catch
Out bool `json:"out"` // not in this hand — mercy-killed, or sitting one out
}
// unoView is a game as its player may see it.
// unoView is a table as one seat may see it.
type unoView struct {
Tier uno.Tier `json:"tier"`
Seats []unoSeatView `json:"seats"`
Tier uno.Tier `json:"tier"`
YourSeat int `json:"your_seat"`
Seats []unoSeatView `json:"seats"`
Hand []unoCardView `json:"hand"` // yours, and only yours
Playable []int `json:"playable"` // which of them can legally go down
@@ -64,138 +65,138 @@ type unoView struct {
Color string `json:"color"` // the colour in play, which a wild renames
Deck int `json:"deck"` // cards left to draw
// The UNO call. UnoAt is which of your cards would leave you holding exactly
// one if you played it — the table asks for the call on those, and it asks the
// engine which they are because No Mercy's "discard all" makes counting the
// hand the wrong answer. Catchable is which seats are sitting on one card they
// never announced.
UnoAt []int `json:"uno_at"`
Catchable []int `json:"catchable"`
UnoAt []int `json:"uno_at"` // your cards that would leave you on one
Catchable []int `json:"catchable"` // seats sitting on one card they never called
Turn int `json:"turn"`
Dir int `json:"dir"`
Turn int `json:"turn"`
Dir int `json:"dir"`
Dealer int `json:"dealer"`
HandNo int `json:"hand_no"`
// No Mercy: the bill a stack of draw cards has run up. Whoever stops stacking
// pays it, and while it stands it is the only thing on the table that matters —
// so the felt has to be able to say what it is.
Pending int `json:"pending"`
Pending int `json:"pending"` // No Mercy: the bill a stack has run up
Bet int64 `json:"bet"`
Pays int64 `json:"pays"` // what going out right now would actually pay
Phase string `json:"phase"`
Outcome string `json:"outcome,omitempty"`
Pot int64 `json:"pot"` // the antes riding on this hand
Ante int64 `json:"ante"` // what each seat puts in
Stack int64 `json:"stack"` // what's in front of you
BoughtIn int64 `json:"bought_in"` // your own buy-in, from the border ledger
Phase string `json:"phase"`
// The last hand's verdict, so the felt can land it between hands.
Winner int `json:"winner"`
Payout int64 `json:"payout,omitempty"`
LastPot int64 `json:"last_pot,omitempty"`
Rake int64 `json:"rake,omitempty"`
Net int64 `json:"net"`
Outcome string `json:"outcome,omitempty"`
}
func viewUno(g uno.State) unoView {
// viewUno renders the table as one seat may see it. viewer is which seat is
// looking — their hand is the only one it will ever put in the payload.
//
// This is the security boundary. The same view fans to every subscriber's stream,
// so a seat that renders anyone else's cards fans them to the whole table.
// TestUnoViewNeverLeaksAnotherSeatsCards renders every seat's view and greps for
// cards that are not theirs.
func viewUno(g uno.State, viewer int) unoView {
v := unoView{
Tier: g.Tier,
Top: viewUnoCard(g.Top()),
Color: g.Color.String(),
Deck: g.Left(),
Turn: g.Turn,
Dir: g.Dir,
Pending: g.Pending,
Bet: g.Bet,
Pays: g.Pays(),
Phase: string(g.Phase),
Outcome: string(g.Outcome),
Winner: -1,
Payout: g.Payout,
Rake: g.Rake,
Net: g.Net(),
Tier: g.Tier,
YourSeat: viewer,
Top: viewUnoCard(g.Top()),
Color: g.Color.String(),
Deck: g.Left(),
Turn: g.Turn,
Dir: g.Dir,
Dealer: g.Dealer,
HandNo: g.HandNo,
Pending: g.Pending,
Pot: g.Pot,
Ante: g.Tier.Ante,
BoughtIn: g.BoughtIn, // a table total; the caller overrides it with this seat's own stake
Phase: string(g.Phase),
Winner: g.Winner,
LastPot: g.LastPot,
Rake: g.Rake,
Outcome: string(g.Outcome),
}
// An empty hand is a seat that went out — *unless* the mercy rule took it, in
// which case an empty hand is a grave. Those two look identical from the count
// alone, which is why a buried seat is asked about rather than inferred.
for i, n := range g.Counts() {
if viewer >= 0 && viewer < len(g.Seats) {
v.Stack = g.Seats[viewer].Stack
}
counts := g.Counts()
for i := range g.Seats {
p := g.Seats[i]
live := g.Live(i)
seat := unoSeatView{
Cards: n, You: i == uno.You, Uno: live && n == 1, Out: !live,
Name: p.Name,
Bot: p.Bot,
You: i == viewer,
Cards: counts[i],
Stack: p.Stack,
Ante: p.Ante,
Uno: live && counts[i] == 1,
Called: i < len(g.Called) && g.Called[i],
}
if i == uno.You {
seat.Name = "You"
} else if i-1 < len(g.Bots) {
seat.Name = g.Bots[i-1]
Out: !live,
}
v.Seats = append(v.Seats, seat)
if live && n == 0 {
v.Winner = i
}
// The wall. Only the viewer's own hand crosses the wire.
if viewer >= 0 && viewer < len(g.Hands) {
for _, c := range g.Hands[viewer] {
v.Hand = append(v.Hand, viewUnoCard(c))
}
}
// And you can win a No Mercy table without ever going out: outlive it, and the
// last seat standing is you, with a hand still in it.
if v.Winner < 0 && g.Outcome.Won() {
v.Winner = uno.You
if v.Hand == nil {
v.Hand = []unoCardView{}
}
for _, c := range g.Hands[uno.You] {
v.Hand = append(v.Hand, viewUnoCard(c))
}
v.Playable = g.Playable()
// Empty arrays, never null: the felt indexes into these.
v.Playable = g.Playable(viewer)
if v.Playable == nil {
v.Playable = []int{}
}
// Empty arrays, never null: the table indexes into these, and `null.indexOf`
// is a broken game rather than a quiet no-op.
v.UnoAt = g.UnoAt()
v.UnoAt = g.UnoAt(viewer)
if v.UnoAt == nil {
v.UnoAt = []int{}
}
v.Catchable = g.Catchable()
v.Catchable = g.Catchable(viewer)
if v.Catchable == nil {
v.Catchable = []int{}
}
return v
}
// unoEventView is one beat of the script the table plays back: a card going
// down, a seat eating a +4, the turn coming round. The engine's own events carry
// engine types, so they are re-rendered here rather than shipped raw — and this
// is also the wall where a bot's drawn card is dropped on the floor.
// unoEventView is one beat of the script the felt plays back. The engine attaches
// every seat's hand and drawn cards; this is the wall where the ones the viewer
// isn't entitled to are stripped.
type unoEventView struct {
Kind string `json:"kind"`
Seat int `json:"seat"`
Card *unoCardView `json:"card,omitempty"`
Color string `json:"color,omitempty"`
N int `json:"n,omitempty"`
Left int `json:"left"` // never omitempty: a seat that goes out leaves zero, and zero is the number the table has to draw
By int `json:"by"` // who did the catching. Seat zero is you, and "you" is a real answer, so never omitempty either
Text string `json:"text,omitempty"`
// Hand is your hand as it stands after this event, on the events that changed
// it. The table redraws your fan from this as the lap plays back — see the
// engine's Event.Hand for why. It is your own hand, so there is nothing here
// the browser wasn't already holding.
Hand []unoCardView `json:"hand,omitempty"`
Kind string `json:"kind"`
Seat int `json:"seat"`
Card *unoCardView `json:"card,omitempty"`
Color string `json:"color,omitempty"`
N int `json:"n,omitempty"`
Left int `json:"left"`
By int `json:"by"`
Text string `json:"text,omitempty"`
Hand []unoCardView `json:"hand,omitempty"`
}
func viewUnoEvents(evs []uno.Event) []unoEventView {
func viewUnoEvents(evs []uno.Event, viewer int) []unoEventView {
out := make([]unoEventView, 0, len(evs))
for _, e := range evs {
v := unoEventView{Kind: e.Kind, Seat: e.Seat, N: e.N, Left: e.Left, By: e.By, Text: e.Text}
if e.Color != uno.Wild {
v.Color = e.Color.String()
}
// The engine only stamps a hand on an event about seat zero. This is the belt
// to that brace: whatever the engine thinks it's doing, no hand but yours
// leaves this building.
if e.Seat == uno.You && e.Hand != nil {
// A hand rides an event only if it is the viewer's own. The engine stamps every
// seat's hand; this strips the rest.
if e.Seat == viewer && e.Hand != nil {
v.Hand = make([]unoCardView, 0, len(e.Hand))
for _, c := range e.Hand {
v.Hand = append(v.Hand, viewUnoCard(c))
}
}
// A card rides an event only if it is a card played face up, or one the viewer
// drew. A bot's (or another human's) drawn card never carries a face.
if e.Card != nil {
// The engine only ever attaches a card to an event the seat is entitled
// to see it in — a card played face up, or one *you* drew. This check is
// the belt to that pair of braces: a bot's draw never carries a face,
// whatever the engine thinks it's doing.
if e.Kind == uno.EvDraw || e.Kind == uno.EvForced {
if e.Seat == uno.You {
if e.Seat == viewer {
c := viewUnoCard(*e.Card)
v.Card = &c
}
@@ -209,52 +210,227 @@ func viewUnoEvents(evs []uno.Event) []unoEventView {
return out
}
// handleUnoStart takes the bet and deals. Same order as every other table: the
// chips are staked first, in the same statement that checks they exist, so two
// deals fired at once cannot bet the same chip.
func (s *Server) handleUnoStart(w http.ResponseWriter, r *http.Request) {
// ---- sitting down ----------------------------------------------------------
// unoSeatRows mirrors the engine's seats into the storage rows that shadow them,
// index for index. A human's staked is the buy-in that crossed the border; a
// bot's is zero.
func unoSeatRows(g uno.State, human string, buyIn int64) []storage.Seat {
rows := make([]storage.Seat, len(g.Seats))
for i := range g.Seats {
p := g.Seats[i]
row := storage.Seat{Seat: i, Name: p.Name}
if !p.Bot {
row.MatrixUser = human
row.Staked = buyIn
}
rows[i] = row
}
return rows
}
// handleUnoSit seats a player at a fresh table of their own or at an open chair on
// somebody else's.
func (s *Server) handleUnoSit(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req struct {
Bet int64 `json:"bet"`
Tier string `json:"tier"`
Tier string `json:"tier"`
BuyIn int64 `json:"buyin"`
Table string `json:"table"`
Seat *int `json:"seat"`
}
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
if err := decodeJSON(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
tier, err := uno.TierBySlug(req.Tier)
if req.Table != "" {
s.joinUno(w, r, user, req.Table, req.Seat, req.BuyIn)
return
}
s.openUno(w, r, user, req.Tier, req.BuyIn)
}
// openUno opens a fresh table with the player in seat zero and bots in the rest —
// the old solo flow, now a real shared table with no other humans on it yet.
func (s *Server) openUno(w http.ResponseWriter, r *http.Request, user, tierSlug string, buyIn int64) {
tier, err := uno.TierBySlug(tierSlug)
if err != nil {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
return
}
if err := storage.Stake(user, req.Bet); err != nil {
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
return
}
slog.Error("games: uno stake", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
if buyIn < tier.MinBuy || buyIn > tier.MaxBuy {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that isn't a legal buy-in for this table"})
return
}
name := s.displayName(r, user)
seed1, seed2 := newSeeds()
g, evs, err := uno.New(req.Bet, tier, blackjack.DefaultRules().RakePct, seed1, seed2)
g, _, err := uno.New(tier, uno.TableSeats(tier, name, tier.Bots, buyIn), blackjack.DefaultRules().RakePct, seed1, seed2)
if err != nil {
// The game never happened, so the stake never should have left.
_ = storage.Award(user, req.Bet)
slog.Error("games: uno deal", "user", user, "err", err)
slog.Error("games: uno open", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.persistUno(w, user, g, evs, seed1, seed2, true)
blob, err := json.Marshal(g)
if err != nil {
slog.Error("games: marshal new uno", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
id, err := storage.NewTableID()
if err != nil {
slog.Error("games: mint table id", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
t := storage.Table{
ID: id, Game: gameUno, Tier: tier.Slug, State: blob,
Seed1: seed1, Seed2: seed2, Phase: string(g.Phase), HandNo: int64(g.HandNo),
}
err = storage.OpenSoloTable(t, unoSeatRows(g, user, buyIn), buyIn)
switch {
case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"})
return
case errors.Is(err, storage.ErrHandInProgress):
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
return
case err != nil:
slog.Error("games: open solo uno table", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.writeUnoTable(w, user, nil)
}
// handleUnoMove plays one turn: a card, a draw, or passing on the card you drew.
// The bots' turns come back with it.
// pickOpenUnoSeat chooses a chair to join: the one asked for if it is a bot's,
// otherwise the first bot seat. Returns -1 if there is nowhere to sit.
func pickOpenUnoSeat(g uno.State, want *int) int {
if want != nil {
i := *want
if i >= 0 && i < len(g.Seats) && g.Seats[i].Bot {
return i
}
return -1
}
for i := range g.Seats {
if g.Seats[i].Bot {
return i
}
}
return -1
}
// joinUno sits a player at an open chair on an existing table, one transaction
// under the table lock, so two people racing for the last seat cannot both win it.
func (s *Server) joinUno(w http.ResponseWriter, r *http.Request, user, tableID string, wantSeat *int, buyIn int64) {
name := s.displayName(r, user)
var respErr error
err := s.tableLocks.withTable(tableID, func() error {
t, _, err := storage.LoadTable(tableID)
if errors.Is(err, storage.ErrNoSuchTable) {
respErr = storage.ErrNoSuchTable
return nil
}
if err != nil {
return err
}
if t.Game != gameUno {
respErr = uno.ErrUnknownMove
return nil
}
var g uno.State
if err := json.Unmarshal(t.State, &g); err != nil {
return err
}
if unoPlaying(g) {
respErr = uno.ErrHandLive // you join between hands
return nil
}
seat := pickOpenUnoSeat(g, wantSeat)
if seat < 0 {
respErr = uno.ErrTableFull
return nil
}
if err := g.Occupy(seat, name, buyIn); err != nil {
respErr = err
return nil
}
blob, err := json.Marshal(g)
if err != nil {
return err
}
t.State, t.Phase, t.HandNo = blob, string(g.Phase), int64(g.HandNo)
err = storage.SitDown(storage.Sit{
Table: t,
Seat: storage.Seat{Seat: seat, MatrixUser: user, Name: name, Staked: buyIn},
BuyIn: buyIn,
})
switch {
case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
respErr = storage.ErrInsufficientChips
return nil
case errors.Is(err, storage.ErrHandInProgress):
respErr = storage.ErrHandInProgress
return nil
case errors.Is(err, storage.ErrSeatTaken), errors.Is(err, storage.ErrStaleTable):
respErr = storage.ErrSeatTaken
return nil
case err != nil:
return err
}
s.publishTable(tableID)
return nil
})
if err != nil {
slog.Error("games: join uno", "user", user, "table", tableID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if respErr != nil {
writeJSONStatus(w, unoJoinStatus(respErr), map[string]string{"error": unoJoinMessage(respErr)})
return
}
s.writeUnoTable(w, user, nil)
}
func unoJoinStatus(err error) int {
switch {
case errors.Is(err, storage.ErrNoSuchTable), errors.Is(err, uno.ErrTableFull),
errors.Is(err, storage.ErrSeatTaken), errors.Is(err, uno.ErrHandLive):
return http.StatusConflict
default:
return http.StatusBadRequest
}
}
func unoJoinMessage(err error) string {
switch {
case errors.Is(err, storage.ErrNoSuchTable):
return "that table has closed"
case errors.Is(err, uno.ErrTableFull), errors.Is(err, storage.ErrSeatTaken):
return "that seat is taken"
case errors.Is(err, uno.ErrHandLive):
return "a hand is in play — sit down when it's over"
case errors.Is(err, uno.ErrBadBuyIn):
return "that isn't a legal buy-in for this table"
case errors.Is(err, storage.ErrInsufficientChips):
return "not enough chips to sit down"
case errors.Is(err, storage.ErrHandInProgress):
return "finish the game you're in first"
default:
return "you can't sit there"
}
}
// ---- playing a hand --------------------------------------------------------
// handleUnoMove plays one move at the player's table: a hand move, or dealing the
// next hand. Leaving is its own endpoint.
func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
@@ -265,80 +441,237 @@ func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if move.Kind == uno.MoveLeave {
s.leaveUno(w, user)
return
}
live, err := storage.LoadLiveHand(user)
tableID, seat, err := storage.PlayerSeat(user)
if errors.Is(err, storage.ErrNoLiveHand) {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"})
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
return
}
if err != nil {
slog.Error("games: uno load", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if live.Game != gameUno {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
return
}
var g uno.State
if err := json.Unmarshal(live.State, &g); err != nil {
slog.Error("games: unreadable uno game", "user", user, "err", err)
slog.Error("games: uno move seat", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
next, evs, err := uno.ApplyMove(g, move)
if err != nil {
// The refusals a player can actually cause, said in words rather than as
// "that move isn't legal here" — which, in a game with this many rules, is
// the table refusing to explain itself.
msg := "that move isn't legal here"
switch {
case errors.Is(err, uno.ErrCantPlay):
msg = "that card doesn't go on this one"
case errors.Is(err, uno.ErrNeedColor):
msg = "pick a colour for the wild"
case errors.Is(err, uno.ErrMustPlayNow):
msg = "play the card you drew, or pass"
case errors.Is(err, uno.ErrCantPass):
msg = "draw first, then you can pass"
case errors.Is(err, uno.ErrMustStack):
msg = "answer the stack with a draw card, or take it"
case errors.Is(err, uno.ErrNoStack):
msg = "there's nothing pointed at you to take"
case errors.Is(err, uno.ErrNoCatch):
msg = "there's nobody in that seat to catch"
var respErr error
var respEvents []uno.Event
err = s.tableLocks.withTable(tableID, func() error {
t, seats, err := storage.LoadTable(tableID)
if errors.Is(err, storage.ErrNoSuchTable) {
respErr = storage.ErrNoSuchTable
return nil
}
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
if err != nil {
return err
}
var g uno.State
if err := json.Unmarshal(t.State, &g); err != nil {
return err
}
next, evs, aerr := uno.ApplyMove(g, seat, move)
if aerr != nil {
respErr = aerr
return nil
}
// A solo session that just ended (the one human got up or busted at the deal)
// is not a table any more: cash the seat out and close the felt. Only a solo
// table reaches PhaseDone; a shared table plays on.
if next.Phase == uno.PhaseDone {
if err := s.settleUnoLeave(t, next, seat, user); err != nil {
if errors.Is(err, storage.ErrStaleTable) {
respErr = storage.ErrStaleTable
return nil
}
return err
}
respEvents = evs
s.publishTable(tableID)
return nil
}
st, err := unoStep(next, evs, seats)
if err != nil {
return err
}
acting := seats[seat]
acting.Away = false
acting.LastSeen = time.Now().Unix()
t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
if err := storage.CommitTable(storage.TableCommit{
Table: t, Seats: []storage.Seat{acting}, Audit: st.Audit,
}); err != nil {
if errors.Is(err, storage.ErrStaleTable) {
respErr = storage.ErrStaleTable
return nil
}
return err
}
respEvents = evs
s.publishTable(tableID)
return nil
})
if err != nil {
slog.Error("games: uno move", "user", user, "table", tableID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.persistUno(w, user, next, evs, live.Seed1, live.Seed2, false)
if respErr != nil {
writeJSONStatus(w, unoMoveStatus(respErr), map[string]string{"error": unoMoveMessage(respErr)})
return
}
s.writeUnoTable(w, user, respEvents)
}
// persistUno writes the game back and answers the browser.
func (s *Server) persistUno(w http.ResponseWriter, user string, g uno.State, evs []uno.Event, seed1, seed2 uint64, fresh bool) {
blob, err := json.Marshal(g)
if err != nil {
slog.Error("games: marshal uno", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
func unoMoveStatus(err error) int {
switch {
case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable):
return http.StatusConflict
default:
return http.StatusBadRequest
}
done := g.Phase == uno.PhaseDone
v, ok := s.commit(w, user, finished{
Game: gameUno, Blob: blob,
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
Outcome: string(g.Outcome), Done: done,
Seed1: seed1, Seed2: seed2, Fresh: fresh,
})
}
func unoMoveMessage(err error) string {
switch {
case errors.Is(err, storage.ErrStaleTable):
return "the table moved on — take another look"
case errors.Is(err, storage.ErrNoSuchTable):
return "that table has closed"
case errors.Is(err, uno.ErrHandLive):
return "finish the hand first"
case errors.Is(err, uno.ErrNoHand):
return "there's no hand in play — deal one"
case errors.Is(err, uno.ErrNotYourTurn):
return "it isn't your turn"
case errors.Is(err, uno.ErrCantPlay):
return "that card doesn't go on this one"
case errors.Is(err, uno.ErrNeedColor):
return "pick a colour for the wild"
case errors.Is(err, uno.ErrMustPlayNow):
return "play the card you drew, or pass"
case errors.Is(err, uno.ErrCantPass):
return "draw first, then you can pass"
case errors.Is(err, uno.ErrMustStack):
return "answer the stack with a draw card, or take it"
case errors.Is(err, uno.ErrNoStack):
return "there's nothing pointed at you to take"
case errors.Is(err, uno.ErrNoCatch):
return "there's nobody in that seat to catch"
default:
return "that move isn't legal here"
}
}
// ---- getting up ------------------------------------------------------------
// handleUnoLeave is the get-up endpoint. Leaving is its own route because it is a
// storage operation, not an engine move — the chips cross the border and the felt
// may close.
func (s *Server) handleUnoLeave(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
// A finished game is gone from storage, so the table has none to show — but the
// browser still needs the final board to land the verdict on.
if done {
uv := viewUno(g)
v.Uno = &uv
s.leaveUno(w, user)
}
// leaveUno gets a player up from their table, turning what is in front of them
// back into chips. It refuses mid-hand and closes the felt behind the last human.
func (s *Server) leaveUno(w http.ResponseWriter, user string) {
tableID, seat, err := storage.PlayerSeat(user)
if errors.Is(err, storage.ErrNoLiveHand) {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
return
}
if err != nil {
slog.Error("games: uno leave seat", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var respErr error
err = s.tableLocks.withTable(tableID, func() error {
t, _, err := storage.LoadTable(tableID)
if errors.Is(err, storage.ErrNoSuchTable) {
respErr = storage.ErrNoSuchTable
return nil
}
if err != nil {
return err
}
var g uno.State
if err := json.Unmarshal(t.State, &g); err != nil {
return err
}
if unoPlaying(g) {
respErr = uno.ErrHandLive
return nil
}
if err := s.settleUnoLeave(t, g, seat, user); err != nil {
if errors.Is(err, storage.ErrStaleTable) {
respErr = storage.ErrStaleTable
return nil
}
return err
}
s.publishTable(tableID)
return nil
})
if err != nil {
slog.Error("games: uno leave", "user", user, "table", tableID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if respErr != nil {
writeJSONStatus(w, unoMoveStatus(respErr), map[string]string{"error": unoMoveMessage(respErr)})
return
}
s.writeUnoTable(w, user, nil)
}
// settleUnoLeave vacates a seat, credits the stack home, and closes the table if
// nobody human is left — all inside the caller's lock.
func (s *Server) settleUnoLeave(t storage.Table, g uno.State, seat int, user string) error {
home, err := g.Vacate(seat)
if err != nil {
return err
}
blob, err := json.Marshal(g)
if err != nil {
return err
}
t.State, t.Phase, t.HandNo, t.Deadline = blob, string(g.Phase), int64(g.HandNo), 0
if err := storage.LeaveTable(storage.Leave{
Table: t, Seat: seat, MatrixUser: user, Bot: g.Seats[seat].Name, Amount: home,
}); err != nil {
return err
}
return storage.CloseTable(t.ID)
}
// ---- the response ----------------------------------------------------------
// writeUnoTable answers with the whole page state — the money and the table as the
// player's own seat may see it — plus, when a move produced one, the redacted event
// script for that seat to animate.
func (s *Server) writeUnoTable(w http.ResponseWriter, user string, evs []uno.Event) {
v, err := s.table(user)
if err != nil {
slog.Error("games: uno table", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if len(evs) > 0 {
if _, seat, serr := storage.PlayerSeat(user); serr == nil {
v.UnoEvents = viewUnoEvents(evs, seat)
}
}
v.UnoEvents = viewUnoEvents(evs)
writeJSON(w, v)
}

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

View File

@@ -4,57 +4,54 @@ import (
"testing"
"pete/internal/games/uno"
"pete/internal/storage"
)
// The one thing this table cannot get wrong: the stake leaves the stack, and no
// card a player is not entitled to see leaves the server. At UNO that is not one
// hole card — it is the deck and every bot's hand, which is most of the game.
func TestUnoStartTakesTheStakeAndKeepsTheCards(t *testing.T) {
// Sitting down is the only time chips leave your stack at this table, and getting
// up is the only time they come back. The ante and the pot move inside the engine.
func TestUnoSitTakesTheBuyIn(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
fund(t, 5000)
v, code := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
map[string]any{"bet": 100, "tier": "full"}))
v, code := call(t, s, s.handleUnoSit, as(t, s, "reala", "POST", "/api/games/uno/sit",
map[string]any{"tier": "full", "buyin": 1000}))
if code != 200 {
t.Fatalf("start = %d, want 200", code)
t.Fatalf("sit = %d, want 200", code)
}
if v.Chips != 900 {
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
if v.Chips != 4000 {
t.Fatalf("chips after a 1000 buy-in = %d, want 4000", v.Chips)
}
if v.Game != gameUno || v.Uno == nil {
t.Fatalf("start returned no uno game: game=%q", v.Game)
t.Fatalf("sit returned no table: game=%q", v.Game)
}
g := v.Uno
if len(g.Hand) != uno.HandSize {
t.Errorf("you were dealt %d cards, want %d", len(g.Hand), uno.HandSize)
if g.Stack != 1000 {
t.Errorf("you sat down with %d, want the 1000 you bought in for", g.Stack)
}
if len(g.Seats) != 4 {
t.Fatalf("a full house is four seats, got %d", len(g.Seats))
t.Fatalf("three bots and you is four seats, got %d", len(g.Seats))
}
// A bot is a name and a number. There is no field here that could carry a
// card, which is the point — this is a compile-time guarantee, and the test
// exists to make deleting it loud.
for i, seat := range g.Seats {
if i == 0 {
if !seat.You || seat.Name != "You" {
t.Errorf("seat 0 should be you: %+v", seat)
}
continue
}
if seat.You || seat.Name == "" {
t.Errorf("seat %d should be a named bot: %+v", i, seat)
}
if seat.Cards != uno.HandSize {
t.Errorf("seat %d holds %d cards, want %d", i, seat.Cards, uno.HandSize)
}
if g.Phase != "handover" {
t.Errorf("phase %q — a table you just sat at has no hand on it yet", g.Phase)
}
if g.Top.Value == "" {
t.Error("no card in play")
// Deal a hand: chips do not move (the ante is within the blob), and you are dealt
// seven cards and to act.
deal, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "deal"}))
if code != 200 {
t.Fatalf("deal = %d, want 200", code)
}
if g.Turn != 0 {
t.Errorf("you play first, turn is %d", g.Turn)
if deal.Chips != 4000 {
t.Errorf("chips moved on the deal: %d, want 4000 — the ante is inside the engine", deal.Chips)
}
if deal.Uno == nil || len(deal.Uno.Hand) != uno.HandSize {
t.Fatalf("you were dealt the wrong hand: %+v", deal.Uno)
}
if deal.Uno.Turn != 0 {
t.Errorf("you act first at your own table, turn is %d", deal.Uno.Turn)
}
if deal.Uno.Pot != deal.Uno.Ante*int64(len(deal.Uno.Seats)) {
t.Errorf("pot is %d, want one ante per seat", deal.Uno.Pot)
}
}
@@ -62,16 +59,16 @@ func TestUnoStartTakesTheStakeAndKeepsTheCards(t *testing.T) {
// back never carries a bot's drawn card.
func TestUnoMovePlaysTheWholeLap(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
fund(t, 5000)
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
map[string]any{"bet": 100, "tier": "table"}))
if v.Uno == nil {
t.Fatal("no game")
call(t, s, s.handleUnoSit, as(t, s, "reala", "POST", "/api/games/uno/sit",
map[string]any{"tier": "table", "buyin": 1000}))
deal, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "deal"}))
if code != 200 || deal.Uno == nil {
t.Fatalf("deal = %d", code)
}
// Draw, which is legal from any hand: the turn passes to the bots and comes
// back, unless the card drawn happens to be playable.
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "draw"}))
if code != 200 {
@@ -93,110 +90,144 @@ func TestUnoMovePlaysTheWholeLap(t *testing.T) {
}
}
// You cannot play a wild without naming a colour — and the zero value of the
// colour field is not red, so a move that simply omits it is refused rather than
// quietly played as one.
func TestUnoWildWithNoColourIsRefused(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
// TestUnoViewNeverLeaksAnotherSeatsCards is the security boundary of the whole
// multiplayer table. There is no showdown in UNO, so the rule is absolute: no
// seat's hand, and no card a seat drew, ever reaches another viewer. Rendered for
// every seat, after every step of a hand played to its end.
//
// After SSE the view a seat renders fans to that seat's stream, so a single missed
// redaction broadcasts a hand to the whole felt. The engine emits every seat's
// cards (it cannot know who a shared stream is for), which makes viewUno and
// viewUnoEvents the only thing between the deck and the network tab.
func TestUnoViewNeverLeaksAnotherSeatsCards(t *testing.T) {
tier, _ := uno.TierBySlug("full")
// Two humans (0, 2) and two bots (1, 3), so the test covers a viewer seeing both
// another human and a bot, and a human at a non-zero index.
seats := []uno.SeatConfig{
{Name: "Ana", Stack: 2000},
{Name: "Bot A", Bot: true, Stack: 2000},
{Name: "Bo", Stack: 2000},
{Name: "Bot B", Bot: true, Stack: 2000},
}
g, _, err := uno.New(tier, seats, tier.RakePct, 5, 6)
if err != nil {
t.Fatal(err)
}
g, evs, err := uno.ApplyMove(g, 0, uno.Move{Kind: uno.MoveDeal})
if err != nil {
t.Fatal(err)
}
assertUnoRedacted(t, g, evs)
// Deal until a wild lands in the opening hand: it's four cards in 108, so it
// doesn't take long, and this is the only way to get one without rigging the
// deck through a door the server doesn't have.
var wild = -1
for try := 0; try < 40 && wild < 0; try++ {
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
map[string]any{"bet": 10, "tier": "duel"}))
if v.Uno == nil {
t.Fatal("no game")
for step := 0; unoPlaying(g) && step < 200; step++ {
seat := g.Turn
if g.Seats[seat].Bot {
t.Fatalf("advance stopped on a bot at seat %d", seat)
}
for i, c := range v.Uno.Hand {
if c.Wild {
wild = i
break
}
move := unoHumanMove(g, seat)
var stepEvs []uno.Event
g, stepEvs, err = uno.ApplyMove(g, seat, move)
if err != nil {
t.Fatalf("seat %d %s: %v", seat, move.Kind, err)
}
if wild < 0 {
// Abandon this deal and take another. The live row is keyed on the
// player, so it has to go before the next start can be seated.
if err := storage.ClearLiveHand(testPlayer); err != nil {
t.Fatal(err)
}
}
}
if wild < 0 {
t.Skip("40 deals and not one wild — vanishingly unlikely, but not a failure")
}
_, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "play", "index": wild}))
if code != 400 {
t.Fatalf("a wild with no colour = %d, want 400", code)
}
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "play", "index": wild, "color": int(uno.Green)}))
if code != 200 {
t.Fatalf("a wild played as green = %d, want 200", code)
}
if v.Uno != nil && v.Uno.Phase != "done" && v.Uno.Color != "green" && v.Uno.Color != "" {
// The bots have played since, so the colour may have moved on — what must
// not happen is the wild going down as anything we didn't ask for.
t.Logf("colour in play after the bots: %s", v.Uno.Color)
assertUnoRedacted(t, g, stepEvs)
}
}
// A seat the mercy rule has buried holds no cards. So does a seat that has just
// gone out and won. The view has to tell them apart, because everything it says
// afterwards hangs off it: who won, whose fan of cards to rub out, and whether the
// player is looking at a payout or a grave.
//
// This is the whole reason the view asks the engine (Live) instead of inferring it
// from a count of zero.
func TestABuriedSeatIsNotTheWinner(t *testing.T) {
g, _, err := uno.New(100, mustTier(t, "nm-full"), 0.05, 7, 9)
// unoHumanMove picks a legal move for the human to act: play a legal card, take a
// stack you can't answer, pass a drawn card, otherwise draw.
func unoHumanMove(g uno.State, seat int) uno.Move {
if p := g.Playable(seat); len(p) > 0 {
m := uno.Move{Kind: uno.MovePlay, Index: p[0], Color: uno.Red}
for _, at := range g.UnoAt(seat) {
if at == p[0] {
m.Uno = true
}
}
return m
}
switch g.Phase {
case uno.PhaseStack:
return uno.Move{Kind: uno.MoveTake}
case uno.PhaseDrawn:
return uno.Move{Kind: uno.MovePass}
default:
return uno.Move{Kind: uno.MoveDraw}
}
}
// assertUnoRedacted renders every seat's view and event script and fails if any of
// them carries a card belonging to another seat.
func assertUnoRedacted(t *testing.T, g uno.State, evs []uno.Event) {
t.Helper()
for viewer := range g.Seats {
v := viewUno(g, viewer)
// The view's hand is the viewer's own, exactly — no more, no fewer.
if len(v.Hand) != len(g.Hands[viewer]) {
t.Fatalf("seat %d's view carries %d cards, but the seat holds %d",
viewer, len(v.Hand), len(g.Hands[viewer]))
}
// The event script: a hand rides only the viewer's own beat, and a drawn card
// only the viewer's own draw. (A card a bot plays is public — it lands on the
// pile — so only a *drawn* card is a secret, which is why this is scoped to the
// draw and forced beats.) Undo either guard in viewUnoEvents and this fails.
for _, e := range viewUnoEvents(evs, viewer) {
if len(e.Hand) > 0 && e.Seat != viewer {
t.Fatalf("seat %d's event stream carries seat %d's hand", viewer, e.Seat)
}
if e.Card != nil && (e.Kind == uno.EvDraw || e.Kind == uno.EvForced) && e.Seat != viewer {
t.Fatalf("seat %d's event stream carries seat %d's drawn card", viewer, e.Seat)
}
}
}
}
// A seat the mercy rule has buried holds no cards; so does one that just went out.
// The view tells them apart by asking the engine (Live), not by inferring from a
// count of zero.
func TestABuriedSeatIsRenderedOut(t *testing.T) {
tier, _ := uno.TierBySlug("nm-full")
g, _, err := uno.New(tier, uno.SoloSeats(tier, tier.Bots, 1000), 0.05, 7, 9)
if err != nil {
t.Fatal(err)
}
g, _, err = uno.ApplyMove(g, 0, uno.Move{Kind: uno.MoveDeal})
if err != nil {
t.Fatal(err)
}
// Bury seat 2, the way the engine does: no cards, and out.
// Bury seat 2 the way the engine does: no cards, and out.
g.Hands[2] = nil
g.Out[2] = true
v := viewUno(g)
v := viewUno(g, 0)
if !v.Seats[2].Out {
t.Error("a buried seat must say so; the felt draws it as a grave")
}
if v.Seats[2].Uno {
t.Error("a buried seat is not one card away from going out")
}
if v.Winner == 2 {
t.Fatal("the buried seat was reported as the winner — an empty hand is not a win")
}
if v.Winner != -1 {
t.Errorf("nobody has gone out yet, so there is no winner: got %d", v.Winner)
}
// And the other ending only this deck has: you win by outliving the table, with
// a hand still in front of you.
g.Phase, g.Outcome, g.Payout = uno.PhaseDone, uno.OutcomeWon, g.Pays()
if v := viewUno(g); v.Winner != uno.You {
t.Errorf("you outlived the table; winner = %d, want you (%d)", v.Winner, uno.You)
t.Errorf("nobody has taken the pot mid-hand, so there is no winner: got %d", v.Winner)
}
}
// The bill a stack has run up is the only thing on the table while it stands, so it
// has to reach the browser. It is a No Mercy field on a struct the normal game
// shares, which is exactly the kind of field that quietly never gets copied across.
// has to reach the browser.
func TestTheStackBillCrossesTheWire(t *testing.T) {
g, _, err := uno.New(100, mustTier(t, "nm-duel"), 0.05, 3, 4)
tier, _ := uno.TierBySlug("nm-duel")
g, _, err := uno.New(tier, uno.SoloSeats(tier, tier.Bots, 1000), 0.05, 3, 4)
if err != nil {
t.Fatal(err)
}
g, _, err = uno.ApplyMove(g, 0, uno.Move{Kind: uno.MoveDeal})
if err != nil {
t.Fatal(err)
}
g.Pending = 6
g.Phase = uno.PhaseStack
v := viewUno(g)
v := viewUno(g, 0)
if v.Pending != 6 {
t.Errorf("the felt was told the bill is %d, want 6", v.Pending)
}
@@ -204,12 +235,3 @@ func TestTheStackBillCrossesTheWire(t *testing.T) {
t.Errorf("phase = %q, want %q", v.Phase, uno.PhaseStack)
}
}
func mustTier(t *testing.T, slug string) uno.Tier {
t.Helper()
tier, err := uno.TierBySlug(slug)
if err != nil {
t.Fatalf("no tier %q: %v", slug, err)
}
return tier
}

View File

@@ -144,7 +144,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
live = append(live, ch)
}
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}}}
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}, unoTable{}}}
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
// provider is unreachable at boot we log and serve anonymously rather than

View File

@@ -174,11 +174,11 @@
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{.Ante}} ante</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · seven cards each
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · winner takes the pot
</p>
</button>
{{end}}
@@ -192,7 +192,7 @@
class="pete-tier pete-tier-nm rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{.Ante}} ante</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">