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

@@ -109,10 +109,10 @@ func (s *State) absorb(seat int, evs *[]Event, rng *rand.Rand) {
s.Pending = 0
s.deal(seat, n, true, evs, rng)
// The seat can die paying the bill, and a mercy kill can end the whole game —
// the player dying, or the last bot dying and leaving you alone at the table.
// So the phase is only reset if there is still a game to have a phase.
if s.mercy(seat, evs, rng) && s.Phase == PhaseDone {
// The seat can die paying the bill, and a mercy kill can end the hand — the last
// seat but one dying leaves somebody alone to take the pot. So the phase is only
// reset if there is still a hand to have a phase.
if s.mercy(seat, evs, rng) && !s.playing() {
return
}
if !s.live(seat) {
@@ -188,13 +188,13 @@ func (s *State) discardAll(seat int, color Color, evs *[]Event) int {
}
// mercy checks a seat against the limit and, if it has crossed it, takes it out
// of the game: its cards go back into the deck and it never plays again. It
// of the hand: its cards go back into the deck and it plays no more this hand. It
// reports whether the seat died.
//
// What that *means* depends on who it was. You dying is the game over — the
// stake is gone whatever the bots do next. A bot dying leaves a table with one
// fewer seat, and if it leaves you alone at it, you have won: everybody who could
// have beaten you to the last card is dead.
// A mercy kill is no longer game over for anyone — it is one seat out of the
// current hand, human or bot alike, and the hand plays on among the living. When
// it leaves exactly one seat alive, that seat has outlived the table and takes the
// pot; whoever it is, they win it the same way going out first would.
func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool {
if !s.Tier.NoMercy || !s.live(seat) || len(s.Hands[seat]) < MercyLimit {
return false
@@ -207,12 +207,8 @@ func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool {
s.Pending = 0 // a dead seat pays no bill, and leaves none behind
*evs = append(*evs, s.mine(Event{Kind: EvMercy, Seat: seat, N: n, Left: 0}))
if seat == You {
s.lose(evs)
return true
}
if alive := s.alive(); len(alive) == 1 {
s.settle(alive[0], evs) // you outlived the table
s.settle(alive[0], OutcomeWon, evs) // the last one standing takes the pot
}
return true
}
@@ -233,17 +229,9 @@ func (s State) alive() []int {
return out
}
// live reports whether a seat is still playing. Out is empty in a normal game and
// in any game saved before No Mercy existed, so a missing entry is a living seat.
// live reports whether a seat is still in the current hand. Out is empty between
// hands and in any game saved before No Mercy existed, so a missing entry is a
// living seat.
func (s State) live(seat int) bool {
return seat >= len(s.Out) || !s.Out[seat]
}
// lose ends the game against the player without anybody having gone out — which
// is what a mercy kill on seat zero is.
func (s *State) lose(evs *[]Event) {
s.Phase = PhaseDone
s.Outcome = OutcomeLost
s.Payout = 0
*evs = append(*evs, Event{Kind: EvSettle, Seat: You, Text: string(OutcomeLost)})
}