Three things, and the first one was a bug. Your own hand didn't move until the lap ended. bump() keeps the bots' fans honest and has always refused seat zero, and nothing else touched yours — so a +4 landing on you at the top of a lap put four backs into your hand and then nothing, and the cards themselves turned up seconds later when the script finished and paint() finally ran. You spent the whole lap looking at a hand you no longer held. The engine now stamps your hand onto every event that changes it (Event.Hand, seat zero only, which is the one hand the browser is already entitled to see) and the table redraws as the cards land. Measured in the running app: 2 -> 3 cards at 414ms into a 1791ms lap. You couldn't call UNO, and not because the button was missing: going down to one card *was* the call. discard() fired the uno event by itself, which made it a thing that happened to you rather than a thing you did, and a rule nobody can fail is not a rule. So now you say it or you don't (Move.Uno), and if you don't, every bot still in the game gets one look at you before any of them plays — because a bot that has moved on is a bot that has stopped watching your hand. It runs the other way too, and that half is the fun one: a bot forgets often enough to be worth watching for, and when it does it says *nothing*. No event, no badge, no tell on the felt except the count beside its fan reading "1 card". Catch it and it takes two; call a seat that had nothing to hide and you take two yourself, which is what stops the catch button from being a thing you simply mash. Which cards owe the call is the engine's answer, not a count of your hand: No Mercy's "discard all" takes every card of its colour with it, so a six-card hand can land on one, and a browser subtracting one from six walks you into a catch it never warned you about. And the room was silent. Every sound in here is *made* — an oscillator, a burst of filtered noise, an envelope — the same bargain the weather engine takes with its clouds. A card is a slap of noise through a bandpass, a chip is two detuned sines with a knock on the front, a win is four notes going up. No asset files, no round trips, and a sound can be pitched and detuned per call instead of being the same wav three hundred times. Hooked into the FX layer rather than into the games, so every table that throws a chip or turns a card got it at once. The multiples moved, and the test that exists to catch that caught it. The naive strategy now calls UNO, because calling is a button and not a strategy — what these tiers price is bad card play, not a player who ignores the felt shouting at them — and on that footing the normal tables come back to where they were (40.1 / 28.5 / 23.1). No Mercy Full House did not: it was paying a *negative* house edge, which is the house paying you to sit down. Re-priced 3.8 -> 3.5. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
345 lines
12 KiB
Go
345 lines
12 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
|
|
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
|
|
}
|
|
|
|
// 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
|
|
|
|
// 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"`
|
|
|
|
Turn int `json:"turn"`
|
|
Dir int `json:"dir"`
|
|
|
|
// 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"`
|
|
|
|
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,
|
|
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(),
|
|
}
|
|
// 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() {
|
|
live := g.Live(i)
|
|
seat := unoSeatView{
|
|
Cards: n, You: i == uno.You, Uno: live && n == 1, Out: !live,
|
|
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]
|
|
}
|
|
v.Seats = append(v.Seats, seat)
|
|
if live && n == 0 {
|
|
v.Winner = i
|
|
}
|
|
}
|
|
// 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
|
|
}
|
|
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{}
|
|
}
|
|
// 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()
|
|
if v.UnoAt == nil {
|
|
v.UnoAt = []int{}
|
|
}
|
|
v.Catchable = g.Catchable()
|
|
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.
|
|
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"`
|
|
}
|
|
|
|
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, 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 {
|
|
v.Hand = make([]unoCardView, 0, len(e.Hand))
|
|
for _, c := range e.Hand {
|
|
v.Hand = append(v.Hand, viewUnoCard(c))
|
|
}
|
|
}
|
|
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"
|
|
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"
|
|
}
|
|
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)
|
|
}
|