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
204 lines
7.9 KiB
Go
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
|
|
}
|
|
}
|
|
}
|