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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user