A code review of the uno table found the stuck guard had never once fired. It counted how many bots had passed in a row and wanted more of them than there are seats — but the bot loop hands the turn back the moment it comes round to you, so the count could never get there, and your own empty-handed pass was never in it. A dead table just passed the turn round forever. That is not an ugly ending, it's a game you cannot finish, and a game you cannot finish is chips you cannot cash out. So it asks the real question now: is there anything to draw, and is anyone holding a card that goes. And the table let go of itself too early. busy came off when the request landed, not when the script it came back with had finished playing — so for the seconds a bot lap takes, you could click a card at a board the server had already moved past. It comes off at the end now, like the other tables. Also: left: 0 was being dropped on its way out the door, which is the one number that matters (the seat that just went out), the deck counter didn't come back after a reshuffle, and hoisting fly() into flyNode() had quietly flattened the chip arc on every other table in the room. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
285 lines
9.2 KiB
Go
285 lines
9.2 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"pete/internal/games/blackjack"
|
|
"pete/internal/games/uno"
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// UNO, played for chips against bots.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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.
|
|
|
|
// 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.
|
|
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
|
|
}
|
|
|
|
func viewUnoCard(c uno.Card) unoCardView {
|
|
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.
|
|
type unoSeatView struct {
|
|
Name string `json:"name"`
|
|
Cards int `json:"cards"`
|
|
You bool `json:"you"`
|
|
Uno bool `json:"uno"` // down to one card
|
|
}
|
|
|
|
// unoView is a game as its player may see it.
|
|
type unoView struct {
|
|
Tier uno.Tier `json:"tier"`
|
|
Seats []unoSeatView `json:"seats"`
|
|
|
|
Hand []unoCardView `json:"hand"` // yours, and only yours
|
|
Playable []int `json:"playable"` // which of them can legally go down
|
|
Top unoCardView `json:"top"` // the card in play
|
|
Color string `json:"color"` // the colour in play, which a wild renames
|
|
Deck int `json:"deck"` // cards left to draw
|
|
|
|
Turn int `json:"turn"`
|
|
Dir int `json:"dir"`
|
|
|
|
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"`
|
|
Winner int `json:"winner"`
|
|
Payout int64 `json:"payout,omitempty"`
|
|
Rake int64 `json:"rake,omitempty"`
|
|
Net int64 `json:"net"`
|
|
}
|
|
|
|
func viewUno(g uno.State) unoView {
|
|
v := unoView{
|
|
Tier: g.Tier,
|
|
Top: viewUnoCard(g.Top()),
|
|
Color: g.Color.String(),
|
|
Deck: g.Left(),
|
|
Turn: g.Turn,
|
|
Dir: g.Dir,
|
|
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(),
|
|
}
|
|
for i, n := range g.Counts() {
|
|
seat := unoSeatView{Cards: n, You: i == uno.You, Uno: n == 1}
|
|
if i == uno.You {
|
|
seat.Name = "You"
|
|
} else if i-1 < len(g.Bots) {
|
|
seat.Name = g.Bots[i-1]
|
|
}
|
|
v.Seats = append(v.Seats, seat)
|
|
if n == 0 {
|
|
v.Winner = i
|
|
}
|
|
}
|
|
for _, c := range g.Hands[uno.You] {
|
|
v.Hand = append(v.Hand, viewUnoCard(c))
|
|
}
|
|
v.Playable = g.Playable()
|
|
if v.Playable == nil {
|
|
v.Playable = []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.
|
|
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
|
|
Text string `json:"text,omitempty"`
|
|
}
|
|
|
|
func viewUnoEvents(evs []uno.Event) []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, Text: e.Text}
|
|
if e.Color != uno.Wild {
|
|
v.Color = e.Color.String()
|
|
}
|
|
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 {
|
|
c := viewUnoCard(*e.Card)
|
|
v.Card = &c
|
|
}
|
|
} else {
|
|
c := viewUnoCard(*e.Card)
|
|
v.Card = &c
|
|
}
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
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) {
|
|
user, ok := s.player(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Bet int64 `json:"bet"`
|
|
Tier string `json:"tier"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
|
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
|
|
return
|
|
}
|
|
tier, err := uno.TierBySlug(req.Tier)
|
|
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)
|
|
return
|
|
}
|
|
|
|
seed1, seed2 := newSeeds()
|
|
g, evs, err := uno.New(req.Bet, tier, 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)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.persistUno(w, user, g, evs, seed1, seed2, true)
|
|
}
|
|
|
|
// handleUnoMove plays one turn: a card, a draw, or passing on the card you drew.
|
|
// The bots' turns come back with it.
|
|
func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := s.player(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var move uno.Move
|
|
if err := decodeJSON(r, &move); err != nil {
|
|
http.Error(w, "bad json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
live, err := storage.LoadLiveHand(user)
|
|
if errors.Is(err, storage.ErrNoLiveHand) {
|
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"})
|
|
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)
|
|
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"
|
|
}
|
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
|
return
|
|
}
|
|
s.persistUno(w, user, next, evs, live.Seed1, live.Seed2, false)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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,
|
|
})
|
|
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
|
|
}
|
|
v.UnoEvents = viewUnoEvents(evs)
|
|
writeJSON(w, v)
|
|
}
|