Compare commits
4 Commits
638e28263a
...
03524aefbc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03524aefbc | ||
|
|
8db8845feb | ||
|
|
aca523e511 | ||
|
|
4bc38859d4 |
@@ -731,6 +731,24 @@ func (s State) nextIn(from int) int {
|
|||||||
return from
|
return from
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nextDealt is the next seat holding cards this hand, folded or not. Where
|
||||||
|
// nextIn asks "who is still in the betting", this asks "who was dealt in", and
|
||||||
|
// the pair is easy to confuse: fold three seats and nextIn walks straight past
|
||||||
|
// them, so anything counting seats round the table lands somewhere different
|
||||||
|
// depending on how the hand has gone. Position needs the fixed one — where you
|
||||||
|
// sit is decided when the button moves and does not change because somebody
|
||||||
|
// mucked.
|
||||||
|
func (s State) nextDealt(from int) int {
|
||||||
|
n := len(s.Seats)
|
||||||
|
for i := 1; i <= n; i++ {
|
||||||
|
next := (from + i) % n
|
||||||
|
if s.Seats[next].State != Out {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return from
|
||||||
|
}
|
||||||
|
|
||||||
// onlyActor is the one seat that can still act. Call it when canActCount is 1.
|
// onlyActor is the one seat that can still act. Call it when canActCount is 1.
|
||||||
func (s State) onlyActor() int {
|
func (s State) onlyActor() int {
|
||||||
for i := range s.Seats {
|
for i := range s.Seats {
|
||||||
@@ -838,8 +856,8 @@ func (s State) Position(seat int) string {
|
|||||||
return "BB" // heads-up, the other seat is always the big blind
|
return "BB" // heads-up, the other seat is always the big blind
|
||||||
}
|
}
|
||||||
|
|
||||||
sb := s.nextIn(s.Button)
|
sb := s.nextDealt(s.Button)
|
||||||
bb := s.nextIn(sb)
|
bb := s.nextDealt(sb)
|
||||||
switch seat {
|
switch seat {
|
||||||
case sb:
|
case sb:
|
||||||
return "SB"
|
return "SB"
|
||||||
@@ -847,7 +865,7 @@ func (s State) Position(seat int) string {
|
|||||||
return "BB"
|
return "BB"
|
||||||
}
|
}
|
||||||
|
|
||||||
utg := s.nextIn(bb)
|
utg := s.nextDealt(bb)
|
||||||
if seat == utg {
|
if seat == utg {
|
||||||
return "UTG"
|
return "UTG"
|
||||||
}
|
}
|
||||||
@@ -856,7 +874,7 @@ func (s State) Position(seat int) string {
|
|||||||
// closest to the button is the cutoff.
|
// closest to the button is the cutoff.
|
||||||
dist, cur := 0, utg
|
dist, cur := 0, utg
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
cur = s.nextIn(cur)
|
cur = s.nextDealt(cur)
|
||||||
dist++
|
dist++
|
||||||
if cur == seat {
|
if cur == seat {
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -726,3 +726,46 @@ func has(evs []Event, kind string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where you sit is decided when the button moves, and a fold does not move it.
|
||||||
|
// Position walked the table with nextIn, which steps over folded seats while the
|
||||||
|
// seat count still includes them — so as players mucked, the labels slid round
|
||||||
|
// and a six-handed felt printed CO on three different seats at once. The badge is
|
||||||
|
// the only thing that reads this, which is exactly why nothing caught it.
|
||||||
|
func TestPositionsDoNotMoveWhenSeatsFold(t *testing.T) {
|
||||||
|
s := table(t, Tiers[0], 5, 200) // six-handed
|
||||||
|
s, _, _ = ApplyMove(s, Move{Kind: Deal})
|
||||||
|
|
||||||
|
before := make([]string, len(s.Seats))
|
||||||
|
for i := range s.Seats {
|
||||||
|
before[i] = s.Position(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every seat has its own label, and the ones a six-max table prints are these.
|
||||||
|
seen := map[string]int{}
|
||||||
|
for _, p := range before {
|
||||||
|
seen[p]++
|
||||||
|
}
|
||||||
|
for _, want := range []string{"BTN", "SB", "BB", "UTG", "MP", "CO"} {
|
||||||
|
if seen[want] != 1 {
|
||||||
|
t.Errorf("six-handed: %q appears %d times, want exactly once — got %v",
|
||||||
|
want, seen[want], before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now fold seats out of the hand, one at a time. Nobody's position changes by
|
||||||
|
// mucking — folding is done to the state directly because a fold in the engine
|
||||||
|
// belongs to whoever is to act, and what is under test is the label, not the turn.
|
||||||
|
for i := range s.Seats {
|
||||||
|
if i == You || s.Seats[i].State != Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Seats[i].State = Folded
|
||||||
|
for j := range s.Seats {
|
||||||
|
if got := s.Position(j); got != before[j] {
|
||||||
|
t.Fatalf("seat %d was %q and is now %q after seat %d folded — position is "+
|
||||||
|
"where you sit, not who is left", j, before[j], got, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -93,6 +93,64 @@ func botRank(hand []Card, topColor Color, playable []int, minOpponent int) []int
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// botStack answers a stack, or reports -1 when the bot has nothing to answer it
|
||||||
|
// with and has to eat the lot.
|
||||||
|
//
|
||||||
|
// It plays the *smallest* draw card it holds. The bill is passed on either way —
|
||||||
|
// what it is passing on is the stack plus whatever it added — so the cheap card
|
||||||
|
// does the same job as the expensive one, and keeps the +10 in hand for a turn
|
||||||
|
// when the bot is the one choosing to hurt somebody rather than the one dodging.
|
||||||
|
//
|
||||||
|
// The slip is here too: one time in six it reaches for the second-smallest, so a
|
||||||
|
// player can't read the stack it just passed as a complete inventory of what the
|
||||||
|
// bot doesn't have.
|
||||||
|
func botStack(hand []Card, topColor Color, rng *rand.Rand) (Card, int) {
|
||||||
|
var can []int
|
||||||
|
for i, c := range hand {
|
||||||
|
if c.CanStackOn(topColor) {
|
||||||
|
can = append(can, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(can) == 0 {
|
||||||
|
return Card{}, -1
|
||||||
|
}
|
||||||
|
// Smallest draw first. A stable insertion sort: there are never many.
|
||||||
|
for i := 1; i < len(can); i++ {
|
||||||
|
for j := i; j > 0 && hand[can[j]].Value.Draw() < hand[can[j-1]].Value.Draw(); j-- {
|
||||||
|
can[j], can[j-1] = can[j-1], can[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pick := can[0]
|
||||||
|
if len(can) > 1 && rng.IntN(botSlip) == 0 {
|
||||||
|
pick = can[1]
|
||||||
|
}
|
||||||
|
return hand[pick], pick
|
||||||
|
}
|
||||||
|
|
||||||
|
// botRouletteColor names the colour for a roulette: whichever the bot holds
|
||||||
|
// *least* of. The victim flips until that colour turns up, so the rarer the
|
||||||
|
// colour, the longer they flip and the more they keep. Naming the colour you're
|
||||||
|
// long in is naming the one that ends the flipping soonest, which is mercy — and
|
||||||
|
// this is not that game.
|
||||||
|
func botRouletteColor(hand []Card, rng *rand.Rand) Color {
|
||||||
|
counts := [5]int{}
|
||||||
|
for _, c := range hand {
|
||||||
|
if c.Color.Playable() {
|
||||||
|
counts[c.Color]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best, bestN := Wild, 1<<30
|
||||||
|
for col := Red; col <= Green; col++ {
|
||||||
|
if counts[col] < bestN {
|
||||||
|
best, bestN = col, counts[col]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best == Wild {
|
||||||
|
return Red + Color(rng.IntN(4))
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
// botColor names a colour for a wild: whichever the bot holds most of, so the
|
// botColor names a colour for a wild: whichever the bot holds most of, so the
|
||||||
// card it plays next is one it already has. A hand of nothing but wilds picks
|
// card it plays next is one it already has. A hand of nothing but wilds picks
|
||||||
// at random rather than always saying red, which would be a tell.
|
// at random rather than always saying red, which would be a tell.
|
||||||
|
|||||||
249
internal/games/uno/nomercy.go
Normal file
249
internal/games/uno/nomercy.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
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)})
|
||||||
|
}
|
||||||
421
internal/games/uno/nomercy_test.go
Normal file
421
internal/games/uno/nomercy_test.go
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
package uno
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func nmDuel() Tier { t, _ := TierBySlug("nm-duel"); return t }
|
||||||
|
func nmTable() Tier { t, _ := TierBySlug("nm-table"); return t }
|
||||||
|
func nmFull() Tier { t, _ := TierBySlug("nm-full"); return t }
|
||||||
|
|
||||||
|
func TestNoMercyDeckIsADeck(t *testing.T) {
|
||||||
|
m := census(State{Deck: NewNoMercyDeck()})
|
||||||
|
if got := total(m); got != 168 {
|
||||||
|
t.Fatalf("deck has %d cards, want 168", got)
|
||||||
|
}
|
||||||
|
want := map[Card]int{
|
||||||
|
{Red, Zero}: 2, // two of every number, unlike the normal deck's single zero
|
||||||
|
{Blue, Seven}: 2,
|
||||||
|
{Green, Skip}: 3,
|
||||||
|
{Yellow, SkipAll}: 2,
|
||||||
|
{Red, Reverse}: 4,
|
||||||
|
{Blue, DrawTwo}: 2,
|
||||||
|
{Green, DrawFour}: 2, // the *coloured* +4
|
||||||
|
{Yellow, DiscardAll}: 3,
|
||||||
|
{Wild, WildRevFour}: 8,
|
||||||
|
{Wild, WildDrawSix}: 4,
|
||||||
|
{Wild, WildDrawTen}: 4,
|
||||||
|
{Wild, WildRoulette}: 8,
|
||||||
|
}
|
||||||
|
for c, n := range want {
|
||||||
|
if m[c] != n {
|
||||||
|
t.Errorf("%v %v: got %d, want %d", c.Color, c.Value, m[c], n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The normal deck's wilds are not in this one, and its coloured +4 is not in
|
||||||
|
// the normal one. They are different cards that print the same thing.
|
||||||
|
if m[Card{Wild, WildCard}] != 0 || m[Card{Wild, WildDrawFour}] != 0 {
|
||||||
|
t.Error("the No Mercy deck should print none of the normal wilds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoMercyCensus is the load-bearing one, and the same one the normal game
|
||||||
|
// has: 168 cards, each in exactly one place, checked after every move of a
|
||||||
|
// hundred games played to the end.
|
||||||
|
//
|
||||||
|
// It is what would catch the two new ways this deck can lose a card. Discard All
|
||||||
|
// buries a whole colour under the pile, and a mercy kill shovels a
|
||||||
|
// twenty-five-card hand back into the deck — either of those dropping a card on
|
||||||
|
// the floor is a deck that quietly shrinks until the table can't be dealt.
|
||||||
|
func TestNoMercyCensus(t *testing.T) {
|
||||||
|
for _, tier := range []Tier{nmDuel(), nmTable(), nmFull()} {
|
||||||
|
for seed := uint64(0); seed < 100; seed++ {
|
||||||
|
s := deal(t, tier, 100, seed)
|
||||||
|
start := census(s)
|
||||||
|
if got := total(start); got != 168 {
|
||||||
|
t.Fatalf("%s seed %d: dealt %d cards, want 168", tier.Slug, seed, got)
|
||||||
|
}
|
||||||
|
rng := rand.New(rand.NewPCG(seed, 99))
|
||||||
|
for moves := 0; s.Phase != PhaseDone && moves < 800; moves++ {
|
||||||
|
next, _, err := ApplyMove(s, naive(s, rng))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s seed %d: %v (phase %s)", tier.Slug, seed, err, s.Phase)
|
||||||
|
}
|
||||||
|
s = next
|
||||||
|
if got := census(s); total(got) != 168 {
|
||||||
|
t.Fatalf("%s seed %d: %d cards after a move, want 168",
|
||||||
|
tier.Slug, seed, total(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Phase != PhaseDone {
|
||||||
|
t.Fatalf("%s seed %d: game never ended", tier.Slug, seed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// naive is the strategy the multiples are priced against: play the first legal
|
||||||
|
// card you hold, take a stack you can't answer, and draw when you have nothing.
|
||||||
|
// It is a real way to play and a bad one, which is exactly what a house edge is
|
||||||
|
// measured against.
|
||||||
|
func naive(s State, rng *rand.Rand) Move {
|
||||||
|
if s.Phase == PhaseStack {
|
||||||
|
if p := s.Playable(); len(p) > 0 {
|
||||||
|
return playMove(s, p[0], rng)
|
||||||
|
}
|
||||||
|
return Move{Kind: MoveTake}
|
||||||
|
}
|
||||||
|
if p := s.Playable(); len(p) > 0 {
|
||||||
|
return playMove(s, p[0], rng)
|
||||||
|
}
|
||||||
|
return Move{Kind: MoveDraw}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stack loads a seat's hand up to n cards by taking them off the deck, so the
|
||||||
|
// table still holds 168 of them. Every card it moves is one that can't be played
|
||||||
|
// on the pile, which is what a hand on its way to the mercy limit looks like.
|
||||||
|
func stack(s *State, seat, n int) {
|
||||||
|
// Every card the seat was holding goes back in the deck first, so the table is
|
||||||
|
// whole before we take n out of it again. The pile keeps whatever the deal
|
||||||
|
// turned over — replacing it with a card of our choosing would quietly destroy
|
||||||
|
// one, and the census below would blame the engine for it.
|
||||||
|
s.Deck = append(s.Deck, s.Hands[seat]...)
|
||||||
|
s.Hands[seat] = nil
|
||||||
|
s.Color = s.top().Color
|
||||||
|
|
||||||
|
kept := make([]Card, 0, len(s.Deck))
|
||||||
|
for _, c := range s.Deck {
|
||||||
|
if len(s.Hands[seat]) < n {
|
||||||
|
s.Hands[seat] = append(s.Hands[seat], c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, c)
|
||||||
|
}
|
||||||
|
s.Deck = kept
|
||||||
|
}
|
||||||
|
|
||||||
|
func playMove(s State, idx int, rng *rand.Rand) Move {
|
||||||
|
m := Move{Kind: MovePlay, Index: idx}
|
||||||
|
if s.Hands[You][idx].IsWild() {
|
||||||
|
m.Color = Red + Color(rng.IntN(4))
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAStackIsPassedOnAndPaidOnce walks the one rule the whole mode turns on: a
|
||||||
|
// draw card doesn't land on you, it *opens a bill*, and the seat that can't
|
||||||
|
// answer pays the whole thing.
|
||||||
|
func TestAStackIsPassedOnAndPaid(t *testing.T) {
|
||||||
|
s := deal(t, nmDuel(), 100, 7)
|
||||||
|
// Rig it: you hold a +2 on a red pile, the bot holds one card that can answer
|
||||||
|
// and one that can't.
|
||||||
|
s.Color = Red
|
||||||
|
s.Discard = []Card{{Red, Five}}
|
||||||
|
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
|
||||||
|
s.Hands[1] = []Card{{Red, DrawTwo}, {Blue, Nine}}
|
||||||
|
s.Turn = You
|
||||||
|
s.Phase = PhasePlay
|
||||||
|
|
||||||
|
// You play the +2. The bot answers with its own, so the bill comes back to you
|
||||||
|
// at four — and you have nothing to answer with, so you pay it.
|
||||||
|
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("play +2: %v", err)
|
||||||
|
}
|
||||||
|
if next.Phase != PhaseStack {
|
||||||
|
t.Fatalf("phase is %s, want stack: a +2 in No Mercy opens a stack", next.Phase)
|
||||||
|
}
|
||||||
|
if next.Turn != You {
|
||||||
|
t.Fatalf("the stack came back to seat %d, want you", next.Turn)
|
||||||
|
}
|
||||||
|
if next.Pending != 4 {
|
||||||
|
t.Fatalf("the bill is %d, want 4 (your two, plus the bot's two)", next.Pending)
|
||||||
|
}
|
||||||
|
if !hasKind(evs, EvStack) {
|
||||||
|
t.Error("no stack event: the felt has nothing to show the player")
|
||||||
|
}
|
||||||
|
// You cannot draw your way out of it, and you cannot play a card that isn't a
|
||||||
|
// draw card.
|
||||||
|
if _, _, err := ApplyMove(next, Move{Kind: MoveDraw}); err != ErrMustStack {
|
||||||
|
t.Errorf("drawing out of a stack: %v, want ErrMustStack", err)
|
||||||
|
}
|
||||||
|
if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustStack {
|
||||||
|
t.Errorf("playing a plain card under a stack: %v, want ErrMustStack", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pay it. The bot is left holding one card it cannot play, and — because No
|
||||||
|
// Mercy makes it draw until it can — it will draw into a fresh hand and may
|
||||||
|
// well open a *new* stack on the way. That's the game working, not a leak, so
|
||||||
|
// what's asserted here is the bill this seat paid, not the state of the table
|
||||||
|
// afterwards: four cards into the hand, and the bill discharged.
|
||||||
|
before := len(next.Hands[You])
|
||||||
|
paid, evs, err := ApplyMove(next, Move{Kind: MoveTake})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("take: %v", err)
|
||||||
|
}
|
||||||
|
var forced int
|
||||||
|
for _, e := range evs {
|
||||||
|
if e.Kind == EvForced && e.Seat == You {
|
||||||
|
forced = e.N
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if forced != 4 {
|
||||||
|
t.Errorf("the stack made you take %d cards, want 4", forced)
|
||||||
|
}
|
||||||
|
if len(paid.Hands[You]) < before+4 {
|
||||||
|
t.Errorf("hand went %d → %d, want at least four more", before, len(paid.Hands[You]))
|
||||||
|
}
|
||||||
|
// The bill you paid is gone. Anything pending now is a new stack the bot
|
||||||
|
// opened after yours was settled, and it is never the one you just paid.
|
||||||
|
if paid.Pending == 4 && paid.Phase == PhaseStack {
|
||||||
|
t.Error("the bill you just paid is still standing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwentyFiveCardsKillsYou is the mercy rule, from the player's side: the
|
||||||
|
// stake is gone the moment the hand hits the limit, whoever else is still playing.
|
||||||
|
func TestTwentyFiveCardsKillsYou(t *testing.T) {
|
||||||
|
s := deal(t, nmFull(), 100, 3)
|
||||||
|
// Twenty-four cards in your hand, and a stack of ten pointed at you.
|
||||||
|
//
|
||||||
|
// The cards are *moved* from the deck, not invented: a fixture that conjures
|
||||||
|
// a hand out of nothing breaks the census before the engine gets a chance to,
|
||||||
|
// and then the census assertion below is testing the fixture instead of the
|
||||||
|
// mercy rule.
|
||||||
|
stack(&s, You, 24)
|
||||||
|
s.Turn = You
|
||||||
|
s.Phase = PhaseStack
|
||||||
|
s.Pending = 10
|
||||||
|
|
||||||
|
next, evs, err := ApplyMove(s, Move{Kind: MoveTake})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("take: %v", err)
|
||||||
|
}
|
||||||
|
if !hasKind(evs, EvMercy) {
|
||||||
|
t.Fatal("no mercy event: twenty-five cards should have killed the seat")
|
||||||
|
}
|
||||||
|
if next.Phase != PhaseDone || next.Outcome != OutcomeLost {
|
||||||
|
t.Fatalf("phase %s outcome %q, want done/lost", next.Phase, next.Outcome)
|
||||||
|
}
|
||||||
|
if next.Payout != 0 {
|
||||||
|
t.Errorf("a mercy kill paid out %d, want nothing", next.Payout)
|
||||||
|
}
|
||||||
|
if len(next.Hands[You]) != 0 || next.live(You) {
|
||||||
|
t.Error("a dead seat should hold no cards and be out of the game")
|
||||||
|
}
|
||||||
|
if got := total(census(next)); got != 168 {
|
||||||
|
t.Errorf("%d cards after a mercy kill, want 168 — the hand goes back in the deck", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOutlivingTheTableWins is the other side of the mercy rule, and the one
|
||||||
|
// that makes No Mercy pay less than it looks like it should: the deck buries bots
|
||||||
|
// too, and a table with every bot dead is a table you have won.
|
||||||
|
func TestOutlivingTheTableWins(t *testing.T) {
|
||||||
|
s := deal(t, nmDuel(), 100, 11)
|
||||||
|
s.Color = Red
|
||||||
|
s.Discard = []Card{{Red, Five}}
|
||||||
|
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
|
||||||
|
s.Hands[1] = make([]Card, 0, 24)
|
||||||
|
for i := 0; i < 24; i++ {
|
||||||
|
s.Hands[1] = append(s.Hands[1], Card{Blue, Nine}) // nothing it can answer with
|
||||||
|
}
|
||||||
|
s.Turn = You
|
||||||
|
s.Phase = PhasePlay
|
||||||
|
|
||||||
|
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("play +2: %v", err)
|
||||||
|
}
|
||||||
|
if !hasKind(evs, EvMercy) {
|
||||||
|
t.Fatal("the bot should have died taking the stack")
|
||||||
|
}
|
||||||
|
if next.Phase != PhaseDone || next.Outcome != OutcomeWon {
|
||||||
|
t.Fatalf("phase %s outcome %q, want done/won: the last seat standing wins",
|
||||||
|
next.Phase, next.Outcome)
|
||||||
|
}
|
||||||
|
if next.Payout != next.Pays() {
|
||||||
|
t.Errorf("paid %d, quoted %d — settle and the felt must agree", next.Payout, next.Pays())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYouDrawUntilYouCanPlay: no drawing one card and shrugging. The turn only
|
||||||
|
// moves on when the deck itself has nothing left.
|
||||||
|
func TestYouDrawUntilYouCanPlay(t *testing.T) {
|
||||||
|
s := deal(t, nmDuel(), 100, 5)
|
||||||
|
s.Color = Red
|
||||||
|
s.Discard = []Card{{Red, Five}}
|
||||||
|
s.Hands[You] = []Card{{Blue, One}} // nothing playable
|
||||||
|
// A deck whose first two cards are dead and whose third plays.
|
||||||
|
s.Deck = []Card{{Green, Two}, {Yellow, Three}, {Red, Nine}, {Blue, Four}}
|
||||||
|
s.Turn = You
|
||||||
|
s.Phase = PhasePlay
|
||||||
|
|
||||||
|
next, _, err := ApplyMove(s, Move{Kind: MoveDraw})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("draw: %v", err)
|
||||||
|
}
|
||||||
|
if len(next.Hands[You]) != 4 {
|
||||||
|
t.Fatalf("hand is %d, want 4: you draw until something plays",
|
||||||
|
len(next.Hands[You]))
|
||||||
|
}
|
||||||
|
if next.Phase != PhaseDrawn {
|
||||||
|
t.Fatalf("phase %s, want drawn: the card you stopped on is one you must play",
|
||||||
|
next.Phase)
|
||||||
|
}
|
||||||
|
// And you may not pass on it: you drew for it, you play it.
|
||||||
|
if _, _, err := ApplyMove(next, Move{Kind: MovePass}); err != ErrMustPlayNow {
|
||||||
|
t.Errorf("passing in No Mercy: %v, want ErrMustPlayNow", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSkipAllComesBackToYou — everyone else loses their turn, so the turn never
|
||||||
|
// actually leaves the seat that played it.
|
||||||
|
func TestSkipAllComesBackToYou(t *testing.T) {
|
||||||
|
s := deal(t, nmFull(), 100, 13)
|
||||||
|
s.Color = Red
|
||||||
|
s.Discard = []Card{{Red, Five}}
|
||||||
|
s.Hands[You] = []Card{{Red, SkipAll}, {Blue, One}}
|
||||||
|
s.Turn = You
|
||||||
|
s.Phase = PhasePlay
|
||||||
|
|
||||||
|
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("play skip-all: %v", err)
|
||||||
|
}
|
||||||
|
if next.Turn != You {
|
||||||
|
t.Errorf("turn went to seat %d, want you: skip-all skips everyone else", next.Turn)
|
||||||
|
}
|
||||||
|
if !hasKind(evs, EvSkipAll) {
|
||||||
|
t.Error("no skipall event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiscardAllTakesTheColourWithIt, and the cards it takes are still in the
|
||||||
|
// game — buried under the pile, not deleted.
|
||||||
|
func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
|
||||||
|
s := deal(t, nmDuel(), 100, 17)
|
||||||
|
s.Color = Red
|
||||||
|
s.Discard = []Card{{Red, Five}}
|
||||||
|
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Blue, Two}}
|
||||||
|
s.Turn = You
|
||||||
|
s.Phase = PhasePlay
|
||||||
|
before := total(census(s))
|
||||||
|
|
||||||
|
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("play discard-all: %v", err)
|
||||||
|
}
|
||||||
|
if len(next.Hands[You]) != 1 {
|
||||||
|
t.Fatalf("hand is %d, want 1: every red should have gone with it",
|
||||||
|
len(next.Hands[You]))
|
||||||
|
}
|
||||||
|
if next.Hands[You][0] != (Card{Blue, Two}) {
|
||||||
|
t.Errorf("kept %v, want the blue two", next.Hands[You][0])
|
||||||
|
}
|
||||||
|
if top := next.Top(); top.Value != DiscardAll {
|
||||||
|
t.Errorf("the card in play is %v, want the discard-all that was played", top.Value)
|
||||||
|
}
|
||||||
|
if !hasKind(evs, EvDiscardAll) {
|
||||||
|
t.Error("no discard event")
|
||||||
|
}
|
||||||
|
if got := total(census(next)); got != before {
|
||||||
|
t.Errorf("%d cards, want %d: a dumped colour is buried, not destroyed", got, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRouletteFlipsUntilTheColour — and the victim keeps every card it turned.
|
||||||
|
func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
||||||
|
s := deal(t, nmDuel(), 100, 19)
|
||||||
|
s.Color = Blue
|
||||||
|
s.Discard = []Card{{Blue, Five}}
|
||||||
|
s.Hands[You] = []Card{{Wild, WildRoulette}, {Blue, One}}
|
||||||
|
s.Hands[1] = []Card{{Green, Three}}
|
||||||
|
s.Deck = []Card{{Blue, Two}, {Green, Four}, {Yellow, Six}, {Red, Seven}, {Blue, Eight}}
|
||||||
|
s.Turn = You
|
||||||
|
s.Phase = PhasePlay
|
||||||
|
|
||||||
|
// Name red: the bot flips blue, green, yellow, red — four cards — and keeps them.
|
||||||
|
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Red})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("play roulette: %v", err)
|
||||||
|
}
|
||||||
|
var got int
|
||||||
|
for _, e := range evs {
|
||||||
|
if e.Kind == EvRoulette {
|
||||||
|
got = e.N
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got != 4 {
|
||||||
|
t.Errorf("flipped %d, want 4 — up to and including the first red", got)
|
||||||
|
}
|
||||||
|
// One card it started with, plus the four it turned. (The bot is then skipped,
|
||||||
|
// so the turn is back with you and it never played any of them.)
|
||||||
|
if n := len(next.Hands[1]); n != 5 {
|
||||||
|
t.Errorf("the bot holds %d, want 5", n)
|
||||||
|
}
|
||||||
|
if total(census(next)) != total(census(s)) {
|
||||||
|
t.Error("the roulette lost a card")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTheMultiplesAreStillPriced measures the naive strategy against the bots and
|
||||||
|
// checks each tier still charges roughly the house's edge for it.
|
||||||
|
//
|
||||||
|
// This is the test that fails when somebody changes the bots, the deck, or a
|
||||||
|
// rule, and it is *supposed* to: the tier and the game it prices are a pair. If
|
||||||
|
// this goes red, re-measure and move the number, don't loosen the bound.
|
||||||
|
func TestTheMultiplesAreStillPriced(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("slow: plays thousands of games")
|
||||||
|
}
|
||||||
|
for _, tier := range AllTiers() {
|
||||||
|
wins, games := 0, 3000
|
||||||
|
for seed := 0; seed < games; seed++ {
|
||||||
|
s := deal(t, tier, 100, uint64(seed)+7777)
|
||||||
|
rng := rand.New(rand.NewPCG(uint64(seed), 4242))
|
||||||
|
for moves := 0; s.Phase != PhaseDone && moves < 800; moves++ {
|
||||||
|
next, _, err := ApplyMove(s, naive(s, rng))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", tier.Slug, err)
|
||||||
|
}
|
||||||
|
s = next
|
||||||
|
}
|
||||||
|
if s.Outcome.Won() {
|
||||||
|
wins++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p := float64(wins) / float64(games)
|
||||||
|
// What a staked chip comes back as, playing badly: you win p of the time and
|
||||||
|
// keep the multiple less the rake on the profit, and lose the stake the rest.
|
||||||
|
ev := p*(1+(tier.Base-1)*(1-rake)) - 1
|
||||||
|
t.Logf("%-8s bots=%d base=%.2f naive win rate %.1f%% house edge %.1f%%",
|
||||||
|
tier.Slug, tier.Bots, tier.Base, p*100, -ev*100)
|
||||||
|
if ev < -0.14 || ev > -0.02 {
|
||||||
|
t.Errorf("%s: the house edge on naive play is %.1f%%, which is outside the 2–14%% "+
|
||||||
|
"band the tiers are priced to. Re-measure Base: %.2f would put it near 8%%.",
|
||||||
|
tier.Slug, -ev*100, (0.92/p-1)/(1-rake)+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ var (
|
|||||||
ErrNeedColor = errors.New("uno: pick a colour for the wild")
|
ErrNeedColor = errors.New("uno: pick a colour for the wild")
|
||||||
ErrCantPass = errors.New("uno: you can only pass on a card you just drew")
|
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")
|
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")
|
ErrUnknownMove = errors.New("uno: unknown move")
|
||||||
ErrBadBet = errors.New("uno: bet must be positive")
|
ErrBadBet = errors.New("uno: bet must be positive")
|
||||||
ErrUnknownTier = errors.New("uno: no such tier")
|
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.
|
// Value is what's printed on the face.
|
||||||
type Value uint8
|
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 (
|
const (
|
||||||
Zero Value = iota
|
Zero Value = iota
|
||||||
One
|
One
|
||||||
@@ -96,13 +102,23 @@ const (
|
|||||||
DrawTwo
|
DrawTwo
|
||||||
WildCard
|
WildCard
|
||||||
WildDrawFour
|
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",
|
var valueNames = [22]string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
|
||||||
"skip", "reverse", "+2", "wild", "+4"}
|
"skip", "reverse", "+2", "wild", "+4",
|
||||||
|
"skip all", "+4", "discard all", "rev +4", "+6", "+10", "roulette"}
|
||||||
|
|
||||||
func (v Value) String() string {
|
func (v Value) String() string {
|
||||||
if v > WildDrawFour {
|
if v > WildRoulette {
|
||||||
return "?"
|
return "?"
|
||||||
}
|
}
|
||||||
return valueNames[v]
|
return valueNames[v]
|
||||||
@@ -111,6 +127,35 @@ func (v Value) String() string {
|
|||||||
// Action reports whether a card does something beyond being a number.
|
// Action reports whether a card does something beyond being a number.
|
||||||
func (v Value) Action() bool { return v >= Skip }
|
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
|
// Card is one card. Short JSON keys: a hand of these crosses the wire on every
|
||||||
// poll, and a state holds all 108.
|
// poll, and a state holds all 108.
|
||||||
type Card struct {
|
type Card struct {
|
||||||
@@ -119,7 +164,7 @@ type Card struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsWild reports whether the card has no colour of its own.
|
// 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
|
// 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
|
// 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
|
// 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
|
// more. This is the tier dial every other game here has, pointed at the one knob
|
||||||
// UNO actually has.
|
// 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 {
|
type Tier struct {
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Bots int `json:"bots"`
|
Bots int `json:"bots"`
|
||||||
Base float64 `json:"base"` // what going out first pays, before the rake
|
Base float64 `json:"base"` // what going out first pays, before the rake
|
||||||
Blurb string `json:"blurb"`
|
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.
|
// 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
|
// 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
|
// 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.
|
// 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{
|
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."},
|
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."},
|
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."},
|
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) {
|
func TierBySlug(slug string) (Tier, error) {
|
||||||
for _, t := range Tiers {
|
for _, t := range AllTiers() {
|
||||||
if t.Slug == slug {
|
if t.Slug == slug {
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
@@ -194,6 +289,7 @@ type Phase string
|
|||||||
const (
|
const (
|
||||||
PhasePlay Phase = "play" // your turn, play or draw
|
PhasePlay Phase = "play" // your turn, play or draw
|
||||||
PhaseDrawn Phase = "drawn" // you drew a card you can play: play it or pass
|
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"
|
PhaseDone Phase = "done"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -222,6 +318,13 @@ type State struct {
|
|||||||
Turn int `json:"turn"`
|
Turn int `json:"turn"`
|
||||||
Dir int `json:"dir"` // +1 clockwise, -1 after a reverse
|
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"`
|
Seed1 uint64 `json:"seed1"`
|
||||||
Seed2 uint64 `json:"seed2"`
|
Seed2 uint64 `json:"seed2"`
|
||||||
Step uint64 `json:"step"` // how many moves have been applied; the rng's other half
|
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
|
// uno a hand is down to one card
|
||||||
// reshuffle the discard goes back under
|
// reshuffle the discard goes back under
|
||||||
// settle it's over
|
// 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 (
|
const (
|
||||||
EvDeal = "deal"
|
EvDeal = "deal"
|
||||||
EvPlay = "play"
|
EvPlay = "play"
|
||||||
@@ -270,6 +381,12 @@ const (
|
|||||||
EvUno = "uno"
|
EvUno = "uno"
|
||||||
EvReshuffle = "reshuffle"
|
EvReshuffle = "reshuffle"
|
||||||
EvSettle = "settle"
|
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 —
|
// 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
|
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 (
|
const (
|
||||||
MovePlay = "play"
|
MovePlay = "play"
|
||||||
MoveDraw = "draw"
|
MoveDraw = "draw"
|
||||||
MovePass = "pass"
|
MovePass = "pass"
|
||||||
|
MoveTake = "take"
|
||||||
)
|
)
|
||||||
|
|
||||||
// New deals a game: a shuffled deck, seven each, and a card turned over.
|
// 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)
|
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] })
|
rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] })
|
||||||
|
|
||||||
s := State{
|
s := State{
|
||||||
@@ -313,6 +433,7 @@ func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Even
|
|||||||
}
|
}
|
||||||
|
|
||||||
seats := t.Bots + 1
|
seats := t.Bots + 1
|
||||||
|
s.Out = make([]bool, seats)
|
||||||
s.Hands = make([][]Card, seats)
|
s.Hands = make([][]Card, seats)
|
||||||
for i := range s.Hands {
|
for i := range s.Hands {
|
||||||
s.Hands[i] = make([]Card, 0, HandSize)
|
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)
|
evs, err = next.playerDraws(rng)
|
||||||
case MovePass:
|
case MovePass:
|
||||||
evs, err = next.playerPasses()
|
evs, err = next.playerPasses()
|
||||||
|
case MoveTake:
|
||||||
|
evs, err = next.playerTakes(rng)
|
||||||
default:
|
default:
|
||||||
return s, nil, ErrUnknownMove
|
return s, nil, ErrUnknownMove
|
||||||
}
|
}
|
||||||
@@ -401,7 +524,15 @@ func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
|
|||||||
return nil, ErrMustPlayNow
|
return nil, ErrMustPlayNow
|
||||||
}
|
}
|
||||||
card := hand[m.Index]
|
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
|
return nil, ErrCantPlay
|
||||||
}
|
}
|
||||||
if card.IsWild() && !m.Color.Playable() {
|
if card.IsWild() && !m.Color.Playable() {
|
||||||
@@ -415,29 +546,78 @@ func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
|
|||||||
return evs, nil
|
return evs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// playerDraws takes one off the deck. If it can be played you get the choice —
|
// playerDraws takes cards off the deck.
|
||||||
// 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.
|
// 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) {
|
func (s *State) playerDraws(rng *rand.Rand) ([]Event, error) {
|
||||||
if s.Phase == PhaseDrawn {
|
if s.Phase == PhaseDrawn {
|
||||||
return nil, ErrMustPlayNow // you already drew; play it or pass
|
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
|
var evs []Event
|
||||||
drawn := s.deal(You, 1, false, &evs, rng)
|
|
||||||
if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) {
|
if !s.Tier.NoMercy {
|
||||||
s.Phase = PhaseDrawn
|
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
|
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})
|
evs = append(evs, Event{Kind: EvPass, Seat: You})
|
||||||
s.advance(1)
|
s.advance(1)
|
||||||
return evs, nil
|
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.
|
// 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) {
|
func (s *State) playerPasses() ([]Event, error) {
|
||||||
if s.Phase != PhaseDrawn {
|
if s.Phase != PhaseDrawn {
|
||||||
return nil, ErrCantPass
|
return nil, ErrCantPass
|
||||||
}
|
}
|
||||||
|
if s.Tier.NoMercy {
|
||||||
|
return nil, ErrMustPlayNow
|
||||||
|
}
|
||||||
s.Phase = PhasePlay
|
s.Phase = PhasePlay
|
||||||
s.advance(1)
|
s.advance(1)
|
||||||
return []Event{{Kind: EvPass, Seat: You}}, nil
|
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.
|
// botTurn plays one bot's turn.
|
||||||
func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) {
|
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)
|
// A stack pointed at this bot is not a turn, it is a bill. It answers with a
|
||||||
if idx < 0 {
|
// draw card if it holds one, and takes the lot if it doesn't.
|
||||||
// Nothing playable: draw one, and play it if it happens to go.
|
if s.Phase == PhaseStack {
|
||||||
drawn := s.deal(seat, 1, false, evs, rng)
|
card, idx := botStack(s.Hands[seat], s.Color, rng)
|
||||||
if len(drawn) != 1 || !drawn[0].CanPlayOn(s.top(), s.Color) {
|
if idx < 0 {
|
||||||
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
|
s.absorb(seat, evs, rng)
|
||||||
s.advance(1)
|
|
||||||
return
|
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]
|
hand := s.Hands[seat]
|
||||||
s.Hands[seat] = append(hand[:idx:idx], hand[idx+1:]...)
|
s.Hands[seat] = append(hand[:idx:idx], hand[idx+1:]...)
|
||||||
|
|
||||||
color := card.Color
|
color := card.Color
|
||||||
if card.IsWild() {
|
if card.IsWild() {
|
||||||
color = botColor(s.Hands[seat], rng)
|
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.discard(seat, card, color, evs)
|
||||||
s.after(seat, card, evs, rng)
|
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
|
// 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.
|
// out, because the cage won't let you leave a hand half-played.
|
||||||
func (s State) stalled() bool {
|
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 {
|
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
|
return false // there is a card to draw, or a discard to make one out of
|
||||||
}
|
}
|
||||||
for _, hand := range s.Hands {
|
for _, seat := range s.alive() {
|
||||||
for _, c := range hand {
|
for _, c := range s.Hands[seat] {
|
||||||
if c.CanPlayOn(s.top(), s.Color) {
|
if c.CanPlayOn(s.top(), s.Color) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -532,6 +752,24 @@ func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) {
|
|||||||
}
|
}
|
||||||
s.Phase = PhasePlay
|
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 {
|
switch card.Value {
|
||||||
case Skip:
|
case Skip:
|
||||||
victim := s.seatAt(1)
|
victim := s.seatAt(1)
|
||||||
@@ -539,31 +777,50 @@ func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) {
|
|||||||
s.advance(2)
|
s.advance(2)
|
||||||
|
|
||||||
case Reverse:
|
case Reverse:
|
||||||
// Two at the table and a reverse has nobody to hand the turn back to, so it
|
s.flip(seat, evs)
|
||||||
// is a skip — which, with two players, means you go again.
|
|
||||||
if len(s.Hands) == 2 {
|
case SkipAll:
|
||||||
*evs = append(*evs, Event{Kind: EvSkip, Seat: s.seatAt(1)})
|
// Everyone else loses their turn, which means it comes straight back to the
|
||||||
s.advance(2)
|
// 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
|
return
|
||||||
}
|
}
|
||||||
s.Dir = -s.Dir
|
|
||||||
*evs = append(*evs, Event{Kind: EvReverse, Seat: seat})
|
|
||||||
s.advance(1)
|
s.advance(1)
|
||||||
|
|
||||||
case DrawTwo:
|
case WildRoulette:
|
||||||
s.punish(s.seatAt(1), 2, evs, rng)
|
s.roulette(s.seatAt(1), s.Color, evs, rng)
|
||||||
|
|
||||||
case WildDrawFour:
|
|
||||||
s.punish(s.seatAt(1), 4, evs, rng)
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
s.advance(1)
|
s.advance(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// punish makes the next seat eat a draw card and lose its turn. No stacking: a
|
// flip turns the direction round — or, at a table of two, skips the only other
|
||||||
// +2 played onto a +2 is a house rule, and the one this table plays is the one
|
// player, because a reverse with nobody to hand the turn back to is a card that
|
||||||
// on the box.
|
// 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) {
|
func (s *State) punish(victim, n int, evs *[]Event, rng *rand.Rand) {
|
||||||
s.deal(victim, n, true, evs, rng)
|
s.deal(victim, n, true, evs, rng)
|
||||||
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim})
|
*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
|
// 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.
|
// is not a win, because a win here has to be somebody actually going out.
|
||||||
func (s *State) stuck(evs *[]Event) {
|
func (s *State) stuck(evs *[]Event) {
|
||||||
best, tied := 0, false
|
live := s.alive()
|
||||||
for seat := range s.Hands {
|
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 {
|
switch {
|
||||||
case len(s.Hands[seat]) < len(s.Hands[best]):
|
case len(s.Hands[seat]) < len(s.Hands[best]):
|
||||||
best, tied = seat, false
|
best, tied = seat, false
|
||||||
@@ -731,6 +993,17 @@ func (s State) Playable() []int {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
var out []int
|
||||||
for i, c := range hand {
|
for i, c := range hand {
|
||||||
if c.CanPlayOn(s.top(), s.Color) {
|
if c.CanPlayOn(s.top(), s.Color) {
|
||||||
@@ -780,10 +1053,26 @@ func (s *State) pop() (Card, bool) {
|
|||||||
return c, true
|
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 {
|
func (s State) seatAt(n int) int {
|
||||||
seats := len(s.Hands)
|
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.
|
// advance moves the turn on n places.
|
||||||
@@ -815,6 +1104,7 @@ func (s State) clone() State {
|
|||||||
s.Deck = append([]Card(nil), s.Deck...)
|
s.Deck = append([]Card(nil), s.Deck...)
|
||||||
s.Discard = append([]Card(nil), s.Discard...)
|
s.Discard = append([]Card(nil), s.Discard...)
|
||||||
s.Bots = append([]string(nil), s.Bots...)
|
s.Bots = append([]string(nil), s.Bots...)
|
||||||
|
s.Out = append([]bool(nil), s.Out...)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,11 @@ func census(s State) map[Card]int {
|
|||||||
}
|
}
|
||||||
for _, c := range s.Discard {
|
for _, c := range s.Discard {
|
||||||
// A wild is stamped with the colour it was played as while it sits on the
|
// A wild is stamped with the colour it was played as while it sits on the
|
||||||
// pile, so it counts as the wild it really is.
|
// pile, so it counts as the wild it really is. This asks the face, rather
|
||||||
if c.Value == WildCard || c.Value == WildDrawFour {
|
// than listing the wilds: No Mercy prints four more of them, and a census
|
||||||
|
// that didn't know about them would count a played roulette as a red card
|
||||||
|
// and report a deck that balances while the cards don't.
|
||||||
|
if c.Value.Wild() {
|
||||||
c.Color = Wild
|
c.Color = Wild
|
||||||
}
|
}
|
||||||
m[c]++
|
m[c]++
|
||||||
@@ -478,22 +481,37 @@ func TestQuoteIsThePayout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The rake comes out of the winnings, never the stake.
|
// The rake comes out of the winnings, never the stake.
|
||||||
|
//
|
||||||
|
// The arithmetic is derived from the tier rather than written down. It used to be
|
||||||
|
// written down — payout 214, rake 6 — and those numbers were the 2.2× duel. When
|
||||||
|
// the tiers were re-measured and repriced, this test failed on a rake that was
|
||||||
|
// perfectly correct, which is a test asserting a *price* while claiming to assert
|
||||||
|
// a *rule*. The rule is: the house takes its cut of the profit and never touches
|
||||||
|
// the stake. That holds at any multiple.
|
||||||
func TestRakeIsOnWinningsOnly(t *testing.T) {
|
func TestRakeIsOnWinningsOnly(t *testing.T) {
|
||||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
||||||
s.Tier = duel() // 2.2x on 100: 220 back, 120 of it profit, 6 of that to the house
|
s.Tier = duel()
|
||||||
s.Bet = 100
|
s.Bet = 100
|
||||||
|
|
||||||
|
gross := int64(float64(s.Bet) * s.Tier.Base) // what the tier pays back, before the house
|
||||||
|
profit := gross - s.Bet
|
||||||
|
wantRake := int64(float64(profit) * rake)
|
||||||
|
wantPayout := s.Bet + profit - wantRake
|
||||||
|
|
||||||
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("go out: %v", err)
|
t.Fatalf("go out: %v", err)
|
||||||
}
|
}
|
||||||
if next.Payout != 214 {
|
if next.Payout != wantPayout {
|
||||||
t.Errorf("payout %d, want 214 (100 stake + 120 winnings - 6 rake)", next.Payout)
|
t.Errorf("payout %d, want %d (%d stake + %d winnings - %d rake)",
|
||||||
|
next.Payout, wantPayout, s.Bet, profit, wantRake)
|
||||||
}
|
}
|
||||||
if next.Rake != 6 {
|
if next.Rake != wantRake {
|
||||||
t.Errorf("rake %d, want 6", next.Rake)
|
t.Errorf("rake %d, want %d — and never a penny of the %d stake",
|
||||||
|
next.Rake, wantRake, s.Bet)
|
||||||
}
|
}
|
||||||
if next.Net() != 114 {
|
if next.Net() != wantPayout-s.Bet {
|
||||||
t.Errorf("net %d, want 114", next.Net())
|
t.Errorf("net %d, want %d", next.Net(), wantPayout-s.Bet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ type gamesPage struct {
|
|||||||
Quizzes []trivia.Tier // trivia's three difficulties
|
Quizzes []trivia.Tier // trivia's three difficulties
|
||||||
Rungs int // how long the trivia ladder is
|
Rungs int // how long the trivia ladder is
|
||||||
Tables []uno.Tier // uno's three tables, and how many bots sit at each
|
Tables []uno.Tier // uno's three tables, and how many bots sit at each
|
||||||
|
NoMercy []uno.Tier // the same three, playing the other rules
|
||||||
|
MercyLimit int // the hand that ends you in No Mercy
|
||||||
Stakes []holdem.Tier // hold'em's three tables, by blinds
|
Stakes []holdem.Tier // hold'em's three tables, by blinds
|
||||||
MaxBots int // how many seats hold'em will fill with bots
|
MaxBots int // how many seats hold'em will fill with bots
|
||||||
}
|
}
|
||||||
@@ -153,6 +155,8 @@ func (s *Server) gamesPage(r *http.Request) gamesPage {
|
|||||||
Quizzes: trivia.Tiers,
|
Quizzes: trivia.Tiers,
|
||||||
Rungs: trivia.Rungs,
|
Rungs: trivia.Rungs,
|
||||||
Tables: uno.Tiers,
|
Tables: uno.Tiers,
|
||||||
|
NoMercy: uno.NoMercyTiers,
|
||||||
|
MercyLimit: uno.MercyLimit,
|
||||||
Stakes: holdem.Tiers,
|
Stakes: holdem.Tiers,
|
||||||
MaxBots: holdem.MaxBots,
|
MaxBots: holdem.MaxBots,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type unoSeatView struct {
|
|||||||
Cards int `json:"cards"`
|
Cards int `json:"cards"`
|
||||||
You bool `json:"you"`
|
You bool `json:"you"`
|
||||||
Uno bool `json:"uno"` // down to one card
|
Uno bool `json:"uno"` // down to one card
|
||||||
|
Out bool `json:"out"` // No Mercy: buried at twenty-five, and not coming back
|
||||||
}
|
}
|
||||||
|
|
||||||
// unoView is a game as its player may see it.
|
// unoView is a game as its player may see it.
|
||||||
@@ -65,6 +66,11 @@ type unoView struct {
|
|||||||
Turn int `json:"turn"`
|
Turn int `json:"turn"`
|
||||||
Dir int `json:"dir"`
|
Dir int `json:"dir"`
|
||||||
|
|
||||||
|
// No Mercy: the bill a stack of draw cards has run up. Whoever stops stacking
|
||||||
|
// pays it, and while it stands it is the only thing on the table that matters —
|
||||||
|
// so the felt has to be able to say what it is.
|
||||||
|
Pending int `json:"pending"`
|
||||||
|
|
||||||
Bet int64 `json:"bet"`
|
Bet int64 `json:"bet"`
|
||||||
Pays int64 `json:"pays"` // what going out right now would actually pay
|
Pays int64 `json:"pays"` // what going out right now would actually pay
|
||||||
Phase string `json:"phase"`
|
Phase string `json:"phase"`
|
||||||
@@ -83,6 +89,7 @@ func viewUno(g uno.State) unoView {
|
|||||||
Deck: g.Left(),
|
Deck: g.Left(),
|
||||||
Turn: g.Turn,
|
Turn: g.Turn,
|
||||||
Dir: g.Dir,
|
Dir: g.Dir,
|
||||||
|
Pending: g.Pending,
|
||||||
Bet: g.Bet,
|
Bet: g.Bet,
|
||||||
Pays: g.Pays(),
|
Pays: g.Pays(),
|
||||||
Phase: string(g.Phase),
|
Phase: string(g.Phase),
|
||||||
@@ -92,18 +99,27 @@ func viewUno(g uno.State) unoView {
|
|||||||
Rake: g.Rake,
|
Rake: g.Rake,
|
||||||
Net: g.Net(),
|
Net: g.Net(),
|
||||||
}
|
}
|
||||||
|
// An empty hand is a seat that went out — *unless* the mercy rule took it, in
|
||||||
|
// which case an empty hand is a grave. Those two look identical from the count
|
||||||
|
// alone, which is why a buried seat is asked about rather than inferred.
|
||||||
for i, n := range g.Counts() {
|
for i, n := range g.Counts() {
|
||||||
seat := unoSeatView{Cards: n, You: i == uno.You, Uno: n == 1}
|
live := g.Live(i)
|
||||||
|
seat := unoSeatView{Cards: n, You: i == uno.You, Uno: live && n == 1, Out: !live}
|
||||||
if i == uno.You {
|
if i == uno.You {
|
||||||
seat.Name = "You"
|
seat.Name = "You"
|
||||||
} else if i-1 < len(g.Bots) {
|
} else if i-1 < len(g.Bots) {
|
||||||
seat.Name = g.Bots[i-1]
|
seat.Name = g.Bots[i-1]
|
||||||
}
|
}
|
||||||
v.Seats = append(v.Seats, seat)
|
v.Seats = append(v.Seats, seat)
|
||||||
if n == 0 {
|
if live && n == 0 {
|
||||||
v.Winner = i
|
v.Winner = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// And you can win a No Mercy table without ever going out: outlive it, and the
|
||||||
|
// last seat standing is you, with a hand still in it.
|
||||||
|
if v.Winner < 0 && g.Outcome.Won() {
|
||||||
|
v.Winner = uno.You
|
||||||
|
}
|
||||||
for _, c := range g.Hands[uno.You] {
|
for _, c := range g.Hands[uno.You] {
|
||||||
v.Hand = append(v.Hand, viewUnoCard(c))
|
v.Hand = append(v.Hand, viewUnoCard(c))
|
||||||
}
|
}
|
||||||
@@ -248,6 +264,10 @@ func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
|
|||||||
msg = "play the card you drew, or pass"
|
msg = "play the card you drew, or pass"
|
||||||
case errors.Is(err, uno.ErrCantPass):
|
case errors.Is(err, uno.ErrCantPass):
|
||||||
msg = "draw first, then you can pass"
|
msg = "draw first, then you can pass"
|
||||||
|
case errors.Is(err, uno.ErrMustStack):
|
||||||
|
msg = "answer the stack with a draw card, or take it"
|
||||||
|
case errors.Is(err, uno.ErrNoStack):
|
||||||
|
msg = "there's nothing pointed at you to take"
|
||||||
}
|
}
|
||||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -145,3 +145,71 @@ func TestUnoWildWithNoColourIsRefused(t *testing.T) {
|
|||||||
t.Logf("colour in play after the bots: %s", v.Uno.Color)
|
t.Logf("colour in play after the bots: %s", v.Uno.Color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A seat the mercy rule has buried holds no cards. So does a seat that has just
|
||||||
|
// gone out and won. The view has to tell them apart, because everything it says
|
||||||
|
// afterwards hangs off it: who won, whose fan of cards to rub out, and whether the
|
||||||
|
// player is looking at a payout or a grave.
|
||||||
|
//
|
||||||
|
// This is the whole reason the view asks the engine (Live) instead of inferring it
|
||||||
|
// from a count of zero.
|
||||||
|
func TestABuriedSeatIsNotTheWinner(t *testing.T) {
|
||||||
|
g, _, err := uno.New(100, mustTier(t, "nm-full"), 0.05, 7, 9)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bury seat 2, the way the engine does: no cards, and out.
|
||||||
|
g.Hands[2] = nil
|
||||||
|
g.Out[2] = true
|
||||||
|
|
||||||
|
v := viewUno(g)
|
||||||
|
if !v.Seats[2].Out {
|
||||||
|
t.Error("a buried seat must say so; the felt draws it as a grave")
|
||||||
|
}
|
||||||
|
if v.Seats[2].Uno {
|
||||||
|
t.Error("a buried seat is not one card away from going out")
|
||||||
|
}
|
||||||
|
if v.Winner == 2 {
|
||||||
|
t.Fatal("the buried seat was reported as the winner — an empty hand is not a win")
|
||||||
|
}
|
||||||
|
if v.Winner != -1 {
|
||||||
|
t.Errorf("nobody has gone out yet, so there is no winner: got %d", v.Winner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the other ending only this deck has: you win by outliving the table, with
|
||||||
|
// a hand still in front of you.
|
||||||
|
g.Phase, g.Outcome, g.Payout = uno.PhaseDone, uno.OutcomeWon, g.Pays()
|
||||||
|
if v := viewUno(g); v.Winner != uno.You {
|
||||||
|
t.Errorf("you outlived the table; winner = %d, want you (%d)", v.Winner, uno.You)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bill a stack has run up is the only thing on the table while it stands, so it
|
||||||
|
// has to reach the browser. It is a No Mercy field on a struct the normal game
|
||||||
|
// shares, which is exactly the kind of field that quietly never gets copied across.
|
||||||
|
func TestTheStackBillCrossesTheWire(t *testing.T) {
|
||||||
|
g, _, err := uno.New(100, mustTier(t, "nm-duel"), 0.05, 3, 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g.Pending = 6
|
||||||
|
g.Phase = uno.PhaseStack
|
||||||
|
|
||||||
|
v := viewUno(g)
|
||||||
|
if v.Pending != 6 {
|
||||||
|
t.Errorf("the felt was told the bill is %d, want 6", v.Pending)
|
||||||
|
}
|
||||||
|
if v.Phase != string(uno.PhaseStack) {
|
||||||
|
t.Errorf("phase = %q, want %q", v.Phase, uno.PhaseStack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustTier(t *testing.T, slug string) uno.Tier {
|
||||||
|
t.Helper()
|
||||||
|
tier, err := uno.TierBySlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("no tier %q: %v", slug, err)
|
||||||
|
}
|
||||||
|
return tier
|
||||||
|
}
|
||||||
|
|||||||
@@ -1750,11 +1750,134 @@ html[data-phase="night"] {
|
|||||||
.pete-uno-swatch[data-c="yellow"] { --sw: #e0b02c; color: #2b2118; }
|
.pete-uno-swatch[data-c="yellow"] { --sw: #e0b02c; color: #2b2118; }
|
||||||
.pete-uno-swatch[data-c="green"] { --sw: #46a86b; }
|
.pete-uno-swatch[data-c="green"] { --sw: #46a86b; }
|
||||||
|
|
||||||
|
/* ---- No Mercy -------------------------------------------------------------
|
||||||
|
A rules dial, not a fourth table, and the felt says so: the same table, the
|
||||||
|
same cards, plus six faces the normal box doesn't print and one thing that
|
||||||
|
can be pointed at you — a stack of draw cards with a bill on it.
|
||||||
|
|
||||||
|
Sizing note, and it is the rule this table was taught the hard way: a card is
|
||||||
|
sized by --uno-h/--uno-w and nothing else. These faces set a *font*, never a
|
||||||
|
box, so a "DISCARD ALL" is the same card as a "7" at every width. */
|
||||||
|
|
||||||
|
/* The long faces. "DISCARD ALL" does not fit across an oval at card size, so it
|
||||||
|
wraps — and the oval is tilted, so it wraps on the tilt, which is what the
|
||||||
|
printed card does too. Specificity has to clear the wild rule above (a
|
||||||
|
reverse-draw-four is a wild), hence the .pete-uno-card in front. */
|
||||||
|
.pete-uno-card .pete-uno-oval[data-size="mid"] { font-size: 1.05rem; }
|
||||||
|
.pete-uno-card .pete-uno-oval[data-size="words"] {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The wild draws glow.
|
||||||
|
|
||||||
|
These are the cards that decide a game, and No Mercy prints a *coloured* +4
|
||||||
|
right next to the wild one, so at a glance in a hand of twenty they have to be
|
||||||
|
tellable apart. The aura is a sibling behind the card rather than a shadow on
|
||||||
|
it: a box-shadow here would fight the one that says which colour a played wild
|
||||||
|
was named as (data-named, above), and lose.
|
||||||
|
|
||||||
|
It sits under the card (z-index -1) and does not take clicks, so the card is
|
||||||
|
still the button. */
|
||||||
|
.pete-uno-card[data-glow="1"] { position: relative; z-index: 0; }
|
||||||
|
.pete-uno-card[data-glow="1"]::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -0.35rem;
|
||||||
|
z-index: -1;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
pointer-events: none;
|
||||||
|
background: conic-gradient(from 0deg,
|
||||||
|
#d64545, #e0b02c, #46a86b, #3d7fd6, #d64545);
|
||||||
|
filter: blur(7px);
|
||||||
|
opacity: 0.75;
|
||||||
|
animation: pete-uno-glow 3.2s linear infinite;
|
||||||
|
}
|
||||||
|
/* The shimmer across the face itself: a slow sweep of light, the way a foil card
|
||||||
|
catches the room when you tilt it. */
|
||||||
|
.pete-uno-card[data-glow="1"] .pete-uno-face::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(115deg,
|
||||||
|
transparent 35%, rgba(255,255,255,0.55) 50%, transparent 65%);
|
||||||
|
background-size: 260% 100%;
|
||||||
|
animation: pete-uno-shine 2.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pete-uno-glow {
|
||||||
|
to { transform: rotate(1turn); }
|
||||||
|
}
|
||||||
|
@keyframes pete-uno-shine {
|
||||||
|
0% { background-position: 140% 0; }
|
||||||
|
55% { background-position: -40% 0; }
|
||||||
|
100% { background-position: -40% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The bill. While a stack stands it is the only thing on the table that matters,
|
||||||
|
so it is loud, and it is red. */
|
||||||
|
.pete-uno-pending {
|
||||||
|
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 0.18rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #fff;
|
||||||
|
background: #cc3d4a;
|
||||||
|
box-shadow: 0 3px 0 rgba(0,0,0,0.2);
|
||||||
|
animation: pete-uno-bill 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pete-uno-bill {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.06); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A seat the mercy rule buried. It stays at the table — the felt would jump if
|
||||||
|
it didn't, and you want to see who's gone — but it is plainly out. */
|
||||||
|
.pete-uno-seat[data-out="1"] {
|
||||||
|
opacity: 0.4;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
.pete-uno-seat[data-out="1"] .pete-uno-count { color: #ffb3ba; }
|
||||||
|
|
||||||
|
/* The rules switch: two dials, and this is the one that isn't the table size. */
|
||||||
|
.pete-seg {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0.2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--ink) 6%, transparent);
|
||||||
|
}
|
||||||
|
.pete-seg-btn {
|
||||||
|
padding: 0.3rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: color-mix(in srgb, var(--ink) 55%, transparent);
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.pete-seg-btn:hover { color: var(--ink); }
|
||||||
|
.pete-seg-btn[data-on="1"] {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 0 rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
/* A phone. The cards get smaller so a hand of ten still fits without the felt
|
/* A phone. The cards get smaller so a hand of ten still fits without the felt
|
||||||
scrolling sideways, which is the thing to check at 390px with a game on it. */
|
scrolling sideways, which is the thing to check at 390px with a game on it.
|
||||||
|
No Mercy deals bigger hands than that — it is a deck built to bury you — so
|
||||||
|
this is the table where a twenty-card hand has to wrap and not overflow. */
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 639px) {
|
||||||
.pete-uno-hand { --uno-h: 5.2rem; --uno-w: 3.5rem; gap: 0.3rem; }
|
.pete-uno-hand { --uno-h: 5.2rem; --uno-w: 3.5rem; gap: 0.3rem; }
|
||||||
.pete-uno-deck { --uno-h: 5.2rem; --uno-w: 3.5rem; }
|
.pete-uno-deck { --uno-h: 5.2rem; --uno-w: 3.5rem; }
|
||||||
|
.pete-uno-card .pete-uno-oval[data-size="words"] { font-size: 0.42rem; }
|
||||||
|
.pete-uno-card .pete-uno-oval[data-size="mid"] { font-size: 0.9rem; }
|
||||||
/* The vars, not just the box: the card in the discard is a .pete-uno-card and
|
/* The vars, not just the box: the card in the discard is a .pete-uno-card and
|
||||||
takes its size from them, so sizing the box alone left a full-size card
|
takes its size from them, so sizing the box alone left a full-size card
|
||||||
hanging out of a small hole, on top of the colour in play. */
|
hanging out of a small hole, on top of the colour in play. */
|
||||||
@@ -1783,6 +1906,11 @@ html[data-phase="night"] {
|
|||||||
.pete-clock[data-hot="1"],
|
.pete-clock[data-hot="1"],
|
||||||
.pete-answer[data-state="wrong"] { animation: none; }
|
.pete-answer[data-state="wrong"] { animation: none; }
|
||||||
.pete-rung { transition: none; }
|
.pete-rung { transition: none; }
|
||||||
|
/* The wild draws keep their glow — it is what tells the card apart from the
|
||||||
|
coloured +4 beside it, so it is information — but it stops moving. */
|
||||||
|
.pete-uno-card[data-glow="1"]::before { animation: none; }
|
||||||
|
.pete-uno-card[data-glow="1"] .pete-uno-face::after { display: none; }
|
||||||
|
.pete-uno-pending { animation: none; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -514,9 +514,30 @@
|
|||||||
|
|
||||||
// ---- talking to the table --------------------------------------------------
|
// ---- talking to the table --------------------------------------------------
|
||||||
|
|
||||||
|
// lock is busy, said out loud on the buttons.
|
||||||
|
//
|
||||||
|
// send() drops a click that arrives while a move is in flight, and it is right
|
||||||
|
// to: the board on screen during a script is a board the server has already
|
||||||
|
// moved past. But the *between-hands* buttons — Deal, Leave, Top up — stayed
|
||||||
|
// enabled through the whole deal animation, so clicking Leave while the cards
|
||||||
|
// were still flying did nothing at all: no move, no message, no reason given.
|
||||||
|
// (The action buttons never had this problem; panels() hides the whole row when
|
||||||
|
// it isn't your turn.) A button that looks alive and does nothing has lied to
|
||||||
|
// you, so the lock lives on the buttons and not only in the variable.
|
||||||
|
//
|
||||||
|
// Top up keeps its own rule — it is dead when the wallet cannot cover it — and
|
||||||
|
// panels() owns that, so this only ever adds a reason to be disabled.
|
||||||
|
function lock(on) {
|
||||||
|
[foldBtn, checkBtn, callBtn, raiseBtn, dealBtn, leaveBtn].forEach(function (b) {
|
||||||
|
if (b) b.disabled = on;
|
||||||
|
});
|
||||||
|
if (topupBtn) topupBtn.disabled = on || Number(topupBtn.dataset.amount || 0) <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
function send(body, msgEl) {
|
function send(body, msgEl) {
|
||||||
if (busy) return Promise.resolve();
|
if (busy) return Promise.resolve();
|
||||||
busy = true;
|
busy = true;
|
||||||
|
lock(true);
|
||||||
say(msgEl, "");
|
say(msgEl, "");
|
||||||
// Whatever the last hand said about itself stops being true the moment you do
|
// Whatever the last hand said about itself stops being true the moment you do
|
||||||
// something. Only the "hand" beat used to clear this, so a verdict could linger
|
// something. Only the "hand" beat used to clear this, so a verdict could linger
|
||||||
@@ -540,7 +561,7 @@
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(function (err) { say(msgEl, err.message, "bad"); })
|
.catch(function (err) { say(msgEl, err.message, "bad"); })
|
||||||
.then(function () { busy = false; });
|
.then(function () { busy = false; lock(false); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// render0 is the table with nobody at it.
|
// render0 is the table with nobody at it.
|
||||||
|
|||||||
@@ -39,10 +39,16 @@
|
|||||||
var tableEl = root.querySelector("[data-table-name]");
|
var tableEl = root.querySelector("[data-table-name]");
|
||||||
|
|
||||||
var wildEl = root.querySelector("[data-wild]");
|
var wildEl = root.querySelector("[data-wild]");
|
||||||
|
// Not [data-pending]: that is the chip bar's, it lives inside this root, and it
|
||||||
|
// comes first in the document. See the note in uno.html.
|
||||||
|
var billEl = root.querySelector("[data-bill]");
|
||||||
var betting = root.querySelector("[data-betting]");
|
var betting = root.querySelector("[data-betting]");
|
||||||
var playing = root.querySelector("[data-playing]");
|
var playing = root.querySelector("[data-playing]");
|
||||||
var drawBtn = root.querySelector("[data-draw]");
|
var drawBtn = root.querySelector("[data-draw]");
|
||||||
var passBtn = root.querySelector("[data-pass]");
|
var passBtn = root.querySelector("[data-pass]");
|
||||||
|
var takeBtn = root.querySelector("[data-take]");
|
||||||
|
var takeN = root.querySelector("[data-take-n]");
|
||||||
|
var hintEl = root.querySelector("[data-hint]");
|
||||||
var betAmount = root.querySelector("[data-bet-amount]");
|
var betAmount = root.querySelector("[data-bet-amount]");
|
||||||
var startBtn = root.querySelector("[data-start]");
|
var startBtn = root.querySelector("[data-start]");
|
||||||
var msgEl = root.querySelector("[data-table-msg]");
|
var msgEl = root.querySelector("[data-table-msg]");
|
||||||
@@ -81,14 +87,45 @@
|
|||||||
|
|
||||||
// ---- drawing the cards -----------------------------------------------------
|
// ---- drawing the cards -----------------------------------------------------
|
||||||
|
|
||||||
// GLYPHS are what goes in the middle of an action card. The face the engine
|
// FACES is how each printed face is drawn. The engine sends a word ("skip",
|
||||||
// sends is a word ("skip", "+2"); this is the drawing of it.
|
// "+2", "discard all"); this is the picture of it.
|
||||||
var GLYPHS = { skip: "🚫", reverse: "⇄", "+2": "+2", wild: "★", "+4": "+4" };
|
//
|
||||||
|
// The corner is not always the same mark as the middle. "DISCARD ALL" fits
|
||||||
|
// across an oval and does not fit in a corner, so the long faces carry a short
|
||||||
|
// mark for the corners — which is what a real deck does too. `size` is a class,
|
||||||
|
// never a height: a card is sized by --uno-h/--uno-w and nothing else, and a
|
||||||
|
// face that sets its own font in pixels is a face that breaks on a phone.
|
||||||
|
var FACES = {
|
||||||
|
"skip": { mid: "🚫", corner: "🚫" },
|
||||||
|
"reverse": { mid: "⇄", corner: "⇄" },
|
||||||
|
"+2": { mid: "+2", corner: "+2" },
|
||||||
|
"wild": { mid: "★", corner: "★" },
|
||||||
|
"+4": { mid: "+4", corner: "+4" },
|
||||||
|
|
||||||
|
// No Mercy.
|
||||||
|
"skip all": { mid: "SKIP ALL", corner: "⊘⊘", size: "words" },
|
||||||
|
"discard all": { mid: "DISCARD ALL", corner: "≡", size: "words" },
|
||||||
|
"rev +4": { mid: "⇄+4", corner: "⇄4", size: "mid" },
|
||||||
|
"+6": { mid: "+6", corner: "+6" },
|
||||||
|
"+10": { mid: "+10", corner: "+10", size: "mid" },
|
||||||
|
// The roulette names a colour and turns cards over until it comes up. The
|
||||||
|
// wheel behind the oval is already the picture of that; the mark is the
|
||||||
|
// question the wheel is asking.
|
||||||
|
"roulette": { mid: "?", corner: "?" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function faceOf(c) { return FACES[c.value] || { mid: c.value, corner: c.value }; }
|
||||||
|
|
||||||
|
// The faces that make somebody draw. On a wild these are the cards worth the
|
||||||
|
// most and worth watching for, so they get the glow — and it is the one thing
|
||||||
|
// that tells the *wild* +4 apart from No Mercy's coloured +4 at a glance.
|
||||||
|
var DRAWS = { "+2": true, "+4": true, "+6": true, "+10": true, "rev +4": true };
|
||||||
|
|
||||||
// card builds one UNO card. The oval across the middle at an angle is the whole
|
// card builds one UNO card. The oval across the middle at an angle is the whole
|
||||||
// look of the thing — without it a card reads as a coloured button.
|
// look of the thing — without it a card reads as a coloured button.
|
||||||
function card(c, opts) {
|
function card(c, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
|
var f = faceOf(c);
|
||||||
var el = document.createElement(opts.button ? "button" : "div");
|
var el = document.createElement(opts.button ? "button" : "div");
|
||||||
if (opts.button) el.type = "button";
|
if (opts.button) el.type = "button";
|
||||||
el.className = "pete-uno-card";
|
el.className = "pete-uno-card";
|
||||||
@@ -99,7 +136,8 @@
|
|||||||
|
|
||||||
var oval = document.createElement("span");
|
var oval = document.createElement("span");
|
||||||
oval.className = "pete-uno-oval";
|
oval.className = "pete-uno-oval";
|
||||||
oval.textContent = GLYPHS[c.value] || c.value;
|
if (f.size) oval.dataset.size = f.size;
|
||||||
|
oval.textContent = f.mid;
|
||||||
face.appendChild(oval);
|
face.appendChild(oval);
|
||||||
|
|
||||||
// The corners, as printed.
|
// The corners, as printed.
|
||||||
@@ -107,7 +145,7 @@
|
|||||||
var corner = document.createElement("span");
|
var corner = document.createElement("span");
|
||||||
corner.className = "pete-uno-corner";
|
corner.className = "pete-uno-corner";
|
||||||
corner.dataset.at = at;
|
corner.dataset.at = at;
|
||||||
corner.textContent = GLYPHS[c.value] || c.value;
|
corner.textContent = f.corner;
|
||||||
corner.setAttribute("aria-hidden", "true");
|
corner.setAttribute("aria-hidden", "true");
|
||||||
face.appendChild(corner);
|
face.appendChild(corner);
|
||||||
});
|
});
|
||||||
@@ -121,16 +159,33 @@
|
|||||||
if (c.color && c.color !== "wild") el.dataset.named = c.color;
|
if (c.color && c.color !== "wild") el.dataset.named = c.color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The wild draws glow. They are the cards that decide a game — a +4 you were
|
||||||
|
// holding is four cards somebody else is about to be holding — and in No Mercy
|
||||||
|
// there are four of them to tell apart from the coloured +4 sitting next to
|
||||||
|
// them in the same hand. The glow is what says "this one is the nasty one".
|
||||||
|
if (c.wild && DRAWS[c.value]) el.dataset.glow = "1";
|
||||||
|
|
||||||
el.appendChild(face);
|
el.appendChild(face);
|
||||||
el.setAttribute("aria-label", label(c));
|
el.setAttribute("aria-label", label(c));
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// label is what a screen reader says. Note "+4" is two different cards — No
|
||||||
|
// Mercy prints a coloured one next to the wild one — so the name comes from the
|
||||||
|
// face *and* whether it's a wild, never from the face alone.
|
||||||
|
var SPOKEN = {
|
||||||
|
"+2": "draw two", "+4": "draw four", "+6": "draw six", "+10": "draw ten",
|
||||||
|
"wild": "wild", "skip": "skip", "reverse": "reverse",
|
||||||
|
"skip all": "skip everyone", "discard all": "discard all",
|
||||||
|
"rev +4": "reverse and draw four", "roulette": "colour roulette",
|
||||||
|
};
|
||||||
|
|
||||||
function label(c) {
|
function label(c) {
|
||||||
var v = c.value === "+2" ? "draw two" :
|
var v = SPOKEN[c.value] || c.value;
|
||||||
c.value === "+4" ? "wild draw four" :
|
if (c.wild) {
|
||||||
c.value === "wild" ? "wild" : c.value;
|
var name = c.value === "wild" ? "wild" : "wild " + v; // not "wild wild"
|
||||||
if (c.wild) return v + (c.color && c.color !== "wild" ? ", played as " + c.color : "");
|
return name + (c.color && c.color !== "wild" ? ", played as " + c.color : "");
|
||||||
|
}
|
||||||
return c.color + " " + v;
|
return c.color + " " + v;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +214,7 @@
|
|||||||
el.dataset.seat = String(i);
|
el.dataset.seat = String(i);
|
||||||
el.dataset.turn = v.turn === i && v.phase !== "done" ? "1" : "0";
|
el.dataset.turn = v.turn === i && v.phase !== "done" ? "1" : "0";
|
||||||
if (s.uno) el.dataset.uno = "1";
|
if (s.uno) el.dataset.uno = "1";
|
||||||
|
if (s.out) el.dataset.out = "1"; // No Mercy buried them; they are not coming back
|
||||||
|
|
||||||
var fan = document.createElement("div");
|
var fan = document.createElement("div");
|
||||||
fan.className = "pete-uno-fan";
|
fan.className = "pete-uno-fan";
|
||||||
@@ -179,7 +235,7 @@
|
|||||||
var count = document.createElement("p");
|
var count = document.createElement("p");
|
||||||
count.className = "pete-uno-count";
|
count.className = "pete-uno-count";
|
||||||
count.dataset.count = "";
|
count.dataset.count = "";
|
||||||
count.textContent = s.cards + (s.cards === 1 ? " card" : " cards");
|
count.textContent = s.out ? "Buried" : s.cards + (s.cards === 1 ? " card" : " cards");
|
||||||
el.appendChild(count);
|
el.appendChild(count);
|
||||||
|
|
||||||
seatsEl.appendChild(el);
|
seatsEl.appendChild(el);
|
||||||
@@ -234,10 +290,22 @@
|
|||||||
colourEl.textContent = v.color;
|
colourEl.textContent = v.color;
|
||||||
colourEl.dataset.c = v.color;
|
colourEl.dataset.c = v.color;
|
||||||
feltEl.dataset.c = v.color;
|
feltEl.dataset.c = v.color;
|
||||||
|
pending(v.phase === "done" ? 0 : v.pending || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
var feltEl = root.querySelector(".pete-uno");
|
var feltEl = root.querySelector(".pete-uno");
|
||||||
|
|
||||||
|
// pending is the bill a stack has run up. It is on the felt, on the button and
|
||||||
|
// in the turn line, because it is the only thing on the table while it stands:
|
||||||
|
// a player who misses it plays into a hand that is about to grow by ten.
|
||||||
|
function pending(n) {
|
||||||
|
if (billEl) {
|
||||||
|
billEl.textContent = n > 0 ? "+" + n + " on you" : "";
|
||||||
|
billEl.classList.toggle("hidden", n <= 0);
|
||||||
|
}
|
||||||
|
if (takeN) takeN.textContent = String(n);
|
||||||
|
}
|
||||||
|
|
||||||
function renderRail(v) {
|
function renderRail(v) {
|
||||||
if (!v) {
|
if (!v) {
|
||||||
paysEl.textContent = "—";
|
paysEl.textContent = "—";
|
||||||
@@ -259,6 +327,7 @@
|
|||||||
var yours = v.turn === 0;
|
var yours = v.turn === 0;
|
||||||
var who = yours ? "Your turn" : (v.seats[v.turn] || {}).name + " is thinking…";
|
var who = yours ? "Your turn" : (v.seats[v.turn] || {}).name + " is thinking…";
|
||||||
if (yours && v.phase === "drawn") who = "Play it, or keep it";
|
if (yours && v.phase === "drawn") who = "Play it, or keep it";
|
||||||
|
if (yours && v.phase === "stack") who = "+" + (v.pending || 0) + " — stack it, or take it";
|
||||||
turnEl.textContent = who;
|
turnEl.textContent = who;
|
||||||
turnEl.dataset.you = yours ? "1" : "0";
|
turnEl.dataset.you = yours ? "1" : "0";
|
||||||
var n = v.hand.length;
|
var n = v.hand.length;
|
||||||
@@ -278,6 +347,11 @@
|
|||||||
if (v.outcome === "lost" && v.winner > 0 && v.seats[v.winner]) {
|
if (v.outcome === "lost" && v.winner > 0 && v.seats[v.winner]) {
|
||||||
text = v.seats[v.winner].name + " went out first.";
|
text = v.seats[v.winner].name + " went out first.";
|
||||||
}
|
}
|
||||||
|
// No Mercy has two endings the normal deck hasn't got: you can lose without
|
||||||
|
// anybody going out (the mercy rule got you), and win without going out at all
|
||||||
|
// (it got everybody else).
|
||||||
|
if (v.outcome === "lost" && v.winner < 0) text = "Buried. Twenty-five cards and you're done.";
|
||||||
|
if (v.outcome === "won" && v.seats[0] && v.seats[0].cards > 0) text = "Last one standing! 🎉";
|
||||||
if (!text) { verdictEl.classList.add("hidden"); return; }
|
if (!text) { verdictEl.classList.add("hidden"); return; }
|
||||||
if (v.net > 0) text += " +" + v.net.toLocaleString();
|
if (v.net > 0) text += " +" + v.net.toLocaleString();
|
||||||
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
||||||
@@ -295,24 +369,45 @@
|
|||||||
var live = !!game && game.phase !== "done";
|
var live = !!game && game.phase !== "done";
|
||||||
var yours = live && game.turn === 0;
|
var yours = live && game.turn === 0;
|
||||||
var drawn = live && game.phase === "drawn";
|
var drawn = live && game.phase === "drawn";
|
||||||
|
var stack = live && game.phase === "stack";
|
||||||
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
|
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
|
||||||
b.disabled = busy || !yours || b.dataset.on !== "1";
|
b.disabled = busy || !yours || b.dataset.on !== "1";
|
||||||
});
|
});
|
||||||
if (drawBtn) drawBtn.disabled = busy || !yours || drawn;
|
// Under a stack you cannot draw. The deck is not a way out of a bill: the two
|
||||||
|
// moves that exist are answering it with a draw card or taking the lot.
|
||||||
|
if (drawBtn) drawBtn.disabled = busy || !yours || drawn || stack;
|
||||||
if (passBtn) passBtn.disabled = busy || !yours;
|
if (passBtn) passBtn.disabled = busy || !yours;
|
||||||
if (deckEl) deckEl.disabled = busy || !yours || drawn;
|
if (takeBtn) takeBtn.disabled = busy || !yours;
|
||||||
|
if (deckEl) deckEl.disabled = busy || !yours || drawn || stack;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HINTS is the line above the buttons. A game with a stack in it has a rule the
|
||||||
|
// player has just met for the first time, and the table should say what it is
|
||||||
|
// rather than leave them clicking a hand where nothing lights up.
|
||||||
|
var HINTS = {
|
||||||
|
play: "Click a card that lights up. Nothing lights up? Draw one.",
|
||||||
|
drawn: "You drew a card you can play. Play it, or keep it and pass.",
|
||||||
|
stack: "Only draw cards will do here. Answer it, and it lands on somebody else — or take the lot.",
|
||||||
|
};
|
||||||
|
|
||||||
function setPhase(v) {
|
function setPhase(v) {
|
||||||
game = v;
|
game = v;
|
||||||
var live = !!v && v.phase !== "done";
|
var live = !!v && v.phase !== "done";
|
||||||
var drawn = live && v.phase === "drawn";
|
var drawn = live && v.phase === "drawn";
|
||||||
|
var stack = live && v.phase === "stack";
|
||||||
betting.classList.toggle("hidden", live);
|
betting.classList.toggle("hidden", live);
|
||||||
playing.classList.toggle("hidden", !live);
|
playing.classList.toggle("hidden", !live);
|
||||||
hideWild();
|
hideWild();
|
||||||
|
|
||||||
if (drawBtn) drawBtn.classList.toggle("hidden", drawn);
|
if (drawBtn) drawBtn.classList.toggle("hidden", drawn || stack);
|
||||||
if (passBtn) passBtn.classList.toggle("hidden", !drawn);
|
if (takeBtn) takeBtn.classList.toggle("hidden", !stack);
|
||||||
|
// No Mercy has no pass: you drew until you found a card that plays, and
|
||||||
|
// playing it is the price of having drawn. The server refuses one anyway;
|
||||||
|
// this is the table not offering a button that cannot work.
|
||||||
|
var canPass = drawn && !(v && v.tier && v.tier.no_mercy);
|
||||||
|
if (passBtn) passBtn.classList.toggle("hidden", !canPass);
|
||||||
|
if (hintEl && live) hintEl.textContent = HINTS[v.phase] || HINTS.play;
|
||||||
|
if (!live && v) rulesFor(v); // it's over: leave the panel where the game was
|
||||||
controls();
|
controls();
|
||||||
if (!v || !v.outcome) verdictEl.classList.add("hidden");
|
if (!v || !v.outcome) verdictEl.classList.add("hidden");
|
||||||
}
|
}
|
||||||
@@ -352,7 +447,8 @@
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
var fan = el.querySelector(".pete-uno-fan");
|
var fan = el.querySelector(".pete-uno-fan");
|
||||||
var count = el.querySelector("[data-count]");
|
var count = el.querySelector("[data-count]");
|
||||||
if (count) count.textContent = left + (left === 1 ? " card" : " cards");
|
var buried = el.dataset.out === "1"; // an empty hand here is a grave, not a win
|
||||||
|
if (count) count.textContent = buried ? "Buried" : left + (left === 1 ? " card" : " cards");
|
||||||
if (!fan) return;
|
if (!fan) return;
|
||||||
var show = Math.min(left, FAN);
|
var show = Math.min(left, FAN);
|
||||||
while (fan.children.length > show) fan.removeChild(fan.lastChild);
|
while (fan.children.length > show) fan.removeChild(fan.lastChild);
|
||||||
@@ -442,7 +538,9 @@
|
|||||||
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90 }));
|
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90 }));
|
||||||
}
|
}
|
||||||
var punished = e.kind === "forced";
|
var punished = e.kind === "forced";
|
||||||
if (punished) badge(e.seat, "+" + e.n, "bad");
|
// A forced draw is also how a stack ends: somebody stopped answering
|
||||||
|
// and paid the bill, so the bill is gone.
|
||||||
|
if (punished) { pending(0); badge(e.seat, "+" + e.n, "bad"); }
|
||||||
return Promise.all(backs).then(function () {
|
return Promise.all(backs).then(function () {
|
||||||
bump(e.seat, e.left);
|
bump(e.seat, e.left);
|
||||||
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
|
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
|
||||||
@@ -453,6 +551,65 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "stack":
|
||||||
|
// The bill has moved on to somebody, and N is what it stands at now —
|
||||||
|
// not what was just added to it. A +2 answered with a +10 is a seat
|
||||||
|
// looking at twelve cards.
|
||||||
|
pending(e.n);
|
||||||
|
spotlight(e.seat);
|
||||||
|
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
|
||||||
|
|
||||||
|
case "skipall":
|
||||||
|
// The turn does not move: it comes straight back to the seat that
|
||||||
|
// played it, which is the whole card.
|
||||||
|
return badge(e.seat, "Everyone skipped").then(function () { return wait(240); });
|
||||||
|
|
||||||
|
case "discard": {
|
||||||
|
// A whole colour left a hand at once. If it was your hand, those cards
|
||||||
|
// are on the table in front of you — so they go before the flight, or
|
||||||
|
// they sit there looking like they were dumped twice.
|
||||||
|
if (e.seat === 0 && e.color) {
|
||||||
|
handEl.querySelectorAll('.pete-uno-card[data-c="' + e.color + '"]').forEach(function (el) {
|
||||||
|
el.style.visibility = "hidden";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bump(e.seat, e.left);
|
||||||
|
var dumpFrom = seatAnchor(e.seat);
|
||||||
|
var dumped = [];
|
||||||
|
for (var d = 0; d < Math.min(e.n, 4); d++) {
|
||||||
|
dumped.push(throwCard(back(), dumpFrom, discardEl, { index: d, delay: d * 70 }));
|
||||||
|
}
|
||||||
|
badge(e.seat, "−" + e.n + " " + (e.color || ""));
|
||||||
|
return Promise.all(dumped).then(function () { return wait(220); });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "roulette": {
|
||||||
|
// They turn cards over until your colour comes up, and they keep every
|
||||||
|
// one of them. N is how deep it went.
|
||||||
|
spotlight(e.seat);
|
||||||
|
var spinTo = seatAnchor(e.seat);
|
||||||
|
var spun = [];
|
||||||
|
for (var r = 0; r < Math.min(e.n, 4); r++) {
|
||||||
|
spun.push(throwCard(back(), deckEl, spinTo, { index: r, delay: r * 80 }));
|
||||||
|
}
|
||||||
|
badge(e.seat, "Roulette +" + e.n, "bad");
|
||||||
|
return Promise.all(spun).then(function () {
|
||||||
|
bump(e.seat, e.left);
|
||||||
|
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
|
||||||
|
return wait(400);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "mercy": {
|
||||||
|
// Twenty-five cards, and they are out of the game for good. It is the
|
||||||
|
// one beat in this script allowed to take its time.
|
||||||
|
var grave = seatEl(e.seat);
|
||||||
|
if (grave) grave.dataset.out = "1";
|
||||||
|
bump(e.seat, 0);
|
||||||
|
pending(0); // a dead seat pays no bill and leaves none behind
|
||||||
|
return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); });
|
||||||
|
}
|
||||||
|
|
||||||
case "skip":
|
case "skip":
|
||||||
return badge(e.seat, "Skipped", "bad").then(function () { return wait(80); });
|
return badge(e.seat, "Skipped", "bad").then(function () { return wait(80); });
|
||||||
|
|
||||||
@@ -593,6 +750,15 @@
|
|||||||
move({ kind: "draw" });
|
move({ kind: "draw" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Taking the stack: you give in, you eat every card it has run up, and you lose
|
||||||
|
// your turn. It is a move of its own, not a draw — see the engine's MoveTake.
|
||||||
|
if (takeBtn) {
|
||||||
|
takeBtn.addEventListener("click", function () {
|
||||||
|
if (busy || !game || game.turn !== 0 || game.phase !== "stack") return;
|
||||||
|
move({ kind: "take" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- talking to the table ----------------------------------------------------
|
// ---- talking to the table ----------------------------------------------------
|
||||||
|
|
||||||
// send takes the table away for the whole move — not just the request, but the
|
// send takes the table away for the whole move — not just the request, but the
|
||||||
@@ -647,6 +813,38 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The rules switch. It is a different dial from the table size — the size is
|
||||||
|
// what you're paid for, the deck is what you're playing with — so throwing it
|
||||||
|
// swaps the three tables for the other three rather than becoming a fourth one.
|
||||||
|
var DEFAULT_TABLE = { normal: "table", nomercy: "nm-table" };
|
||||||
|
|
||||||
|
function pickRules(which, slug) {
|
||||||
|
root.querySelectorAll("[data-rules]").forEach(function (b) {
|
||||||
|
b.dataset.on = b.dataset.rules === which ? "1" : "0";
|
||||||
|
});
|
||||||
|
root.querySelectorAll("[data-grid]").forEach(function (g) {
|
||||||
|
g.classList.toggle("hidden", g.dataset.grid !== which);
|
||||||
|
});
|
||||||
|
root.querySelectorAll("[data-note]").forEach(function (p) {
|
||||||
|
p.classList.toggle("hidden", p.dataset.note !== which);
|
||||||
|
});
|
||||||
|
pickTier(slug || DEFAULT_TABLE[which]);
|
||||||
|
}
|
||||||
|
|
||||||
|
root.querySelectorAll("[data-rules]").forEach(function (b) {
|
||||||
|
b.addEventListener("click", function () {
|
||||||
|
if (busy) return;
|
||||||
|
pickRules(b.dataset.rules);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A game that just ended leaves the betting panel on the table it was played at:
|
||||||
|
// the commonest thing a player wants after a hand of No Mercy is another one.
|
||||||
|
function rulesFor(v) {
|
||||||
|
if (!v || !v.tier) return;
|
||||||
|
pickRules(v.tier.no_mercy ? "nomercy" : "normal", v.tier.slug);
|
||||||
|
}
|
||||||
|
|
||||||
// The chip you click is the chip that flies. Scoped to buttons: the bare
|
// The chip you click is the chip that flies. Scoped to buttons: the bare
|
||||||
// [data-chip] spans in the rack are the house's, and the house is not betting.
|
// [data-chip] spans in the rack are the house's, and the house is not betting.
|
||||||
root.querySelectorAll("button[data-chip]").forEach(function (btn) {
|
root.querySelectorAll("button[data-chip]").forEach(function (btn) {
|
||||||
@@ -689,7 +887,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pickTier(tier);
|
pickRules("normal");
|
||||||
|
|
||||||
var resumed = false;
|
var resumed = false;
|
||||||
window.PeteGames.onUpdate(function (v) {
|
window.PeteGames.onUpdate(function (v) {
|
||||||
|
|||||||
@@ -101,8 +101,9 @@
|
|||||||
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
|
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
|
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
|
||||||
One to three bots, and the more of them there are the more it pays: up to 3.6× for
|
One to three bots, and the more of them there are the more it pays: up to 4.1× for
|
||||||
beating a full table. Anybody else going out first takes your stake.
|
beating a full table. Anybody else going out first takes your stake. Or throw the
|
||||||
|
No Mercy switch: 168 cards, draws that stack, and twenty-five in your hand puts you out.
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,15 @@
|
|||||||
<div class="flex flex-col items-center gap-2">
|
<div class="flex flex-col items-center gap-2">
|
||||||
<div data-discard class="pete-uno-discard" aria-label="The card in play"></div>
|
<div data-discard class="pete-uno-discard" aria-label="The card in play"></div>
|
||||||
<p data-colour class="pete-uno-colour" aria-live="polite"></p>
|
<p data-colour class="pete-uno-colour" aria-live="polite"></p>
|
||||||
|
<!-- The bill. While a stack stands it is the only thing on the table
|
||||||
|
that matters, so it is said on the felt and not only on a button.
|
||||||
|
|
||||||
|
It is data-*bill*, not data-pending: the chip bar already owns
|
||||||
|
data-pending (chips still in flight from a buy-in), the chip bar is
|
||||||
|
inside this root, and it comes first in the document — so a stack
|
||||||
|
was quietly writing "+10 coming your way" over the escrow readout
|
||||||
|
and never showing up on the felt at all. -->
|
||||||
|
<p data-bill class="pete-uno-pending hidden" aria-live="assertive"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,7 +109,7 @@
|
|||||||
<!-- Playing: shown while a game is live. -->
|
<!-- Playing: shown while a game is live. -->
|
||||||
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<p class="text-sm text-[color:var(--ink)]/50">
|
<p data-hint class="text-sm text-[color:var(--ink)]/50">
|
||||||
Click a card that lights up. Nothing lights up? Draw one.
|
Click a card that lights up. Nothing lights up? Draw one.
|
||||||
</p>
|
</p>
|
||||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||||
@@ -109,6 +118,14 @@
|
|||||||
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
Draw
|
Draw
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Giving in to a stack. It is a decision, not a draw — you are paying a
|
||||||
|
bill somebody ran up and pointing at you — so it gets a button of its
|
||||||
|
own, and the button says what it costs. -->
|
||||||
|
<button type="button" data-take
|
||||||
|
class="hidden rounded-full bg-[#cc3d4a] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||||
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Take <span data-take-n>0</span>
|
||||||
|
</button>
|
||||||
<button type="button" data-pass
|
<button type="button" data-pass
|
||||||
class="hidden rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
|
class="hidden rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||||
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
@@ -122,21 +139,52 @@
|
|||||||
<!-- Betting: shown between games. -->
|
<!-- Betting: shown between games. -->
|
||||||
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
|
||||||
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Who are you playing?</div>
|
<!-- Two dials, and they are different dials. The table size is the tier — it is
|
||||||
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
what you're paid for. No Mercy is the *deck*, and it is a switch across all
|
||||||
{{range .Tables}}
|
three sizes, which is why it sits up here beside the question rather than
|
||||||
<button type="button" data-tier="{{.Slug}}"
|
being a fourth table. -->
|
||||||
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="flex items-baseline justify-between gap-2">
|
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Who are you playing?</div>
|
||||||
<span class="font-display text-lg font-bold">{{.Name}}</span>
|
<div class="pete-seg" role="group" aria-label="Which rules">
|
||||||
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
|
<button type="button" data-rules="normal" class="pete-seg-btn">UNO</button>
|
||||||
</div>
|
<button type="button" data-rules="nomercy" class="pete-seg-btn">No Mercy</button>
|
||||||
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
|
</div>
|
||||||
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
|
</div>
|
||||||
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · seven cards each
|
|
||||||
</p>
|
<div data-grid="normal">
|
||||||
</button>
|
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
||||||
{{end}}
|
{{range .Tables}}
|
||||||
|
<button type="button" data-tier="{{.Slug}}"
|
||||||
|
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<span class="font-display text-lg font-bold">{{.Name}}</span>
|
||||||
|
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
|
||||||
|
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
|
||||||
|
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · seven cards each
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-grid="nomercy" class="hidden">
|
||||||
|
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
||||||
|
{{range .NoMercy}}
|
||||||
|
<button type="button" data-tier="{{.Slug}}"
|
||||||
|
class="pete-tier pete-tier-nm rounded-2xl border-2 p-3 text-left transition">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<span class="font-display text-lg font-bold">{{.Name}}</span>
|
||||||
|
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
|
||||||
|
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
|
||||||
|
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · 168 cards
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
|
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
|
||||||
@@ -162,9 +210,16 @@
|
|||||||
Deal
|
Deal
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
<!-- No Mercy pays *less*, which reads like a typo until you've played one: the
|
||||||
|
mercy rule buries bots too, and every bot it buries is one fewer seat that
|
||||||
|
can beat you to the last card. Say so, rather than let it look like a bug. -->
|
||||||
|
<p data-note="normal" class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
||||||
Normal rules: no stacking a +2 on a +2, and a reverse is a skip when it's just the two of you.
|
Normal rules: no stacking a +2 on a +2, and a reverse is a skip when it's just the two of you.
|
||||||
</p>
|
</p>
|
||||||
|
<p data-note="nomercy" class="hidden mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
||||||
|
168 cards, +6s and +10s. Draw cards stack, you draw until you can play, and {{.MercyLimit}} cards in
|
||||||
|
your hand puts you out of the game. It pays less than normal UNO because it buries the bots too.
|
||||||
|
</p>
|
||||||
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,33 @@ This plan reuses that seam wholesale and does not invent a second one.
|
|||||||
|
|
||||||
A multi-session build. This section is the handover; read it before anything else.
|
A multi-session build. This section is the handover; read it before anything else.
|
||||||
|
|
||||||
|
### Start here (next session)
|
||||||
|
|
||||||
|
**Everything builds, everything is played, and nothing since blackjack is
|
||||||
|
deployed.** Six games (seven, counting the No Mercy dial) sit on main and the live
|
||||||
|
casino is still blackjack alone. Deploying is the next job — see "Next, in order".
|
||||||
|
|
||||||
|
**The money finding, and the thing to actually remember:** the *normal* UNO tiers
|
||||||
|
had been mispriced for a while, and it wasn't No Mercy's fault. They were set
|
||||||
|
against a naive win rate of 43/32/27%; re-measuring says 40.3/29.2/23.3%. The bots
|
||||||
|
improved at some point after the multiples were written down and nobody re-ran the
|
||||||
|
measurement — which §0 already warned about ("the bots and the tiers are a pair").
|
||||||
|
Table and Full House had quietly been charging an **18–19% house edge instead of
|
||||||
|
8%**. All six tiers are repriced off a fresh measurement, and
|
||||||
|
**`TestTheMultiplesAreStillPriced`** now fails the build if any of them drift out
|
||||||
|
of a 2–14% band. It is the test the normal tiers never had, which is precisely how
|
||||||
|
they drifted. *Any change to the bots, the deck, or a rule re-opens this.*
|
||||||
|
|
||||||
|
And the counterintuitive one, which would have shipped wrong if it had been
|
||||||
|
guessed: **No Mercy is easier than UNO at every table size** (naive wins 46.7% vs
|
||||||
|
40.3% heads-up), so it **pays less** — 2.0/3.1/3.8 against the normal deck's new
|
||||||
|
2.4/3.3/4.1. The mercy rule doesn't 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 is merciless mostly to the table.
|
||||||
|
|
||||||
|
Still un-deployed: **only blackjack is live.** The other five games (six, now, if
|
||||||
|
you count the No Mercy dial) are on main and have never been deployed.
|
||||||
|
|
||||||
### Decisions taken (these close §9's open questions)
|
### Decisions taken (these close §9's open questions)
|
||||||
|
|
||||||
- **Chips are 1:1 with euros.** No second denomination.
|
- **Chips are 1:1 with euros.** No second denomination.
|
||||||
@@ -400,6 +427,68 @@ A multi-session build. This section is the handover; read it before anything els
|
|||||||
in.**
|
in.**
|
||||||
|
|
||||||
|
|
||||||
|
- **No Mercy, on the felt, and it plays.** *(2026-07-14. The engine landed in
|
||||||
|
`aca523e`; this is the half you can see. Driven in a browser, and that is where
|
||||||
|
both bugs were.)*
|
||||||
|
- **It is one switch, not a fourth table.** The betting panel grows a two-way
|
||||||
|
segmented control (UNO / No Mercy) that swaps the three tier cards for the other
|
||||||
|
three. The table size stays the tier — it is what you're paid for — and the deck
|
||||||
|
is the other dial. A settled game leaves the panel on the table you just played,
|
||||||
|
because the commonest thing anybody wants after a hand of No Mercy is another one.
|
||||||
|
- **The six faces the normal box doesn't print.** `FACES` in `uno.js` is one table
|
||||||
|
of {middle mark, corner mark, size}: the long ones ("DISCARD ALL", "SKIP ALL")
|
||||||
|
wrap across the tilted oval and carry a short mark for the corners, exactly as a
|
||||||
|
real deck does. Sized by a *font class*, never a box — `--uno-h`/`--uno-w` remain
|
||||||
|
the only thing that decides how big a card is, which is the rule the phone bug
|
||||||
|
taught this table.
|
||||||
|
- **The wild draws glow** (the user asked, and it turned out to be load-bearing).
|
||||||
|
A rainbow aura behind the card plus a slow shine across it, on any wild that
|
||||||
|
makes somebody draw. It is the one thing that tells the *wild* +4 apart from No
|
||||||
|
Mercy's **coloured** +4 sitting next to it in the same hand. It is a sibling
|
||||||
|
behind the card, not a shadow on it, because a box-shadow there would fight the
|
||||||
|
one that says which colour a played wild was named as.
|
||||||
|
- **The stack says what the bill is, in three places**: a red pill on the felt, the
|
||||||
|
turn line ("+6 — stack it, or take it"), and the button itself ("Take 6"). Under
|
||||||
|
a stack the hand only lights the cards that answer it, the deck is dead (you
|
||||||
|
cannot draw your way out of a bill), and the hint line changes to say so.
|
||||||
|
- **A buried seat is not an empty one.** This is the whole trap of the mercy rule
|
||||||
|
in the view layer: a seat killed at twenty-five holds zero cards, and so does a
|
||||||
|
seat that just went out and *won*. The view asks the engine (`State.Live`) rather
|
||||||
|
than inferring from a count of zero, so the winner is never the corpse.
|
||||||
|
`TestABuriedSeatIsNotTheWinner` is that, and it also covers the ending only this
|
||||||
|
deck has — you can win by **outliving** the table, with a hand still in front of
|
||||||
|
you ("Last one standing!"), and you can lose without anybody going out at all.
|
||||||
|
- **Two bugs, and a browser found both.**
|
||||||
|
1. **The bill was writing into the chip bar.** The felt's pending pill was
|
||||||
|
`[data-pending]` — and so is the chip bar's "chips still in flight from a
|
||||||
|
buy-in" readout, and the chip bar lives *inside* `[data-uno]` and comes first
|
||||||
|
in the document. So `root.querySelector` found the wrong one: a stack quietly
|
||||||
|
overwrote the escrow message and never appeared on the felt at all. It is
|
||||||
|
`[data-bill]` now. **A table's own attributes are not a private namespace —
|
||||||
|
the chip bar is in every one of these roots.**
|
||||||
|
2. **Hold'em let you click a button that did nothing** (found while re-driving
|
||||||
|
poker, below). `send()` drops a click that arrives while a move is in flight,
|
||||||
|
which is right — the board on screen during a script is a board the server has
|
||||||
|
already moved past — but the *between-hands* buttons (Deal, Leave, Top up)
|
||||||
|
stayed enabled through the whole deal animation. Clicking Leave while the
|
||||||
|
cards were still flying did nothing: no move, no message, no reason. The lock
|
||||||
|
lives on the buttons now (`lock()`), not only in the `busy` variable.
|
||||||
|
- **Driven in a browser, 2026-07-14.** A Full House game stacked a +6 onto us and
|
||||||
|
the felt said so; taking it worked; a bot went to twenty-five and the seat went
|
||||||
|
grey and said "Buried"; a Duel was won by outliving the table and paid 1,950 on a
|
||||||
|
1,000 stake (2.0× is 1,000 of winnings, less the 5% rake — the quote and the
|
||||||
|
payout agreed, as `Pays()` requires). A 21-card hand at 390px wraps to five rows
|
||||||
|
with every card painted and no sideways scroll. The normal deck still plays and
|
||||||
|
still pays (a Table win on 1,200 paid 3,822). Console silent.
|
||||||
|
|
||||||
|
- **Hold'em, re-driven on the 20M-hand policy.** *(2026-07-14, and it closes Phase
|
||||||
|
4.)* The policy is a data file, so the check was whether the bots still play a real
|
||||||
|
game off it and the money still conserves: sat down for 100 at the 1/2 table, six
|
||||||
|
hands (won a pot with a straight, lost one at showdown, folded the rest), got up
|
||||||
|
with 61, and 4,900 + 61 came back as 4,961 — conserved to the chip. The re-drive is
|
||||||
|
also what turned up the dead-button bug above, which is the argument for the rule:
|
||||||
|
**re-drive after a policy change even though "only data" moved.**
|
||||||
|
|
||||||
- **Hold'em, and it is a cash game.** *(2026-07-14. Built, tested, and driven in a
|
- **Hold'em, and it is a cash game.** *(2026-07-14. Built, tested, and driven in a
|
||||||
browser. The bots had to be retrained from scratch — see below, it is the whole
|
browser. The bots had to be retrained from scratch — see below, it is the whole
|
||||||
story of this phase.)*
|
story of this phase.)*
|
||||||
@@ -479,48 +568,16 @@ A multi-session build. This section is the handover; read it before anything els
|
|||||||
size, with chips scaled to match. The bet total hangs *below* the ring
|
size, with chips scaled to match. The bet total hangs *below* the ring
|
||||||
(`.pete-spot-total`), which is the existing rule for exactly this reason.
|
(`.pete-spot-total`), which is the existing rule for exactly this reason.
|
||||||
|
|
||||||
### Pick this up here — a 20M-hand policy is still training
|
|
||||||
|
|
||||||
The `policy.gob` on main is a **300,000-hand run** — a placeholder. It works (95%
|
|
||||||
heads-up hit rate, and the bots play a real game off it), but it is thin: 4,129
|
|
||||||
nodes. A **20,000,000-hand run** was started on millenia on 2026-07-14 and needs
|
|
||||||
collecting:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ssh reala@192.168.1.212 'tail -2 ~/pete-train/train.log' # is it done?
|
|
||||||
scp reala@192.168.1.212:~/pete-train/policy-new.gob internal/games/holdem/policy.gob
|
|
||||||
go test ./internal/games/holdem/ -run TestTheBotsAreActuallyTrained -v # hit rate must hold ≥60%
|
|
||||||
```
|
|
||||||
|
|
||||||
Then re-drive the table in a browser and commit it. If the run was lost, just do it
|
|
||||||
again — it is one command and about an hour:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
rsync -az --delete --exclude .git --exclude node_modules ./ reala@192.168.1.212:~/pete-train/
|
|
||||||
ssh reala@192.168.1.212 'cd ~/pete-train && go build ./cmd/holdem-train && \
|
|
||||||
nohup ./holdem-train -iterations 20000000 -workers 30 -out ~/pete-train/policy-new.gob \
|
|
||||||
> ~/pete-train/train.log 2>&1 &'
|
|
||||||
```
|
|
||||||
|
|
||||||
millenia (`reala@192.168.1.212`) has 32 cores and does ~250k hands a minute. The
|
|
||||||
local box does ~110k. Nothing about the *code* is waiting on this — the policy is a
|
|
||||||
data file, and a better one only makes the bots harder.
|
|
||||||
|
|
||||||
### Next, in order
|
### Next, in order
|
||||||
|
|
||||||
1. **Deploy.** Hangman, solitaire, trivia, UNO and hold'em are all played and all
|
1. **Deploy.** Hangman, solitaire, trivia, UNO (both rule sets) and hold'em are all
|
||||||
five are sitting on main un-deployed — the live casino is blackjack and nothing
|
played and all of them are sitting on main un-deployed — the live casino is
|
||||||
else. The server runs `StartTriviaBank`, so trivia's bank fills itself once the
|
blackjack and nothing else. The server runs `StartTriviaBank`, so trivia's bank
|
||||||
binary is out there, but the first player to try a ladder in the first minute
|
fills itself once the binary is out there, but the first player to try a ladder in
|
||||||
after a deploy gets the 503.
|
the first minute after a deploy gets the 503.
|
||||||
2. **No Mercy UNO.** The plan's header line has always promised "UNO (normal +
|
2. **Nothing else is queued.** Every game in the header line is built. What's left is
|
||||||
no-mercy)" and only normal was ever built. gogobee has the rules
|
the open list at the bottom of this section: hold'em has no multiway policy, and
|
||||||
(`uno_nomercy.go`: a 168-card deck, draw-stacking, elimination at 25 cards,
|
blackjack still has no split.
|
||||||
sudden-death point scoring). It is a *rules dial orthogonal to the table-size
|
|
||||||
tier*, so the lobby becomes 3 sizes × 2 rule sets — and **the multiples have to be
|
|
||||||
re-measured**, because the current 2.2×/2.9×/3.6× are priced off a measured
|
|
||||||
go-out-first rate (43/32/27%) that draw-stacking and mercy elimination change
|
|
||||||
completely. Shipping No Mercy on the regular tier's prices would misprice it.
|
|
||||||
|
|
||||||
Still open on hold'em, none of it blocking: the policy is **heads-up**, so a
|
Still open on hold'em, none of it blocking: the policy is **heads-up**, so a
|
||||||
six-handed table is an approximation of it (the hit rate falls from 95% to about 17%
|
six-handed table is an approximation of it (the hit rate falls from 95% to about 17%
|
||||||
|
|||||||
Reference in New Issue
Block a user