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.Phase == PhaseDone || 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. You have just played, you are holding one card, and // you didn't say the word — so every bot still in the game gets one look, in seat // order, and the first to see it takes you for two. // // It runs before runBots on purpose. The catch has to land while the table is // still looking at the card you played, not three turns later. func (s *State) botsCatch(evs *[]Event, rng *rand.Rand) { if s.Phase == PhaseDone || len(s.Hands[You]) != 1 || s.called(You) { return } for _, seat := range s.alive() { if seat == You { continue } if rng.Float64() >= botAlert { continue // this one wasn't looking } s.penalise(You, seat, EvCaught, evs, rng) return // caught once is caught. They don't queue up to take turns at you } } // playerCatches calls out a seat you think is holding one card in silence. // // It is not a turn: right or wrong, the turn stays where it was, which is with // you. What it costs is the risk — a seat that did call, or isn't on one card at // all, is a seat you have just accused of nothing, and that is two cards to you. func (s *State) playerCatches(m Move, rng *rand.Rand) ([]Event, error) { seat := m.Seat if seat == You || 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, You, EvCaught, &evs, rng) // got them } else { s.penalise(You, 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() []int { if s.Phase == PhaseDone || s.Turn != You { return nil } hand := s.Hands[You] 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() []int { if s.Phase == PhaseDone || s.Turn != You { return nil // you can only catch a seat on your own turn } var out []int for _, seat := range s.alive() { if seat != You && 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 } } }