Files
Pete/internal/games/uno/call.go
prosolis 927ed84163 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
2026-07-14 18:37:51 -07:00

204 lines
7.9 KiB
Go

package uno
import "math/rand/v2"
// Calling UNO, and catching the seat that didn't.
//
// This was the one rule on the box the table wasn't playing. A hand going down to
// one card used to emit the "uno" event by itself, which made the call a thing
// that *happened to you* rather than a thing you did — and a rule nobody can fail
// is not a rule, it's an announcement.
//
// So now: play your second-to-last card and you owe the table a word. Say it
// (Move.Uno) and you're safe. Stay quiet and the bots get one look at you, right
// then, before any of them plays — because a bot that has moved on is a bot that
// has stopped watching your hand. Miss it and you take two.
//
// 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* — there is
// no event, no badge, no tell on the felt except the count beside its fan reading
// "1 card" with no UNO on it. Catch it (MoveCatch) 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 every turn.
//
// The whole rule turns on one asymmetry, and it's deliberate: the bots get a
// *roll* to notice you, and you get to actually look. Attention is the edge here,
// and it's the player's to take.
const (
// CatchPenalty is what silence costs, both ways. Two, as printed on the box.
CatchPenalty = 2
// botForget is how often a bot goes down to one card without saying so. High
// enough that watching the counts pays — at a full table you'll get a catch to
// make every few games — and low enough that a quiet seat is still a thing you
// have to notice rather than assume.
botForget = 0.30
// botAlert is the chance a *single* bot notices that you didn't call. They each
// get a look, so forgetting is punished about 75% of the time heads-up and 98%
// at a full table: the more seats there are watching you, the less you get away
// with, which is the right shape for it.
botAlert = 0.75
)
// botForgets rolls for whether a bot muffs its call.
func botForgets(rng *rand.Rand) bool { return rng.Float64() < botForget }
// declare records whether a seat that is now on one card said so, and — if it did
// — announces it. A seat on any other number of cards owes nothing and this does
// nothing, which is what makes it safe to call after every play.
func (s *State) declare(seat int, called bool, evs *[]Event) {
if !s.playing() || len(s.Hands[seat]) != 1 {
return
}
s.ensureCalled()
s.Called[seat] = called
if called {
*evs = append(*evs, Event{Kind: EvUno, Seat: seat, Left: 1})
}
}
// botsCatch is the window. The seat that just played is holding one card and
// didn't say the word — so every bot still in the hand gets one look, in seat
// order, and the first to see it takes them for two.
//
// It runs before runBots on purpose. The catch has to land while the table is
// still looking at the card that was played, not three turns later. Only the bots
// catch on a roll here; a human polices the table with the catch button, which is
// the asymmetry the whole rule turns on — attention is the player's edge.
func (s *State) botsCatch(actor int, evs *[]Event, rng *rand.Rand) {
if !s.playing() || len(s.Hands[actor]) != 1 || s.called(actor) {
return
}
for _, seat := range s.alive() {
if seat == actor || !s.Seats[seat].Bot {
continue // a human catches with the button, not on a roll
}
if rng.Float64() >= botAlert {
continue // this one wasn't looking
}
s.penalise(actor, seat, EvCaught, evs, rng)
return // caught once is caught. They don't queue up to take turns at you
}
}
// seatCatches calls out a seat the caller thinks is holding one card in silence.
//
// It is not a turn: right or wrong, the turn stays where it was. What it costs is
// the risk — a seat that did call, or isn't on one card at all, is a seat the
// caller has accused of nothing, and that is two cards to them.
func (s *State) seatCatches(caller int, m Move, rng *rand.Rand) ([]Event, error) {
seat := m.Seat
if seat == caller || seat < 0 || seat >= len(s.Hands) || !s.live(seat) {
return nil, ErrNoCatch
}
var evs []Event
if len(s.Hands[seat]) == 1 && !s.called(seat) {
s.penalise(seat, caller, EvCaught, &evs, rng) // got them
} else {
s.penalise(caller, seat, EvMiscall, &evs, rng) // they were clean, and you weren't looking
}
return evs, nil
}
// penalise makes a seat take the price of the call: cards off the deck, quietly —
// no draw event, because what the table is being told about is the catch, and a
// draw event alongside it would animate the same two cards twice.
//
// In No Mercy those two cards can be the two that bury them, which is a fine way
// to go: caught on one card, dead on twenty-five.
func (s *State) penalise(victim, by int, kind string, evs *[]Event, rng *rand.Rand) {
got := s.drawCards(victim, CatchPenalty, evs, rng)
if len(got) == 0 {
return // the table has nothing left to punish anybody with
}
s.ensureCalled()
s.Called[victim] = false
*evs = append(*evs, s.mine(Event{
Kind: kind, Seat: victim, By: by, N: len(got), Left: len(s.Hands[victim]),
}))
s.mercy(victim, evs, rng)
}
// UnoAt is which cards in your hand would leave you holding exactly one, if you
// played them. It is the table's cue to ask you for the call, and it comes from
// here rather than from the browser counting your cards because No Mercy's
// "discard all" doesn't take one card out of your hand — it takes every card of
// its colour, and the browser guessing at that is the browser getting it wrong.
//
// It answers for every card, legal or not. The table only ever asks about a card
// you were allowed to play anyway.
func (s State) UnoAt(seat int) []int {
if !s.playing() || s.Turn != seat {
return nil
}
hand := s.Hands[seat]
var out []int
for i, c := range hand {
left := len(hand) - 1
if c.Value == DiscardAll {
for j, other := range hand {
if j != i && other.Color == c.Color && !other.IsWild() {
left--
}
}
}
if left == 1 {
out = append(out, i)
}
}
return out
}
// Catchable is which seats are, right now, sitting on one card they never
// announced. It is what the browser puts a button on.
//
// This leaks nothing. The card counts are already on the felt and the UNO badge
// already isn't — this is the same two facts, subtracted, and doing that
// subtraction on the server is only so that the rule for what counts as catchable
// lives in one place instead of two.
func (s State) Catchable(viewer int) []int {
if !s.playing() || s.Turn != viewer {
return nil // you can only catch a seat on your own turn
}
var out []int
for _, seat := range s.alive() {
if seat != viewer && len(s.Hands[seat]) == 1 && !s.called(seat) {
out = append(out, seat)
}
}
return out
}
// called reports whether a seat holding one card announced it. Callers outside
// the package read the Called slice, which is on the state they already hold.
func (s State) called(seat int) bool {
return seat < len(s.Called) && s.Called[seat]
}
// ensureCalled grows the slice to fit the table. A game dealt before this rule
// existed has no Called at all, and the seats in it are all — correctly —
// uncalled: nobody in that game ever said the word, because there was no way to.
func (s *State) ensureCalled() {
for len(s.Called) < len(s.Hands) {
s.Called = append(s.Called, false)
}
}
// tidyCalls forgets the calls that no longer mean anything. A seat's call is only
// ever about the one card it is holding — draw into two and the word you said is
// spent, and saying it again is a new thing you have to do.
//
// Without this, a seat that called on one card, was made to draw, and worked its
// way back down to one would still be wearing the old call, and could never be
// caught again for the rest of the game.
func (s *State) tidyCalls() {
s.ensureCalled()
for seat := range s.Called {
if len(s.Hands[seat]) != 1 {
s.Called[seat] = false
}
}
}