The engine has been able to play No Mercy since aca523e. Now a browser can.
The switch is a switch, not a fourth table: the tier is still the table size,
because that is what you are paid for, and the deck is the other dial. Six faces
the normal box does not print, sized by the card's own vars and never by the box
they sit in. The stack says what the bill is on the felt, in the turn line and on
the button, and under it the deck is dead — you cannot draw your way out of a
bill somebody has run up and pointed at you.
The wild draws glow. That started as decoration and turned out to be doing work:
No Mercy prints a coloured +4 right beside the wild one, and in a hand of twenty
the glow is what tells them apart.
A buried seat is not an empty one, which is the whole trap here — a seat killed
at twenty-five holds no cards, and neither does a seat that just went out and
won. The view asks the engine which it is instead of counting to zero, so the
winner is never the corpse.
Two bugs, both found in a browser and neither findable anywhere else:
The felt's stack bill was writing into the chip bar. It was [data-pending], and
so is the bar's "your chips are still coming" readout — and the bar lives inside
the table's own root and comes first in the document. A stack quietly overwrote
the escrow message and never appeared on the felt at all. A table's attributes
are not a private namespace.
And hold'em, re-driven on the 20M-hand policy (six hands, got up 61 ahead of a
100 buy-in, money conserved to the chip — Phase 4 closed), let you click a button
that did nothing: Deal, Leave and Top up stayed alive through the whole deal
animation, where send() drops the click on purpose. The lock is on the buttons
now, not only in the variable.
Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
250 lines
8.3 KiB
Go
250 lines
8.3 KiB
Go
package uno
|
|
|
|
import "math/rand/v2"
|
|
|
|
// No Mercy.
|
|
//
|
|
// A rules dial, not a fourth table. The table size is still the tier — a duel is
|
|
// a duel — and No Mercy is a switch you throw across all three of them. What it
|
|
// changes is the game they play:
|
|
//
|
|
// - A 168-card deck, with faces the normal one doesn't print: a coloured +4, a
|
|
// +6, a +10, a skip-everyone, a discard-all, a reverse-and-draw-four, and a
|
|
// colour roulette.
|
|
// - Draw cards stack. A +2 pointed at you can be answered with any draw card
|
|
// you hold, and the bill goes to the next seat with the two added on. Whoever
|
|
// runs out of draw cards eats the lot.
|
|
// - You draw until you can play. There is no drawing one card and shrugging.
|
|
// - And twenty-five cards in your hand kills you. That is the whole point of
|
|
// the deck: it is built to bury somebody, and the mercy rule is what happens
|
|
// when it does.
|
|
//
|
|
// Everything here is reached from uno.go behind `s.Tier.NoMercy`. A normal game
|
|
// never runs a line of it.
|
|
|
|
// MercyLimit is the hand that ends you. Reach it and you are out of the game —
|
|
// your cards go back in the deck and the table plays on without you.
|
|
const MercyLimit = 25
|
|
|
|
// NewNoMercyDeck builds the 168.
|
|
//
|
|
// Per colour: two of each number, three skips, two skip-everyones, four
|
|
// reverses, two +2s, two coloured +4s and three discard-alls — thirty-six cards,
|
|
// times four colours. Then the wilds: eight reverse-draw-fours, four +6s, four
|
|
// +10s and eight roulettes. Unshuffled, same as NewDeck, because New shuffles and
|
|
// a test wants the order it was built in.
|
|
func NewNoMercyDeck() []Card {
|
|
d := make([]Card, 0, 168)
|
|
for _, col := range []Color{Red, Blue, Yellow, Green} {
|
|
for v := Zero; v <= Nine; v++ {
|
|
d = append(d, Card{col, v}, Card{col, v})
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
d = append(d, Card{col, Skip})
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
d = append(d, Card{col, SkipAll})
|
|
}
|
|
for i := 0; i < 4; i++ {
|
|
d = append(d, Card{col, Reverse})
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
d = append(d, Card{col, DrawTwo})
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
d = append(d, Card{col, DrawFour})
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
d = append(d, Card{col, DiscardAll})
|
|
}
|
|
}
|
|
for i := 0; i < 8; i++ {
|
|
d = append(d, Card{Wild, WildRevFour})
|
|
}
|
|
for i := 0; i < 4; i++ {
|
|
d = append(d, Card{Wild, WildDrawSix})
|
|
}
|
|
for i := 0; i < 4; i++ {
|
|
d = append(d, Card{Wild, WildDrawTen})
|
|
}
|
|
for i := 0; i < 8; i++ {
|
|
d = append(d, Card{Wild, WildRoulette})
|
|
}
|
|
return d
|
|
}
|
|
|
|
// CanStackOn reports whether a card can be thrown onto a stack that is already
|
|
// building. Any draw card answers any other — there is no escalation rule, so a
|
|
// +2 is a legal reply to a +10 — but a *coloured* draw card still has to follow
|
|
// the colour in play. The wild draws always go.
|
|
//
|
|
// This is why the pending count is not a cap: what you are matching is the fact
|
|
// of a draw card, not its size.
|
|
func (c Card) CanStackOn(topColor Color) bool {
|
|
if c.Value.Draw() == 0 {
|
|
return false
|
|
}
|
|
if c.IsWild() {
|
|
return true
|
|
}
|
|
return c.Color == topColor
|
|
}
|
|
|
|
// canStack reports whether a seat holds anything at all it could answer with.
|
|
func (s State) canStack(seat int) bool {
|
|
for _, c := range s.Hands[seat] {
|
|
if c.CanStackOn(s.Color) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// absorb is what happens when the stack stops with you: you take every card in
|
|
// it, and you lose your turn. The pending count is cleared *before* the cards
|
|
// land, because a mercy kill inside the draw ends the seat and there must be no
|
|
// bill left standing against a seat that is no longer at the table.
|
|
func (s *State) absorb(seat int, evs *[]Event, rng *rand.Rand) {
|
|
n := s.Pending
|
|
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 {
|
|
return
|
|
}
|
|
if !s.live(seat) {
|
|
s.Phase = PhasePlay
|
|
s.advance(1)
|
|
return // it died, but the table plays on. Don't skip a seat that isn't there.
|
|
}
|
|
*evs = append(*evs, Event{Kind: EvSkip, Seat: seat, Left: len(s.Hands[seat])})
|
|
s.Phase = PhasePlay
|
|
s.advance(1) // the turn is on the seat that just paid, so it moves one on
|
|
}
|
|
|
|
// roulette is the colour roulette: the next seat turns cards over until the
|
|
// named colour comes up, and keeps every card it turned. Then it loses its turn.
|
|
//
|
|
// The deck can run out mid-flip (the discard is reshuffled back under as usual,
|
|
// and even that can be dry), so this is bounded by what there is to draw, not by
|
|
// the colour ever actually appearing. A wild is not a colour and never ends it.
|
|
func (s *State) roulette(victim int, color Color, evs *[]Event, rng *rand.Rand) {
|
|
got := 0
|
|
for {
|
|
if len(s.Deck) == 0 && !s.reshuffle(evs, rng) {
|
|
break
|
|
}
|
|
c, ok := s.pop()
|
|
if !ok {
|
|
break
|
|
}
|
|
s.Hands[victim] = append(s.Hands[victim], c)
|
|
got++
|
|
if c.Color == color {
|
|
break
|
|
}
|
|
if len(s.Hands[victim]) >= MercyLimit {
|
|
break // they are dead already; stop dealing cards to a corpse
|
|
}
|
|
}
|
|
if got > 0 {
|
|
e := Event{Kind: EvRoulette, Seat: victim, N: got, Color: color, Left: len(s.Hands[victim])}
|
|
*evs = append(*evs, e)
|
|
}
|
|
if s.mercy(victim, evs, rng) {
|
|
return
|
|
}
|
|
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim, Left: len(s.Hands[victim])})
|
|
s.advance(2)
|
|
}
|
|
|
|
// discardAll dumps every remaining card of a colour out of a hand and buries it
|
|
// under the card that was just played. The pile keeps its top: the played card
|
|
// stays the card in play, and the rest go beneath it, where they are still in the
|
|
// game (a reshuffle brings them back) and still count in a census.
|
|
func (s *State) discardAll(seat int, color Color, evs *[]Event) int {
|
|
hand := s.Hands[seat]
|
|
kept := make([]Card, 0, len(hand))
|
|
var dumped []Card
|
|
for _, c := range hand {
|
|
if c.Color == color && !c.IsWild() {
|
|
dumped = append(dumped, c)
|
|
} else {
|
|
kept = append(kept, c)
|
|
}
|
|
}
|
|
s.Hands[seat] = kept
|
|
if len(dumped) > 0 {
|
|
top := s.Discard[len(s.Discard)-1]
|
|
s.Discard = append(s.Discard[:len(s.Discard)-1], dumped...)
|
|
s.Discard = append(s.Discard, top)
|
|
*evs = append(*evs, Event{Kind: EvDiscardAll, Seat: seat, N: len(dumped),
|
|
Color: color, Left: len(kept)})
|
|
}
|
|
return len(dumped)
|
|
}
|
|
|
|
// 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
|
|
// 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.
|
|
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
|
|
}
|
|
n := len(s.Hands[seat])
|
|
s.Deck = append(s.Deck, s.Hands[seat]...)
|
|
rng.Shuffle(len(s.Deck), func(i, j int) { s.Deck[i], s.Deck[j] = s.Deck[j], s.Deck[i] })
|
|
s.Hands[seat] = nil
|
|
s.Out[seat] = true
|
|
s.Pending = 0 // a dead seat pays no bill, and leaves none behind
|
|
*evs = append(*evs, 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
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Live reports whether a seat is still in the game. The felt needs it: a seat the
|
|
// mercy rule has buried holds no cards, and a seat holding no cards is otherwise
|
|
// indistinguishable from the one that just went out and won.
|
|
func (s State) Live(seat int) bool { return s.live(seat) }
|
|
|
|
// alive lists the seats still in the game.
|
|
func (s State) alive() []int {
|
|
var out []int
|
|
for i := range s.Hands {
|
|
if s.live(i) {
|
|
out = append(out, i)
|
|
}
|
|
}
|
|
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.
|
|
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)})
|
|
}
|