games: the uno table opens its doors

Driven in a browser for the first time, which is where three bugs were.

Every visit to /games/uno was a 500: the page was never added to the list
server.go parses into the games template set, so render() answered "unknown
page". The casino tests all call their handlers directly and never go through
render(), so nothing saw it. TestEveryCasinoPageRenders now walks the mux and
asks for every page the casino routes to.

The play script hid the first card that lit up rather than the one you clicked,
so playing any other playable card made an innocent card vanish. And on a phone
the discard sized its box but not its card, which takes its size from --uno-h,
so a full-size card hung out of a small hole and covered the colour in play.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
prosolis
2026-07-14 07:38:03 -07:00
parent 79c857023f
commit d7e63d86a6
6 changed files with 104 additions and 21 deletions

View File

@@ -0,0 +1,50 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Every page the casino routes to must be in the games template set. This is not
// a fussy test: uno shipped with its handler wired, its engine tested and its
// template written, and every visit to the table answered "unknown page" with a
// 500 — because the page was never added to the list server.go parses. Nothing
// else caught it, since the other tests call the handlers straight and never go
// through render(). Add a game, add it here.
func TestEveryCasinoPageRenders(t *testing.T) {
s := newCasino(t)
pages := []string{
"/games",
"/games/blackjack",
"/games/hangman",
"/games/solitaire",
"/games/trivia",
"/games/uno",
}
mux := http.NewServeMux()
s.casinoRoutes(mux)
for _, path := range pages {
t.Run(path, func(t *testing.T) {
w := httptest.NewRecorder()
mux.ServeHTTP(w, as(t, s, "reala", "GET", path, nil))
if w.Code != http.StatusOK {
t.Fatalf("GET %s = %d, want 200 (body: %s)", path, w.Code, strings.TrimSpace(w.Body.String()))
}
// render() writes the 500 as a body and a page that fails halfway
// through still comes back 200, so look at what was actually served.
body := w.Body.String()
if strings.Contains(body, "unknown page") {
t.Fatalf("GET %s served the unknown-page error: the template is missing from the games set in server.go", path)
}
if !strings.Contains(body, "</html>") {
t.Fatalf("GET %s did not render a whole page (%d bytes) — the template blew up mid-render", path, len(body))
}
})
}
}

View File

