games: the word you owe the table, and the hand you were already holding

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
This commit is contained in:
prosolis
2026-07-14 13:15:11 -07:00
parent a4666866a8
commit 39ed293f4f
22 changed files with 1450 additions and 59 deletions

View File

@@ -45,11 +45,12 @@ func viewUnoCard(c uno.Card) unoCardView {
// 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
Out bool `json:"out"` // No Mercy: buried at twenty-five, and not coming back
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.
@@ -63,6 +64,14 @@ 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"`
Turn int `json:"turn"`
Dir int `json:"dir"`
@@ -104,7 +113,10 @@ func viewUno(g uno.State) unoView {
// 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}
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) {
@@ -127,6 +139,16 @@ func viewUno(g uno.State) unoView {
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
}
@@ -141,16 +163,32 @@ type unoEventView struct {
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, Text: e.Text}
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
@@ -268,6 +306,8 @@ func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
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