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
238 lines
8.1 KiB
Go
238 lines
8.1 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 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) {
|
|
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, s.mine(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, s.mine(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 hand: its cards go back into the deck and it plays no more this hand. It
|
|
// reports whether the seat died.
|
|
//
|
|
// 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
|
|
}
|
|
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, s.mine(Event{Kind: EvMercy, Seat: seat, N: n, Left: 0}))
|
|
|
|
if alive := s.alive(); len(alive) == 1 {
|
|
s.settle(alive[0], OutcomeWon, evs) // the last one standing takes the pot
|
|
}
|
|
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 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]
|
|
}
|