@@ -95,7 +95,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
pages []string
}{
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia"}},
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia", "uno"}},
}
tpls := make(map[string]*template.Template)
for _, set := range sets {

View File

@@ -1755,7 +1755,10 @@ html[data-phase="night"] {
@media (max-width: 639px) {
.pete-uno-hand { --uno-h: 5.2rem; --uno-w: 3.5rem; gap: 0.3rem; }
.pete-uno-deck { --uno-h: 5.2rem; --uno-w: 3.5rem; }
.pete-uno-discard { height: 5.2rem; width: 3.5rem; }
/* The vars, not just the box: the card in the discard is a .pete-uno-card and
takes its size from them, so sizing the box alone left a full-size card
hanging out of a small hole, on top of the colour in play. */
.pete-uno-discard { --uno-h: 5.2rem; --uno-w: 3.5rem; height: 5.2rem; width: 3.5rem; }
.pete-uno-hand .pete-uno-card { padding: 0.22rem; }
.pete-uno-oval { font-size: 1.15rem; }
.pete-uno-seat { padding: 0.35rem 0.45rem; }

File diff suppressed because one or more lines are too long

View File

@@ -63,6 +63,8 @@
var game = null; // the game as the server last described it
var tier = "table";
var pendingWild = -1; // the wild you clicked, waiting on a colour
var played = -1; // the card you just played, so the script lifts that one out
// of the hand and not merely the first one that lit up
var reduced = FX.reduced;
function pace(ms) { return reduced ? 0 : ms; }
@@ -399,9 +401,10 @@
var from = seatAnchor(e.seat);
// Your own card leaves the hand it was in, so the hand has to lose it
// before the flight or the card is briefly in two places.
if (e.seat === 0) {
var live = handEl.querySelector('.pete-uno-card[data-on="1"][data-at]');
if (e.seat === 0 && played >= 0) {
var live = handEl.querySelector('.pete-uno-card[data-at="' + played + '"]');
if (live) live.style.visibility = "hidden";
played = -1;
}
bump(e.seat, e.left);
return throwCard(node, from, discardEl, { index: n })
@@ -555,6 +558,7 @@
if (cancelWild) cancelWild.addEventListener("click", hideWild);
function move(body) {
played = body.kind === "play" ? body.index : -1;
send("/api/games/uno/move", body, gameMsgEl);
}

View File

@@ -322,9 +322,9 @@ A multi-session build. This section is the handover; read it before anything els
live games on the felt: no overlap with text, no overlap with the shoe, no
horizontal overflow, desktop geometry unchanged.
- **UNO, and it plays for chips.** *(2026-07-14. Built and tested, **not yet driven
in a browser** — see "Next" below, because in this room that means it is not
finished.)*
- **UNO, and it plays for chips.** *(2026-07-14. Built, tested, and now **played** —
see "Driven in a browser" at the bottom of this entry, which is where the three
bugs were.)*
- **You beat the table, or you don't.** The user's call between three money
models: stake once, go out first and take the tier's multiple; anybody else
going out first takes the stake. **The table size is the tier**, which is the
@@ -367,22 +367,48 @@ A multi-session build. This section is the handover; read it before anything els
- The felt has no corner free for the house rack (bots along the top, piles in the
middle, your hand at the bottom), so it takes solitaire's **rail** instead:
`data-at="rail"`, off the felt, no collision to check for.
- **Driven in a browser, 2026-07-14, and it plays.** A Full House game went the
distance: the bots' turns come back as a readable script (a card flies from the
seat that played it, SKIPPED and +2 land on somebody), the wild picker takes a
colour and the felt changes to it, a reload mid-game brings back the hand, the
counts, the colour in play and the stake, and the money is right — a Duel staked
200 and won paid 428 back into a 4,600 stack (2.2× is 240 of winnings, less the
5% rake, so +228 net), while a lost Full House took the stake and nothing else.
A thirteen-card hand wraps to three rows at 390px with no sideways overflow and
nothing colliding. Console silent.
- **Three bugs, and the first one was the whole table.**
1. **Every visit to `/games/uno` was a 500.** The handler was wired, the route
was in `casinoRoutes()`, the template was written — and `uno` was never added
to the list of pages `server.go` parses into the games template set, so
`render()` answered "unknown page". No Go test saw it because the casino tests
all call the handlers *directly* and never go through `render()`. There is now
a test that does: `TestEveryCasinoPageRenders` walks the mux, asks for every
page the casino routes to, and fails on a 500 or a half-rendered body. **Add a
game, add it there.**
2. **The wrong card left your hand.** The play script hid `.pete-uno-card[data-on="1"]`
— the *first* card that lit up, not the one you clicked — so playing any other
playable card made an innocent one vanish while the card you played sat there
and a copy of it flew to the discard. It self-corrected on the re-render, which
is why it read as a flicker rather than a bug. The index you played is now kept
(`played`) and that card is the one lifted out.
3. **On a phone the card in play sat on top of the colour in play.** The mobile
query shrank `.pete-uno-discard`'s *box* with a raw height and width, but the
card inside it is a `.pete-uno-card` and takes its size from `--uno-h`/`--uno-w`,
which the discard never set — so a full-size card hung out of a small hole and
covered the RED/BLUE pill under it. The vars go on the discard now. Worth
remembering as a rule: **size a card by its vars, never by the box you put it
in.**
### Next, in order
1. **Drive UNO in a browser.** It is built, the engine is tested and the handlers are
tested, but nothing on this table has been *looked at* — and every game before it
found bugs in that step that no Go test could see (a white-on-white pill, a rack on
top of a multiplier, a spot printing double the stake). Specifically worth watching:
the bots' turns play back as a readable script rather than a blur; the wild colour
picker; a hand of ten cards at 390px with no sideways overflow; and a reload
mid-game bringing the board back. `PETE_DEV_CASINO=:7788 go test ./internal/web
-run TestDevCasino -timeout 0`, then Playwright.
2. Phase 4 (hold'em) as below.
3. Trivia is played but not deployed. Hangman, solitaire, trivia and now UNO are all
still sitting on main un-deployed — the server runs `StartTriviaBank`, so its bank
fills itself once the binary is out there, but the first player to try a ladder
in the first minute after a deploy gets the 503.
1. **Phase 4 — hold'em**, as below. It is the last game on the list, and the first
one where a hand has other people in it: the spot is a singleton and the chip
animations are tuned for one seat (see "still open" below).
2. **Deploy.** Hangman, solitaire, trivia and UNO are all played, and all four are
still sitting on main un-deployed — the live casino is blackjack and nothing else.
The server runs `StartTriviaBank`, so trivia's bank fills itself once the binary
is out there, but the first player to try a ladder in the first minute after a
deploy gets the 503.
Still open on the table itself, none of it blocking: **split** isn't implemented (the
engine has no move for it), the felt is roomy at desktop widths with only one seat on