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:
prosolis
2026-07-14 18:37:51 -07:00
parent f8b07d8e6c
commit 927ed84163
15 changed files with 1799 additions and 1208 deletions

View File

@@ -19,17 +19,25 @@ import (
// ---- the lobby -------------------------------------------------------------
// handleHoldemLobby lists the hold'em tables with a seat going spare. A table
// with every chair taken is not shown, because a lobby you cannot join from is
// just a list; bots keep every open table populated, so there is always
// something to sit down at.
// handleHoldemLobby and handleUnoLobby list the tables of their game with a seat
// going spare. A table with every chair taken is not shown, because a lobby you
// cannot join from is just a list; bots keep every open table populated, so there
// is always something to sit down at.
func (s *Server) handleHoldemLobby(w http.ResponseWriter, r *http.Request) {
s.lobby(w, r, gameHoldem)
}
func (s *Server) handleUnoLobby(w http.ResponseWriter, r *http.Request) {
s.lobby(w, r, gameUno)
}
func (s *Server) lobby(w http.ResponseWriter, r *http.Request, game string) {
if _, ok := s.player(w, r); !ok {
return
}
tables, err := storage.LobbyTables(gameHoldem, 50)
tables, err := storage.LobbyTables(game, 50)
if err != nil {
slog.Error("games: holdem lobby", "err", err)
slog.Error("games: lobby", "game", game, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
@@ -58,7 +66,7 @@ const streamPing = 25 * time.Second
// table the player is at. After that it only ever reads its channel. Holding a
// query open for the life of a stream would hold the single pooled connection for
// the life of a stream, and one idle subscriber would take the whole site down.
func (s *Server) handleHoldemStream(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleTableStream(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
@@ -117,7 +125,7 @@ func (s *Server) handleHoldemStream(w http.ResponseWriter, r *http.Request) {
// handleHoldemChat reads the recent rail of a player's table, oldest first, with
// their own lines flagged so the felt can lay them out on the right.
func (s *Server) handleHoldemChat(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleTableChat(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
@@ -152,7 +160,7 @@ func (s *Server) handleHoldemChat(w http.ResponseWriter, r *http.Request) {
// stamped with the hand it was said during — the one question chat at a money
// table ever really raises — and pushed to every open stream so it lands on the
// rail in real time.
func (s *Server) handleHoldemSay(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleTableSay(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return