games: no mercy, and the multiples nobody re-measured
No Mercy UNO as a rules dial on the existing tier, not a fourth table: 168 cards, draw-until-playable, draw-stacking, and the twenty-five card mercy kill. Six tiers now; a normal game never runs a line of the new code. The engine is the whole of it so far — the felt hasn't been touched, so there is no way to play this in a browser yet. Two things worth knowing. The normal tiers were mispriced, and had been for a while. They were set against a naive win rate of 43/32/27%; it now measures 40.3/29.2/23.3%. The bots got better at some point after the multiples were written down and nobody re-ran the measurement — which the plan explicitly warns about, because the bots and the tiers are a pair. Table and Full House had been charging an 18–19% house edge instead of the 8% they were meant to. All six tiers are repriced off a fresh measurement, and TestTheMultiplesAreStillPriced now fails the build if they drift again. It is the test the normal tiers never had, which is how they drifted. And No Mercy is *easier* than UNO, at every table size, so it pays less. The mercy rule does not care whose hand hits twenty-five: it kills bots too, and every bot it buries is one fewer seat that can beat you to the last card. A deck built to be merciless turns out to be merciless mostly to the table. The rake test used to assert a payout of 214, which was the 2.2x duel written down as a number. It failed on a rake that was entirely correct. It derives the arithmetic from the tier now: the rule is that the house takes its cut of the profit and never touches the stake, and that holds at any multiple. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
@@ -37,6 +37,8 @@ var (
|
||||
ErrNeedColor = errors.New("uno: pick a colour for the wild")
|
||||
ErrCantPass = errors.New("uno: you can only pass on a card you just drew")
|
||||
ErrMustPlayNow = errors.New("uno: play the card you drew, or pass")
|
||||
ErrMustStack = errors.New("uno: answer the stack with a draw card, or take it")
|
||||
ErrNoStack = errors.New("uno: there's no stack to take")
|
||||
ErrUnknownMove = errors.New("uno: unknown move")
|
||||
ErrBadBet = errors.New("uno: bet must be positive")
|
||||
ErrUnknownTier = errors.New("uno: no such tier")
|
||||
@@ -80,6 +82,10 @@ func (c Color) Playable() bool { return c >= Red && c <= Green }
|
||||
// Value is what's printed on the face.
|
||||
type Value uint8
|
||||
|
||||
// The faces. The first fifteen are the ones on a normal box, and their numbers
|
||||
// are load-bearing: a game in flight is a JSON blob of these integers, so the No
|
||||
// Mercy faces are *appended*. Renumbering them would deal a live table a
|
||||
// different card.
|
||||
const (
|
||||
Zero Value = iota
|
||||
One
|
||||
@@ -96,13 +102,23 @@ const (
|
||||
DrawTwo
|
||||
WildCard
|
||||
WildDrawFour
|
||||
|
||||
// No Mercy only, all of them.
|
||||
SkipAll // skip everyone: you go again
|
||||
DrawFour // a *coloured* +4, which the normal deck doesn't have
|
||||
DiscardAll // play it, and every other card of its colour goes with it
|
||||
WildRevFour // reverse, and the seat that lands next takes four
|
||||
WildDrawSix // +6
|
||||
WildDrawTen // +10
|
||||
WildRoulette // the next seat flips until your colour turns up, and keeps the lot
|
||||
)
|
||||
|
||||
var valueNames = [15]string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
|
||||
"skip", "reverse", "+2", "wild", "+4"}
|
||||
var valueNames = [22]string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
|
||||
"skip", "reverse", "+2", "wild", "+4",
|
||||
"skip all", "+4", "discard all", "rev +4", "+6", "+10", "roulette"}
|
||||
|
||||
func (v Value) String() string {
|
||||
if v > WildDrawFour {
|
||||
if v > WildRoulette {
|
||||
return "?"
|
||||
}
|
||||
return valueNames[v]
|
||||
@@ -111,6 +127,35 @@ func (v Value) String() string {
|
||||
// Action reports whether a card does something beyond being a number.
|
||||
func (v Value) Action() bool { return v >= Skip }
|
||||
|
||||
// Wild reports whether the face has no colour of its own. Note DrawFour is *not*
|
||||
// one: No Mercy prints a coloured +4, which is a different card from the wild +4
|
||||
// sitting next to it in the same deck.
|
||||
func (v Value) Wild() bool {
|
||||
switch v {
|
||||
case WildCard, WildDrawFour, WildRevFour, WildDrawSix, WildDrawTen, WildRoulette:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Draw is how many cards the face makes somebody take, and zero if it doesn't.
|
||||
// It is also what makes a card stackable, so Roulette is deliberately zero: it
|
||||
// hands over a random number of cards, and you cannot stack onto a number nobody
|
||||
// knows yet.
|
||||
func (v Value) Draw() int {
|
||||
switch v {
|
||||
case DrawTwo:
|
||||
return 2
|
||||
case DrawFour, WildDrawFour, WildRevFour:
|
||||
return 4
|
||||
case WildDrawSix:
|
||||
return 6
|
||||
case WildDrawTen:
|
||||
return 10
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Card is one card. Short JSON keys: a hand of these crosses the wire on every
|
||||
// poll, and a state holds all 108.
|
||||
type Card struct {
|
||||
@@ -119,7 +164,7 @@ type Card struct {
|
||||
}
|
||||
|
||||
// IsWild reports whether the card has no colour of its own.
|
||||
func (c Card) IsWild() bool { return c.Value == WildCard || c.Value == WildDrawFour }
|
||||
func (c Card) IsWild() bool { return c.Value.Wild() }
|
||||
|
||||
// CanPlayOn is the whole rule of UNO: match the colour in play, or match the
|
||||
// face, or be a wild. Note it takes the colour *in play* rather than the top
|
||||
@@ -153,12 +198,24 @@ func NewDeck() []Card {
|
||||
// shot — three of them going out before you is three ways to lose — so it pays
|
||||
// more. This is the tier dial every other game here has, pointed at the one knob
|
||||
// UNO actually has.
|
||||
// No Mercy rides on the same struct rather than a second one, because it is the
|
||||
// tier that lands in the state and the payload — so a game carries which rules it
|
||||
// is playing by, and cannot be reloaded into the other set.
|
||||
type Tier struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Bots int `json:"bots"`
|
||||
Base float64 `json:"base"` // what going out first pays, before the rake
|
||||
Blurb string `json:"blurb"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Bots int `json:"bots"`
|
||||
Base float64 `json:"base"` // what going out first pays, before the rake
|
||||
Blurb string `json:"blurb"`
|
||||
NoMercy bool `json:"no_mercy"`
|
||||
}
|
||||
|
||||
// Deck is the deck this tier plays with.
|
||||
func (t Tier) Deck() []Card {
|
||||
if t.NoMercy {
|
||||
return NewNoMercyDeck()
|
||||
}
|
||||
return NewDeck()
|
||||
}
|
||||
|
||||
// Tiers are the three tables.
|
||||
@@ -169,18 +226,56 @@ type Tier struct {
|
||||
// under what that costs, so bad play loses slowly and good play (holding the
|
||||
// wilds, dumping the colour you're long in, counting what a bot picked up) is
|
||||
// worth roughly the house's edge. That is the game being about something.
|
||||
// Re-measured 2026-07-14, and they moved: the naive strategy now wins 40.3% /
|
||||
// 29.2% / 23.3%, not the 43 / 32 / 27 these were originally priced off. The bots
|
||||
// got better at some point after the multiples were set and nobody re-ran the
|
||||
// measurement, so Table and Full House had quietly been charging an 18–19% edge
|
||||
// instead of the 8% they were meant to. The numbers below are the honest ones.
|
||||
//
|
||||
// This is exactly the drift TestTheMultiplesAreStillPriced now exists to stop.
|
||||
var Tiers = []Tier{
|
||||
{Slug: "duel", Name: "Duel", Bots: 1, Base: 2.2,
|
||||
{Slug: "duel", Name: "Duel", Bots: 1, Base: 2.4,
|
||||
Blurb: "One bot, head to head. A reverse is a skip with two at the table."},
|
||||
{Slug: "table", Name: "Table", Bots: 2, Base: 2.9,
|
||||
{Slug: "table", Name: "Table", Bots: 2, Base: 3.3,
|
||||
Blurb: "Two bots. Twice the +4s pointed at you."},
|
||||
{Slug: "full", Name: "Full House", Bots: 3, Base: 3.6,
|
||||
{Slug: "full", Name: "Full House", Bots: 3, Base: 4.1,
|
||||
Blurb: "Three bots, and any of them going out first takes your stake."},
|
||||
}
|
||||
|
||||
// TierBySlug finds a tier by the name the browser sent.
|
||||
// NoMercyTiers are the same three tables playing the other rules.
|
||||
//
|
||||
// The multiples are measured, not guessed, and they are *not* the normal ones —
|
||||
// the naive strategy (play the first legal card; take a stack you can't answer)
|
||||
// wins 46.7% / 31.2% / 25.3% here, against 40.3 / 29.2 / 23.3 on the normal deck.
|
||||
//
|
||||
// Which is to say: **No Mercy is easier than UNO**, at every table size, and so
|
||||
// it pays less. That reads backwards until you see why. The mercy rule kills
|
||||
// *bots* — it does not care whose hand hits twenty-five — and every bot it buries
|
||||
// is one fewer seat that can beat you to the last card. Three opponents burying
|
||||
// each other is a game you win by outliving, and the deck that was built to be
|
||||
// merciless turns out to be merciless mostly to the table.
|
||||
//
|
||||
// So a nastier game pays a smaller multiple, which is the correct answer and a
|
||||
// slightly funny one. TestTheMultiplesAreStillPriced is what keeps it honest:
|
||||
// change the bots, the deck or a rule, and it fails until these are measured
|
||||
// again. It is the test the normal tiers never had, which is how they drifted.
|
||||
var NoMercyTiers = []Tier{
|
||||
{Slug: "nm-duel", Name: "No Mercy Duel", Bots: 1, Base: 2.0, NoMercy: true,
|
||||
Blurb: "One bot, 168 cards. Stack the draws or eat them."},
|
||||
{Slug: "nm-table", Name: "No Mercy Table", Bots: 2, Base: 3.1, NoMercy: true,
|
||||
Blurb: "Two bots. A +10 answered twice is somebody's whole hand."},
|
||||
{Slug: "nm-full", Name: "No Mercy Full House", Bots: 3, Base: 3.8, NoMercy: true,
|
||||
Blurb: "Three bots. Twenty-five cards and you're out of the game."},
|
||||
}
|
||||
|
||||
// AllTiers is every table in the room, both dials.
|
||||
func AllTiers() []Tier {
|
||||
return append(append([]Tier(nil), Tiers...), NoMercyTiers...)
|
||||
}
|
||||
|
||||
// TierBySlug finds a tier by the name the browser sent, across both rule sets.
|
||||
func TierBySlug(slug string) (Tier, error) {
|
||||
for _, t := range Tiers {
|
||||
for _, t := range AllTiers() {
|
||||
if t.Slug == slug {
|
||||
return t, nil
|
||||
}
|
||||
@@ -194,6 +289,7 @@ type Phase string
|
||||
const (
|
||||
PhasePlay Phase = "play" // your turn, play or draw
|
||||
PhaseDrawn Phase = "drawn" // you drew a card you can play: play it or pass
|
||||
PhaseStack Phase = "stack" // No Mercy: a draw card is pointed at you — answer it or take it
|
||||
PhaseDone Phase = "done"
|
||||
)
|
||||
|
||||
@@ -222,6 +318,13 @@ type State struct {
|
||||
Turn int `json:"turn"`
|
||||
Dir int `json:"dir"` // +1 clockwise, -1 after a reverse
|
||||
|
||||
// No Mercy only. Out is the seats the mercy rule has killed, and it is what
|
||||
// the turn order steps over — a dead seat is skipped, not merely empty.
|
||||
// Pending is the bill a stack of draw cards has run up: whoever stops
|
||||
// stacking pays it.
|
||||
Out []bool `json:"out,omitempty"`
|
||||
Pending int `json:"pending,omitempty"`
|
||||
|
||||
Seed1 uint64 `json:"seed1"`
|
||||
Seed2 uint64 `json:"seed2"`
|
||||
Step uint64 `json:"step"` // how many moves have been applied; the rng's other half
|
||||
@@ -259,6 +362,14 @@ type Event struct {
|
||||
// uno a hand is down to one card
|
||||
// reshuffle the discard goes back under
|
||||
// settle it's over
|
||||
//
|
||||
// And the No Mercy ones:
|
||||
//
|
||||
// stack a draw card is pointed at a seat: N is the bill so far
|
||||
// skipall everybody else loses their turn
|
||||
// discard a whole colour left a hand at once
|
||||
// roulette a seat flipped N cards looking for a colour, and kept them
|
||||
// mercy a seat hit 25 cards and is out of the game
|
||||
const (
|
||||
EvDeal = "deal"
|
||||
EvPlay = "play"
|
||||
@@ -270,6 +381,12 @@ const (
|
||||
EvUno = "uno"
|
||||
EvReshuffle = "reshuffle"
|
||||
EvSettle = "settle"
|
||||
|
||||
EvStack = "stack"
|
||||
EvSkipAll = "skipall"
|
||||
EvDiscardAll = "discard"
|
||||
EvRoulette = "roulette"
|
||||
EvMercy = "mercy"
|
||||
)
|
||||
|
||||
// Move is what the player sends: play this card, take one off the deck, or —
|
||||
@@ -280,11 +397,14 @@ type Move struct {
|
||||
Color Color `json:"color"` // the colour you name, for a wild
|
||||
}
|
||||
|
||||
// Move kinds.
|
||||
// Move kinds. Take is No Mercy's: it is how you give in to a stack you can't
|
||||
// answer, and it is a *decision*, so it gets a name of its own rather than being
|
||||
// bolted onto draw.
|
||||
const (
|
||||
MovePlay = "play"
|
||||
MoveDraw = "draw"
|
||||
MovePass = "pass"
|
||||
MoveTake = "take"
|
||||
)
|
||||
|
||||
// New deals a game: a shuffled deck, seven each, and a card turned over.
|
||||
@@ -302,7 +422,7 @@ func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Even
|
||||
}
|
||||
rng := stepRNG(seed1, seed2, 0)
|
||||
|
||||
deck := NewDeck()
|
||||
deck := t.Deck()
|
||||
rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] })
|
||||
|
||||
s := State{
|
||||
@@ -313,6 +433,7 @@ func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Even
|
||||
}
|
||||
|
||||
seats := t.Bots + 1
|
||||
s.Out = make([]bool, seats)
|
||||
s.Hands = make([][]Card, seats)
|
||||
for i := range s.Hands {
|
||||
s.Hands[i] = make([]Card, 0, HandSize)
|
||||
@@ -369,6 +490,8 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
evs, err = next.playerDraws(rng)
|
||||
case MovePass:
|
||||
evs, err = next.playerPasses()
|
||||
case MoveTake:
|
||||
evs, err = next.playerTakes(rng)
|
||||
default:
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
@@ -401,7 +524,15 @@ func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
|
||||
return nil, ErrMustPlayNow
|
||||
}
|
||||
card := hand[m.Index]
|
||||
if !card.CanPlayOn(s.top(), s.Color) {
|
||||
|
||||
// With a stack pointed at you, the only cards that exist are the ones that
|
||||
// answer it. Everything else in your hand is unplayable until the bill is
|
||||
// settled — by you, or by the seat you pass it to.
|
||||
if s.Phase == PhaseStack {
|
||||
if !card.CanStackOn(s.Color) {
|
||||
return nil, ErrMustStack
|
||||
}
|
||||
} else if !card.CanPlayOn(s.top(), s.Color) {
|
||||
return nil, ErrCantPlay
|
||||
}
|
||||
if card.IsWild() && !m.Color.Playable() {
|
||||
@@ -415,29 +546,78 @@ func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
// playerDraws takes one off the deck. If it can be played you get the choice —
|
||||
// that's PhaseDrawn, and it's the only place the turn pauses mid-move. If it
|
||||
// can't, the turn passes on the spot: there is nothing to decide.
|
||||
// playerDraws takes cards off the deck.
|
||||
//
|
||||
// The normal game takes one: if it can be played you get the choice — that's
|
||||
// PhaseDrawn, the only place a turn pauses mid-move — and if it can't, the turn
|
||||
// passes on the spot, because there is nothing to decide.
|
||||
//
|
||||
// No Mercy makes you draw *until* you can play. There is no drawing one card and
|
||||
// shrugging, which is most of why hands there get big enough for the mercy rule
|
||||
// to have something to kill. The card you end on is a card you must then play, so
|
||||
// there is still nothing to decide — but the deck can be dry, and a hand can hit
|
||||
// twenty-five on the way, and both of those end the drawing.
|
||||
func (s *State) playerDraws(rng *rand.Rand) ([]Event, error) {
|
||||
if s.Phase == PhaseDrawn {
|
||||
return nil, ErrMustPlayNow // you already drew; play it or pass
|
||||
}
|
||||
if s.Phase == PhaseStack {
|
||||
return nil, ErrMustStack // answer it or take it; you cannot draw out of a stack
|
||||
}
|
||||
var evs []Event
|
||||
drawn := s.deal(You, 1, false, &evs, rng)
|
||||
if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) {
|
||||
s.Phase = PhaseDrawn
|
||||
|
||||
if !s.Tier.NoMercy {
|
||||
drawn := s.deal(You, 1, false, &evs, rng)
|
||||
if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) {
|
||||
s.Phase = PhaseDrawn
|
||||
return evs, nil
|
||||
}
|
||||
evs = append(evs, Event{Kind: EvPass, Seat: You})
|
||||
s.advance(1)
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
for {
|
||||
drawn := s.deal(You, 1, false, &evs, rng)
|
||||
if len(drawn) == 0 {
|
||||
break // the table has nothing left to draw
|
||||
}
|
||||
if s.mercy(You, &evs, rng) {
|
||||
return evs, nil // twenty-five cards, and you are out of the game
|
||||
}
|
||||
if drawn[0].CanPlayOn(s.top(), s.Color) {
|
||||
s.Phase = PhaseDrawn
|
||||
return evs, nil
|
||||
}
|
||||
}
|
||||
evs = append(evs, Event{Kind: EvPass, Seat: You})
|
||||
s.advance(1)
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
// playerTakes gives in to a stack: you take every card it has run up, and you
|
||||
// lose your turn.
|
||||
func (s *State) playerTakes(rng *rand.Rand) ([]Event, error) {
|
||||
if s.Phase != PhaseStack {
|
||||
return nil, ErrNoStack
|
||||
}
|
||||
var evs []Event
|
||||
s.absorb(You, &evs, rng)
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
// playerPasses declines the card you just drew.
|
||||
//
|
||||
// In No Mercy you may not: you drew until you found a card that plays, and that
|
||||
// card is the price of having drawn. Passing there would make drawing a way to
|
||||
// buy a look at the deck and put nothing down.
|
||||
func (s *State) playerPasses() ([]Event, error) {
|
||||
if s.Phase != PhaseDrawn {
|
||||
return nil, ErrCantPass
|
||||
}
|
||||
if s.Tier.NoMercy {
|
||||
return nil, ErrMustPlayNow
|
||||
}
|
||||
s.Phase = PhasePlay
|
||||
s.advance(1)
|
||||
return []Event{{Kind: EvPass, Seat: You}}, nil
|
||||
@@ -455,24 +635,61 @@ func (s *State) runBots(evs *[]Event, rng *rand.Rand) {
|
||||
|
||||
// botTurn plays one bot's turn.
|
||||
func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) {
|
||||
card, idx := botPick(s.Hands[seat], s.top(), s.Color, s.minOpponent(seat), rng)
|
||||
if idx < 0 {
|
||||
// Nothing playable: draw one, and play it if it happens to go.
|
||||
drawn := s.deal(seat, 1, false, evs, rng)
|
||||
if len(drawn) != 1 || !drawn[0].CanPlayOn(s.top(), s.Color) {
|
||||
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
|
||||
s.advance(1)
|
||||
// A stack pointed at this bot is not a turn, it is a bill. It answers with a
|
||||
// draw card if it holds one, and takes the lot if it doesn't.
|
||||
if s.Phase == PhaseStack {
|
||||
card, idx := botStack(s.Hands[seat], s.Color, rng)
|
||||
if idx < 0 {
|
||||
s.absorb(seat, evs, rng)
|
||||
return
|
||||
}
|
||||
card, idx = drawn[0], len(s.Hands[seat])-1
|
||||
s.botPlays(seat, card, idx, evs, rng)
|
||||
return
|
||||
}
|
||||
|
||||
card, idx := botPick(s.Hands[seat], s.top(), s.Color, s.minOpponent(seat), rng)
|
||||
if idx < 0 {
|
||||
// Nothing playable: draw. The normal game draws one and shrugs; No Mercy
|
||||
// draws until something goes, which is what buries a bot as surely as it
|
||||
// buries you — the mercy rule cuts both ways, and a bot can die on the deck.
|
||||
for {
|
||||
drawn := s.deal(seat, 1, false, evs, rng)
|
||||
if len(drawn) != 1 {
|
||||
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
|
||||
s.advance(1)
|
||||
return
|
||||
}
|
||||
if s.Tier.NoMercy && s.mercy(seat, evs, rng) {
|
||||
return
|
||||
}
|
||||
if drawn[0].CanPlayOn(s.top(), s.Color) {
|
||||
card, idx = drawn[0], len(s.Hands[seat])-1
|
||||
break
|
||||
}
|
||||
if !s.Tier.NoMercy {
|
||||
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
|
||||
s.advance(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
s.botPlays(seat, card, idx, evs, rng)
|
||||
}
|
||||
|
||||
// botPlays puts a bot's chosen card down and resolves it.
|
||||
func (s *State) botPlays(seat int, card Card, idx int, evs *[]Event, rng *rand.Rand) {
|
||||
hand := s.Hands[seat]
|
||||
s.Hands[seat] = append(hand[:idx:idx], hand[idx+1:]...)
|
||||
|
||||
color := card.Color
|
||||
if card.IsWild() {
|
||||
color = botColor(s.Hands[seat], rng)
|
||||
if card.Value == WildRoulette {
|
||||
// The roulette is not a card you play a colour *from*, it is a card you
|
||||
// point at somebody. So the bot names the colour it holds least of, which
|
||||
// is the one the deck is least likely to turn up quickly.
|
||||
color = botRouletteColor(s.Hands[seat], rng)
|
||||
}
|
||||
}
|
||||
s.discard(seat, card, color, evs)
|
||||
s.after(seat, card, evs, rng)
|
||||
@@ -489,11 +706,14 @@ func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) {
|
||||
// and worse than either, a live game you can't finish is chips you can't cash
|
||||
// out, because the cage won't let you leave a hand half-played.
|
||||
func (s State) stalled() bool {
|
||||
if s.Pending > 0 {
|
||||
return false // a stack is a move somebody still has to make: taking it
|
||||
}
|
||||
if len(s.Deck) > 0 || len(s.Discard) > 1 {
|
||||
return false // there is a card to draw, or a discard to make one out of
|
||||
}
|
||||
for _, hand := range s.Hands {
|
||||
for _, c := range hand {
|
||||
for _, seat := range s.alive() {
|
||||
for _, c := range s.Hands[seat] {
|
||||
if c.CanPlayOn(s.top(), s.Color) {
|
||||
return false
|
||||
}
|
||||
@@ -532,6 +752,24 @@ func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) {
|
||||
}
|
||||
s.Phase = PhasePlay
|
||||
|
||||
// A draw card. In No Mercy this doesn't land yet: it opens a stack, and the
|
||||
// seat it points at gets the choice of answering it. In the normal game it
|
||||
// lands where it always did.
|
||||
if n := card.Value.Draw(); n > 0 {
|
||||
if card.Value == WildRevFour {
|
||||
s.flip(seat, evs) // it reverses *first*: the seat it hits is the one after that
|
||||
}
|
||||
if s.Tier.NoMercy {
|
||||
s.Pending += n
|
||||
s.advance(1)
|
||||
s.Phase = PhaseStack
|
||||
*evs = append(*evs, Event{Kind: EvStack, Seat: s.Turn, N: s.Pending})
|
||||
return
|
||||
}
|
||||
s.punish(s.seatAt(1), n, evs, rng)
|
||||
return
|
||||
}
|
||||
|
||||
switch card.Value {
|
||||
case Skip:
|
||||
victim := s.seatAt(1)
|
||||
@@ -539,31 +777,50 @@ func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) {
|
||||
s.advance(2)
|
||||
|
||||
case Reverse:
|
||||
// Two at the table and a reverse has nobody to hand the turn back to, so it
|
||||
// is a skip — which, with two players, means you go again.
|
||||
if len(s.Hands) == 2 {
|
||||
*evs = append(*evs, Event{Kind: EvSkip, Seat: s.seatAt(1)})
|
||||
s.advance(2)
|
||||
s.flip(seat, evs)
|
||||
|
||||
case SkipAll:
|
||||
// Everyone else loses their turn, which means it comes straight back to the
|
||||
// seat that played it. The turn does not move at all.
|
||||
*evs = append(*evs, Event{Kind: EvSkipAll, Seat: seat})
|
||||
|
||||
case DiscardAll:
|
||||
// Every other card of this colour goes down with it. That can empty the
|
||||
// hand, which is a win — and the reason this can't lean on the empty-hand
|
||||
// check at the top of the function, which already ran.
|
||||
s.discardAll(seat, card.Color, evs)
|
||||
if len(s.Hands[seat]) == 0 {
|
||||
s.settle(seat, evs)
|
||||
return
|
||||
}
|
||||
s.Dir = -s.Dir
|
||||
*evs = append(*evs, Event{Kind: EvReverse, Seat: seat})
|
||||
s.advance(1)
|
||||
|
||||
case DrawTwo:
|
||||
s.punish(s.seatAt(1), 2, evs, rng)
|
||||
|
||||
case WildDrawFour:
|
||||
s.punish(s.seatAt(1), 4, evs, rng)
|
||||
case WildRoulette:
|
||||
s.roulette(s.seatAt(1), s.Color, evs, rng)
|
||||
|
||||
default:
|
||||
s.advance(1)
|
||||
}
|
||||
}
|
||||
|
||||
// punish makes the next seat eat a draw card and lose its turn. No stacking: a
|
||||
// +2 played onto a +2 is a house rule, and the one this table plays is the one
|
||||
// on the box.
|
||||
// flip turns the direction round — or, at a table of two, skips the only other
|
||||
// player, because a reverse with nobody to hand the turn back to is a card that
|
||||
// means you go again.
|
||||
func (s *State) flip(seat int, evs *[]Event) {
|
||||
if len(s.alive()) == 2 {
|
||||
*evs = append(*evs, Event{Kind: EvSkip, Seat: s.seatAt(1)})
|
||||
s.advance(2)
|
||||
return
|
||||
}
|
||||
s.Dir = -s.Dir
|
||||
*evs = append(*evs, Event{Kind: EvReverse, Seat: seat})
|
||||
s.advance(1)
|
||||
}
|
||||
|
||||
// punish makes the next seat eat a draw card and lose its turn. This is the
|
||||
// normal game's rule: no stacking, because a +2 played onto a +2 is a house rule
|
||||
// and the one on the box is the one this deck plays. No Mercy prints the stacking
|
||||
// rule on its own box, and takes the other road out of after().
|
||||
func (s *State) punish(victim, n int, evs *[]Event, rng *rand.Rand) {
|
||||
s.deal(victim, n, true, evs, rng)
|
||||
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim})
|
||||
@@ -648,8 +905,13 @@ func (s *State) settle(winner int, evs *[]Event) {
|
||||
// card deep, and every seat has passed. The shortest hand takes it — and a tie
|
||||
// is not a win, because a win here has to be somebody actually going out.
|
||||
func (s *State) stuck(evs *[]Event) {
|
||||
best, tied := 0, false
|
||||
for seat := range s.Hands {
|
||||
live := s.alive()
|
||||
if len(live) == 0 {
|
||||
s.lose(evs) // can't happen: a mercy kill that empties the table settles first
|
||||
return
|
||||
}
|
||||
best, tied := live[0], false
|
||||
for _, seat := range live {
|
||||
switch {
|
||||
case len(s.Hands[seat]) < len(s.Hands[best]):
|
||||
best, tied = seat, false
|
||||
@@ -731,6 +993,17 @@ func (s State) Playable() []int {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Under a stack, the only cards that light up are the ones that answer it.
|
||||
// Everything else in the hand is dead until the bill is paid.
|
||||
if s.Phase == PhaseStack {
|
||||
var out []int
|
||||
for i, c := range hand {
|
||||
if c.CanStackOn(s.Color) {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
var out []int
|
||||
for i, c := range hand {
|
||||
if c.CanPlayOn(s.top(), s.Color) {
|
||||
@@ -780,10 +1053,26 @@ func (s *State) pop() (Card, bool) {
|
||||
return c, true
|
||||
}
|
||||
|
||||
// seatAt is the seat n places round from the one whose turn it is.
|
||||
// seatAt is the seat n *live* places round from the one whose turn it is.
|
||||
//
|
||||
// A seat the mercy rule has killed is not there any more: it is stepped over, not
|
||||
// landed on and skipped. So this counts living seats rather than doing the
|
||||
// arithmetic on the index — which is the same thing in a normal game, where
|
||||
// nobody is ever out, and the only thing that keeps a No Mercy table from
|
||||
// handing the turn to a corpse.
|
||||
func (s State) seatAt(n int) int {
|
||||
seats := len(s.Hands)
|
||||
return ((s.Turn+s.Dir*n)%seats + seats) % seats
|
||||
at := s.Turn
|
||||
for moved := 0; moved < n; {
|
||||
at = ((at+s.Dir)%seats + seats) % seats
|
||||
if at == s.Turn && !s.live(at) {
|
||||
return at // nobody left alive to hand it to; the caller ends the game
|
||||
}
|
||||
if s.live(at) {
|
||||
moved++
|
||||
}
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// advance moves the turn on n places.
|
||||
@@ -815,6 +1104,7 @@ func (s State) clone() State {
|
||||
s.Deck = append([]Card(nil), s.Deck...)
|
||||
s.Discard = append([]Card(nil), s.Discard...)
|
||||
s.Bots = append([]string(nil), s.Bots...)
|
||||
s.Out = append([]bool(nil), s.Out...)
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user