games: the word you owe the table, and the hand you were already holding

Three things, and the first one was a bug.

Your own hand didn't move until the lap ended. bump() keeps the bots'
fans honest and has always refused seat zero, and nothing else touched
yours — so a +4 landing on you at the top of a lap put four backs into
your hand and then nothing, and the cards themselves turned up seconds
later when the script finished and paint() finally ran. You spent the
whole lap looking at a hand you no longer held. The engine now stamps
your hand onto every event that changes it (Event.Hand, seat zero only,
which is the one hand the browser is already entitled to see) and the
table redraws as the cards land. Measured in the running app: 2 -> 3
cards at 414ms into a 1791ms lap.

You couldn't call UNO, and not because the button was missing: going
down to one card *was* the call. discard() fired the uno event by
itself, which made it a thing that happened to you rather than a thing
you did, and a rule nobody can fail is not a rule. So now you say it or
you don't (Move.Uno), and if you don't, every bot still in the game gets
one look at you before any of them plays — because a bot that has moved
on is a bot that has stopped watching your hand. It runs the other way
too, and that half is the fun one: a bot forgets often enough to be
worth watching for, and when it does it says *nothing*. No event, no
badge, no tell on the felt except the count beside its fan reading
"1 card". Catch it and it takes two; call a seat that had nothing to
hide and you take two yourself, which is what stops the catch button
from being a thing you simply mash.

Which cards owe the call is the engine's answer, not a count of your
hand: No Mercy's "discard all" takes every card of its colour with it,
so a six-card hand can land on one, and a browser subtracting one from
six walks you into a catch it never warned you about.

And the room was silent. Every sound in here is *made* — an oscillator,
a burst of filtered noise, an envelope — the same bargain the weather
engine takes with its clouds. A card is a slap of noise through a
bandpass, a chip is two detuned sines with a knock on the front, a win
is four notes going up. No asset files, no round trips, and a sound can
be pitched and detuned per call instead of being the same wav three
hundred times. Hooked into the FX layer rather than into the games, so
every table that throws a chip or turns a card got it at once.

The multiples moved, and the test that exists to catch that caught it.
The naive strategy now calls UNO, because calling is a button and not a
strategy — what these tiers price is bad card play, not a player who
ignores the felt shouting at them — and on that footing the normal
tables come back to where they were (40.1 / 28.5 / 23.1). No Mercy Full
House did not: it was paying a *negative* house edge, which is the house
paying you to sit down. Re-priced 3.8 -> 3.5.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
prosolis
2026-07-14 13:15:11 -07:00
parent a4666866a8
commit 39ed293f4f
22 changed files with 1450 additions and 59 deletions

201
internal/games/uno/call.go Normal file
View File

@@ -0,0 +1,201 @@
package uno
import "math/rand/v2"
// Calling UNO, and catching the seat that didn't.
//
// This was the one rule on the box the table wasn't playing. A hand going down to
// one card used to emit the "uno" event by itself, which made the call a thing
// that *happened to you* rather than a thing you did — and a rule nobody can fail
// is not a rule, it's an announcement.
//
// So now: play your second-to-last card and you owe the table a word. Say it
// (Move.Uno) and you're safe. Stay quiet and the bots get one look at you, right
// then, before any of them plays — because a bot that has moved on is a bot that
// has stopped watching your hand. Miss it and you take two.
//
// It runs the other way too, and that half is the fun one. A bot forgets often
// enough to be worth watching for, and when it does it says *nothing* — there is
// no event, no badge, no tell on the felt except the count beside its fan reading
// "1 card" with no UNO on it. Catch it (MoveCatch) and it takes two. Call a seat
// that had nothing to hide and you take two yourself, which is what stops the
// catch button from being a thing you simply mash every turn.
//
// The whole rule turns on one asymmetry, and it's deliberate: the bots get a
// *roll* to notice you, and you get to actually look. Attention is the edge here,
// and it's the player's to take.
const (
// CatchPenalty is what silence costs, both ways. Two, as printed on the box.
CatchPenalty = 2
// botForget is how often a bot goes down to one card without saying so. High
// enough that watching the counts pays — at a full table you'll get a catch to
// make every few games — and low enough that a quiet seat is still a thing you
// have to notice rather than assume.
botForget = 0.30
// botAlert is the chance a *single* bot notices that you didn't call. They each
// get a look, so forgetting is punished about 75% of the time heads-up and 98%
// at a full table: the more seats there are watching you, the less you get away
// with, which is the right shape for it.
botAlert = 0.75
)
// botForgets rolls for whether a bot muffs its call.
func botForgets(rng *rand.Rand) bool { return rng.Float64() < botForget }
// declare records whether a seat that is now on one card said so, and — if it did
// — announces it. A seat on any other number of cards owes nothing and this does
// nothing, which is what makes it safe to call after every play.
func (s *State) declare(seat int, called bool, evs *[]Event) {
if s.Phase == PhaseDone || len(s.Hands[seat]) != 1 {
return
}
s.ensureCalled()
s.Called[seat] = called
if called {
*evs = append(*evs, Event{Kind: EvUno, Seat: seat, Left: 1})
}
}
// botsCatch is the window. You have just played, you are holding one card, and
// you didn't say the word — so every bot still in the game gets one look, in seat
// order, and the first to see it takes you for two.
//
// It runs before runBots on purpose. The catch has to land while the table is
// still looking at the card you played, not three turns later.
func (s *State) botsCatch(evs *[]Event, rng *rand.Rand) {
if s.Phase == PhaseDone || len(s.Hands[You]) != 1 || s.called(You) {
return
}
for _, seat := range s.alive() {
if seat == You {
continue
}
if rng.Float64() >= botAlert {
continue // this one wasn't looking
}
s.penalise(You, seat, EvCaught, evs, rng)
return // caught once is caught. They don't queue up to take turns at you
}
}
// playerCatches calls out a seat you think is holding one card in silence.
//
// It is not a turn: right or wrong, the turn stays where it was, which is with
// you. What it costs is the risk — a seat that did call, or isn't on one card at
// all, is a seat you have just accused of nothing, and that is two cards to you.
func (s *State) playerCatches(m Move, rng *rand.Rand) ([]Event, error) {
seat := m.Seat
if seat == You || seat < 0 || seat >= len(s.Hands) || !s.live(seat) {
return nil, ErrNoCatch
}
var evs []Event
if len(s.Hands[seat]) == 1 && !s.called(seat) {
s.penalise(seat, You, EvCaught, &evs, rng) // got them
} else {
s.penalise(You, seat, EvMiscall, &evs, rng) // they were clean, and you weren't looking
}
return evs, nil
}
// penalise makes a seat take the price of the call: cards off the deck, quietly —
// no draw event, because what the table is being told about is the catch, and a
// draw event alongside it would animate the same two cards twice.
//
// In No Mercy those two cards can be the two that bury them, which is a fine way
// to go: caught on one card, dead on twenty-five.
func (s *State) penalise(victim, by int, kind string, evs *[]Event, rng *rand.Rand) {
got := s.drawCards(victim, CatchPenalty, evs, rng)
if len(got) == 0 {
return // the table has nothing left to punish anybody with
}
s.ensureCalled()
s.Called[victim] = false
*evs = append(*evs, s.mine(Event{
Kind: kind, Seat: victim, By: by, N: len(got), Left: len(s.Hands[victim]),
}))
s.mercy(victim, evs, rng)
}
// UnoAt is which cards in your hand would leave you holding exactly one, if you
// played them. It is the table's cue to ask you for the call, and it comes from
// here rather than from the browser counting your cards because No Mercy's
// "discard all" doesn't take one card out of your hand — it takes every card of
// its colour, and the browser guessing at that is the browser getting it wrong.
//
// It answers for every card, legal or not. The table only ever asks about a card
// you were allowed to play anyway.
func (s State) UnoAt() []int {
if s.Phase == PhaseDone || s.Turn != You {
return nil
}
hand := s.Hands[You]
var out []int
for i, c := range hand {
left := len(hand) - 1
if c.Value == DiscardAll {
for j, other := range hand {
if j != i && other.Color == c.Color && !other.IsWild() {
left--
}
}
}
if left == 1 {
out = append(out, i)
}
}
return out
}
// Catchable is which seats are, right now, sitting on one card they never
// announced. It is what the browser puts a button on.
//
// This leaks nothing. The card counts are already on the felt and the UNO badge
// already isn't — this is the same two facts, subtracted, and doing that
// subtraction on the server is only so that the rule for what counts as catchable
// lives in one place instead of two.
func (s State) Catchable() []int {
if s.Phase == PhaseDone || s.Turn != You {
return nil // you can only catch a seat on your own turn
}
var out []int
for _, seat := range s.alive() {
if seat != You && len(s.Hands[seat]) == 1 && !s.called(seat) {
out = append(out, seat)
}
}
return out
}
// called reports whether a seat holding one card announced it. Callers outside
// the package read the Called slice, which is on the state they already hold.
func (s State) called(seat int) bool {
return seat < len(s.Called) && s.Called[seat]
}
// ensureCalled grows the slice to fit the table. A game dealt before this rule
// existed has no Called at all, and the seats in it are all — correctly —
// uncalled: nobody in that game ever said the word, because there was no way to.
func (s *State) ensureCalled() {
for len(s.Called) < len(s.Hands) {
s.Called = append(s.Called, false)
}
}
// tidyCalls forgets the calls that no longer mean anything. A seat's call is only
// ever about the one card it is holding — draw into two and the word you said is
// spent, and saying it again is a new thing you have to do.
//
// Without this, a seat that called on one card, was made to draw, and worked its
// way back down to one would still be wearing the old call, and could never be
// caught again for the rest of the game.
func (s *State) tidyCalls() {
s.ensureCalled()
for seat := range s.Called {
if len(s.Hands[seat]) != 1 {
s.Called[seat] = false
}
}
}

View File

@@ -0,0 +1,323 @@
package uno
import "testing"
// The UNO call, and the catch on the other side of it. See call.go.
// oneCardAway sets you up holding two cards, both of which go on the pile, so
// playing either one takes you to UNO.
//
// The bot is given a hand that can't touch a red pile and a deck that can't help
// it, so whatever it does on its turn, it does not make you draw. That matters:
// a hand that grows spends the call (see tidyCalls), which is correct and would
// otherwise make these tests flap on the seeds where the bot happens to turn up
// a +2.
func oneCardAway(t *testing.T, seed uint64) State {
t.Helper()
s := deal(t, duel(), 100, seed)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
s.Hands[1] = []Card{{Blue, Three}, {Green, Four}, {Yellow, Six}}
s.Deck = make([]Card, 24)
for i := range s.Deck {
s.Deck[i] = Card{Blue, Nine} // nothing here plays on a red one, and nothing bites
}
s.Turn = You
s.Phase = PhasePlay
return s
}
// TestCallingUnoKeepsYouSafe — say the word and the table has nothing on you.
// Across a spread of seeds, not one bot ever gets a catch.
func TestCallingUnoKeepsYouSafe(t *testing.T) {
for seed := uint64(0); seed < 200; seed++ {
s := oneCardAway(t, seed)
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Uno: true})
if err != nil {
t.Fatalf("seed %d: play: %v", seed, err)
}
if hasKind(evs, EvCaught) {
t.Fatalf("seed %d: caught after calling UNO", seed)
}
if !hasKind(evs, EvUno) {
t.Fatalf("seed %d: called UNO and the table never said so", seed)
}
if !next.Called[You] {
t.Fatalf("seed %d: the call wasn't recorded", seed)
}
}
}
// TestForgettingUnoGetsYouCaught — stay quiet on one card and the bot takes you
// for two. It gets one look, so it misses sometimes; over 400 games it should
// land near botAlert, and the two cards should actually arrive.
func TestForgettingUnoGetsYouCaught(t *testing.T) {
caught, games := 0, 400
for seed := uint64(0); seed < uint64(games); seed++ {
s := oneCardAway(t, seed)
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("seed %d: play: %v", seed, err)
}
if !hasKind(evs, EvCaught) {
continue
}
caught++
// One card left after the play, plus the two the catch cost.
if n := len(next.Hands[You]); n != 3 {
t.Fatalf("seed %d: caught and holding %d, want 3", seed, n)
}
if hasKind(evs, EvUno) {
t.Fatalf("seed %d: an UNO was announced by a seat that never called", seed)
}
}
rate := float64(caught) / float64(games)
if rate < botAlert-0.08 || rate > botAlert+0.08 {
t.Errorf("one bot caught you %.0f%% of the time, want about %.0f%% (botAlert)",
rate*100, botAlert*100)
}
}
// TestMoreBotsMeansLessGettingAwayWithIt — every seat gets its own look, so
// forgetting at a full table is very nearly always punished.
func TestMoreBotsMeansLessGettingAwayWithIt(t *testing.T) {
away := func(tier Tier, seed uint64) bool {
s := deal(t, tier, 100, seed)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
s.Turn = You
s.Phase = PhasePlay
_, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play: %v", err)
}
return !hasKind(evs, EvCaught)
}
var got [2]float64
for i, tier := range []Tier{duel(), table()} {
escapes := 0
for seed := uint64(0); seed < 400; seed++ {
if away(tier, seed) {
escapes++
}
}
got[i] = float64(escapes) / 400
}
if got[1] >= got[0] {
t.Errorf("you got away with it %.0f%% of the time against one bot and %.0f%% against two; "+
"two pairs of eyes should catch you more often, not less", got[0]*100, got[1]*100)
}
}
// quietBot puts a bot on one card it never called, with the turn back on you.
func quietBot(t *testing.T, called bool) State {
t.Helper()
s := deal(t, duel(), 100, 21)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}, {Blue, Two}, {Green, Three}}
s.Hands[1] = []Card{{Yellow, Nine}}
s.Called = []bool{false, called}
s.Turn = You
s.Phase = PhasePlay
return s
}
// TestCatchingAQuietBot — it's on one card and it never said so. Two cards to it,
// and the turn is still yours: catching is not a move you spend a turn on.
func TestCatchingAQuietBot(t *testing.T) {
s := quietBot(t, false)
before := total(census(s))
next, evs, err := ApplyMove(s, Move{Kind: MoveCatch, Seat: 1})
if err != nil {
t.Fatalf("catch: %v", err)
}
if !hasKind(evs, EvCaught) {
t.Fatal("no catch event")
}
if n := len(next.Hands[1]); n != 3 {
t.Errorf("the bot holds %d, want 3: one card, plus the two it just took", n)
}
if n := len(next.Hands[You]); n != 3 {
t.Errorf("your hand is %d, want 3: a catch costs you nothing", n)
}
if next.Turn != You {
t.Errorf("the turn went to seat %d: a catch is not a turn", next.Turn)
}
if total(census(next)) != before {
t.Error("the catch lost a card")
}
}
// TestCatchingACleanBotCostsYou — it called, or it isn't on one card at all.
// Either way you've accused it of nothing, and that is two cards to you.
func TestCatchingACleanBotCostsYou(t *testing.T) {
for _, tc := range []struct {
name string
state State
}{
{"it called", quietBot(t, true)},
{"it isn't even close", func() State {
s := quietBot(t, false)
s.Hands[1] = []Card{{Yellow, Nine}, {Yellow, Eight}}
return s
}()},
} {
t.Run(tc.name, func(t *testing.T) {
next, evs, err := ApplyMove(tc.state, Move{Kind: MoveCatch, Seat: 1})
if err != nil {
t.Fatalf("catch: %v", err)
}
if !hasKind(evs, EvMiscall) {
t.Fatal("no miscall event")
}
if n := len(next.Hands[You]); n != 5 {
t.Errorf("your hand is %d, want 5: three, plus the two a bad call cost", n)
}
if next.Turn != You {
t.Errorf("the turn went to seat %d: even a bad catch isn't a turn", next.Turn)
}
})
}
}
// TestYouCannotCatchYourself, or a seat that isn't at the table.
func TestYouCannotCatchYourself(t *testing.T) {
s := quietBot(t, false)
for _, seat := range []int{You, -1, 9} {
if _, _, err := ApplyMove(s, Move{Kind: MoveCatch, Seat: seat}); err != ErrNoCatch {
t.Errorf("catching seat %d: got %v, want ErrNoCatch", seat, err)
}
}
}
// TestACallIsSpentWhenTheHandGrows. Call on one card, get made to draw, and work
// your way back down to one: that is a new call you owe, not the old one still
// standing. Without this a seat could be caught out once and never again.
func TestACallIsSpentWhenTheHandGrows(t *testing.T) {
s := deal(t, duel(), 100, 5)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}}
s.Called = []bool{true, false}
s.Turn = You
s.Phase = PhasePlay
// Draw, and the hand is two: the word you said was about a card you no longer
// hold on its own.
next, _, err := ApplyMove(s, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.Called[You] {
t.Error("the call survived the hand growing; it should be spent")
}
}
// TestCatchableIsWhatTheTableCanSee — a quiet bot on one card, and nobody else.
func TestCatchable(t *testing.T) {
s := quietBot(t, false)
if got := s.Catchable(); len(got) != 1 || got[0] != 1 {
t.Errorf("Catchable() = %v, want [1]", got)
}
clean := quietBot(t, true)
if got := clean.Catchable(); len(got) != 0 {
t.Errorf("Catchable() = %v on a bot that called, want none", got)
}
// And not on somebody else's turn: you can only call it out when it's on you.
off := quietBot(t, false)
off.Turn = 1
if got := off.Catchable(); len(got) != 0 {
t.Errorf("Catchable() = %v off-turn, want none", got)
}
}
// TestUnoAtSeesThroughDiscardAll — the whole reason the table asks the engine
// which cards take you to one, rather than counting your hand itself. "Discard
// all" takes every card of its colour with it, so a six-card hand can land on
// one, and a browser subtracting one from six gets a player caught.
func TestUnoAtSeesThroughDiscardAll(t *testing.T) {
s := deal(t, nmDuel(), 100, 3)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Red, Seven}, {Blue, Two}}
s.Turn = You
s.Phase = PhasePlay
// Index 0 dumps itself and the three other reds: five cards become one.
// Index 4 is an ordinary play: five become four.
got := s.UnoAt()
if len(got) != 1 || got[0] != 0 {
t.Errorf("UnoAt() = %v, want [0]: only the discard-all lands you on one card", got)
}
}
// TestUnoAtIsTheOrdinaryCaseToo — two cards in hand, and either of them is a call.
func TestUnoAtIsTheOrdinaryCaseToo(t *testing.T) {
s := oneCardAway(t, 1)
got := s.UnoAt()
if len(got) != 2 {
t.Errorf("UnoAt() = %v, want both cards: either one leaves you holding one", got)
}
}
// TestGoingOutNeedsNoCall — your last card is not one card, it's none. Nobody
// owes the table a word for winning.
func TestGoingOutNeedsNoCall(t *testing.T) {
s := deal(t, duel(), 100, 9)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}}
s.Turn = You
s.Phase = PhasePlay
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play the last card: %v", err)
}
if !next.Outcome.Won() {
t.Fatalf("outcome %q, want a win", next.Outcome)
}
if hasKind(evs, EvCaught) {
t.Error("caught for not calling UNO on the card that won the game")
}
}
// TestABotThatForgetsSaysNothing — the tell is the absence of the badge, and the
// count beside the fan. If a quiet bot emitted anything at all there'd be nothing
// to spot.
func TestABotThatForgetsSaysNothing(t *testing.T) {
quiet := 0
for seed := uint64(0); seed < 300 && quiet < 1; seed++ {
s := deal(t, duel(), 100, seed)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Blue, Two}, {Blue, Three}, {Blue, Four}}
s.Hands[1] = []Card{{Red, One}, {Red, Nine}}
s.Turn = 1
s.Phase = PhasePlay
s.Turn = You // the bot plays on the back of your move
// Draw, handing the turn over: the bot plays a red and lands on one card.
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if len(next.Hands[1]) != 1 || next.Called[1] {
continue // it either didn't get down to one, or it remembered
}
quiet++
if hasKind(evs, EvUno) {
t.Error("a bot that forgot to call still announced it")
}
if len(next.Catchable()) != 1 {
t.Error("a quiet bot on one card isn't catchable")
}
}
if quiet == 0 {
t.Skip("no bot forgot in 300 games; botForget may have been turned down")
}
}

View File

@@ -152,7 +152,7 @@ func (s *State) roulette(victim int, color Color, evs *[]Event, rng *rand.Rand)
}
if got > 0 {
e := Event{Kind: EvRoulette, Seat: victim, N: got, Color: color, Left: len(s.Hands[victim])}
*evs = append(*evs, e)
*evs = append(*evs, s.mine(e))
}
if s.mercy(victim, evs, rng) {
return
@@ -181,8 +181,8 @@ func (s *State) discardAll(seat int, color Color, evs *[]Event) int {
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)})
*evs = append(*evs, s.mine(Event{Kind: EvDiscardAll, Seat: seat, N: len(dumped),
Color: color, Left: len(kept)}))
}
return len(dumped)
}
@@ -205,7 +205,7 @@ func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool {
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})
*evs = append(*evs, s.mine(Event{Kind: EvMercy, Seat: seat, N: n, Left: 0}))
if seat == You {
s.lose(evs)

View File

@@ -120,6 +120,19 @@ func playMove(s State, idx int, rng *rand.Rand) Move {
if s.Hands[You][idx].IsWild() {
m.Color = Red + Color(rng.IntN(4))
}
// It calls UNO. That is not a strategy, it is a button — the table puts it in
// front of you and waits — and what these multiples price is *bad card play*,
// not a player who ignores the felt shouting at them.
//
// A player who genuinely never calls loses far more than this one does, and
// nothing here is priced for them. That is the rule having teeth, which is the
// point of it. Naive is the floor for someone playing the game badly, not for
// someone not playing it.
for _, at := range s.UnoAt() {
if at == idx {
m.Uno = true
}
}
return m
}
@@ -323,7 +336,10 @@ func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
s.Phase = PhasePlay
before := total(census(s))
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
// With the call, because this play takes three reds out of the hand at once and
// lands on exactly one card — which is precisely the case a browser counting
// "hand length minus one" would miss, and the reason UnoAt is the engine's job.
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Uno: true})
if err != nil {
t.Fatalf("play discard-all: %v", err)
}

View File

@@ -39,6 +39,7 @@ var (
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")
ErrNoCatch = errors.New("uno: there's nobody to catch there")
ErrUnknownMove = errors.New("uno: unknown move")
ErrBadBet = errors.New("uno: bet must be positive")
ErrUnknownTier = errors.New("uno: no such tier")
@@ -233,6 +234,13 @@ func (t Tier) Deck() []Card {
// 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.
//
// Re-measured again when the UNO call went in (call.go): 40.1% / 28.5% / 23.1%,
// which is the same game. That is the point — the naive player *calls*, because
// calling is a button and not a strategy, so the rule costs them nothing and
// these multiples stand. What the rule adds is upside for a player who watches
// the counts and catches a quiet bot, which is the good play these tiers are
// meant to leave room for.
var Tiers = []Tier{
{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."},
@@ -246,7 +254,7 @@ var Tiers = []Tier{
//
// 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.
// wins 45.6% / 31.8% / 27.4% here, against 40.1 / 28.5 / 23.1 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
@@ -264,7 +272,11 @@ var NoMercyTiers = []Tier{
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,
// Re-priced 2026-07-14 with the call rule in: 3.8 was paying a *negative* house
// edge here (-0.2%), which is the house paying you to sit down. The naive win
// rate at this table is 27.4%, not the 25.3% it was priced off — three bots
// burying each other is even better for you than the last measurement caught.
{Slug: "nm-full", Name: "No Mercy Full House", Bots: 3, Base: 3.5, NoMercy: true,
Blurb: "Three bots. Twenty-five cards and you're out of the game."},
}
@@ -325,6 +337,11 @@ type State struct {
Out []bool `json:"out,omitempty"`
Pending int `json:"pending,omitempty"`
// Called is who, holding one card, said so. It is only ever meaningful for a
// seat on exactly one card — every other seat's entry is false and means
// nothing — and it is what a catch is tested against. See call.go.
Called []bool `json:"called,omitempty"`
Seed1 uint64 `json:"seed1"`
Seed2 uint64 `json:"seed2"`
Step uint64 `json:"step"` // how many moves have been applied; the rng's other half
@@ -346,7 +363,26 @@ type Event struct {
Color Color `json:"color,omitempty"` // the colour now in play, on a wild
N int `json:"n,omitempty"` // how many cards were drawn
Left int `json:"left"` // cards left in that seat's hand afterwards
By int `json:"by"` // who caught them, on a catch. Seat zero is a real answer here, so never omitempty
Text string `json:"text,omitempty"`
// Hand is *your* hand as it stands after this event, and it is only ever set
// on an event that changed it. The table plays a lap of events back over
// several seconds, and for all of them the hand on screen has to be the hand
// you actually hold — a +4 that lands on you at the top of the lap must show
// up in your fan as it lands, not when the lap ends. There is no leak here:
// this is the one hand the browser is already entitled to see.
Hand []Card `json:"hand,omitempty"`
}
// mine stamps the player's hand onto an event that just changed it. Events about
// a bot's hand carry nothing — the browser never learns those, and stamping is
// scoped to seat zero precisely so it can't start.
func (s *State) mine(e Event) Event {
if e.Seat == You {
e.Hand = append([]Card(nil), s.Hands[You]...)
}
return e
}
// The kinds an Event comes in.
@@ -359,7 +395,9 @@ type Event struct {
// pass the turn moves on with nothing played
// skip a seat loses its turn
// reverse the direction flips
// uno a hand is down to one card
// uno a hand is down to one card, and its owner said so
// caught a seat went down to one card *quietly*, and somebody noticed: +2
// miscall you called a seat that had nothing to hide, and paid for it: +2
// reshuffle the discard goes back under
// settle it's over
//
@@ -379,6 +417,8 @@ const (
EvSkip = "skip"
EvReverse = "reverse"
EvUno = "uno"
EvCaught = "caught"
EvMiscall = "miscall"
EvReshuffle = "reshuffle"
EvSettle = "settle"
@@ -392,19 +432,26 @@ const (
// Move is what the player sends: play this card, take one off the deck, or —
// having taken one you can play — decline to play it.
type Move struct {
Kind string `json:"kind"` // "play" | "draw" | "pass"
Kind string `json:"kind"` // "play" | "draw" | "pass" | "take" | "catch"
Index int `json:"index"` // which card of your hand, for a play
Color Color `json:"color"` // the colour you name, for a wild
Uno bool `json:"uno"` // "…and UNO!", for a play that leaves you on one card
Seat int `json:"seat"` // whose silence you're calling, for a catch
}
// 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.
//
// Catch is the other half of the UNO call — see call.go. It is a move you make
// out of turn order (it costs you nothing but the risk of being wrong), so it is
// a kind rather than a flag on the moves that do cost a turn.
const (
MovePlay = "play"
MoveDraw = "draw"
MovePass = "pass"
MoveTake = "take"
MoveCatch = "catch"
)
// New deals a game: a shuffled deck, seven each, and a card turned over.
@@ -434,6 +481,7 @@ func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Even
seats := t.Bots + 1
s.Out = make([]bool, seats)
s.Called = make([]bool, seats)
s.Hands = make([][]Card, seats)
for i := range s.Hands {
s.Hands[i] = make([]Card, 0, HandSize)
@@ -460,7 +508,7 @@ func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Even
break
}
return s, []Event{{Kind: EvDeal, Card: s.topPtr(), Color: s.Color}}, nil
return s, []Event{s.mine(Event{Kind: EvDeal, Card: s.topPtr(), Color: s.Color})}, nil
}
// ApplyMove is the engine. Your move goes in; your move, and every bot turn it
@@ -479,6 +527,7 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
next := s.clone()
next.Step++
next.ensureCalled()
rng := stepRNG(next.Seed1, next.Seed2, next.Step)
var evs []Event
@@ -492,6 +541,8 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
evs, err = next.playerPasses()
case MoveTake:
evs, err = next.playerTakes(rng)
case MoveCatch:
evs, err = next.playerCatches(m, rng)
default:
return s, nil, ErrUnknownMove
}
@@ -499,8 +550,17 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
return s, nil, err // the caller's state, untouched
}
// Before anybody moves: did you go down to one card without saying so? This is
// the only window there is. The bots are about to take their turns, and a bot
// that has played on is a bot that has stopped looking at your hand.
next.botsCatch(&evs, rng)
// The bots take their turns on the back of yours, and the whole run comes
// back as one script. This is the reason solo UNO needs no socket.
//
// A catch is not a turn — it leaves the turn where it was, which is with you —
// so this is a no-op after one, and that is the whole mechanism by which
// catching a bot costs you nothing but the risk of being wrong.
next.runBots(&evs, rng)
// And if that left a table nobody can move at, it ends here rather than
@@ -508,6 +568,7 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
if next.Phase != PhaseDone && next.stalled() {
next.stuck(&evs)
}
next.tidyCalls()
return next, evs, nil
}
@@ -543,6 +604,11 @@ func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
var evs []Event
s.discard(You, card, m.Color, &evs)
s.after(You, card, &evs, rng)
// Whether you called is checked *after* the card has finished resolving, not
// after it left your hand. No Mercy's "discard all" takes every card of a
// colour with it, so it can drop you from six cards to one in a single play —
// and the seat that gets there that way owes the call just the same.
s.declare(You, m.Uno, &evs)
return evs, nil
}
@@ -693,6 +759,11 @@ func (s *State) botPlays(seat int, card Card, idx int, evs *[]Event, rng *rand.R
}
s.discard(seat, card, color, evs)
s.after(seat, card, evs, rng)
// A bot that has just gone down to one card mostly remembers to say so. When it
// doesn't, it says nothing at all — no event, no badge — and the only thing on
// the table that gives it away is the count beside its fan reading "1 card".
// Spotting that is the player's to do.
s.declare(seat, !botForgets(rng), evs)
}
// stalled reports whether the table is dead: nothing left to draw anywhere, and
@@ -737,10 +808,11 @@ func (s *State) discard(seat int, card Card, color Color, evs *[]Event) {
}
s.Discard = append(s.Discard, card)
e := Event{Kind: EvPlay, Seat: seat, Card: &card, Color: s.Color, Left: len(s.Hands[seat])}
*evs = append(*evs, e)
if len(s.Hands[seat]) == 1 {
*evs = append(*evs, Event{Kind: EvUno, Seat: seat})
}
*evs = append(*evs, s.mine(e))
// No UNO event here any more. Going down to one card used to *be* the call,
// which meant nobody at this table could ever fail to make it. Now the call is
// a thing a seat does or doesn't do, and it is announced — or conspicuously not
// — by the two functions that know whether it was made. See call.go.
}
// after resolves what the card just played does, and moves the turn on. It is
@@ -834,18 +906,7 @@ func (s *State) punish(victim, n int, evs *[]Event, rng *rand.Rand) {
// A bot's cards go face down: the event carries the count, never the card. The
// only hand whose faces cross the wire is yours.
func (s *State) deal(seat, n int, forced bool, evs *[]Event, rng *rand.Rand) []Card {
got := make([]Card, 0, n)
for i := 0; i < n; i++ {
if len(s.Deck) == 0 && !s.reshuffle(evs, rng) {
break
}
c, ok := s.pop()
if !ok {
break
}
s.Hands[seat] = append(s.Hands[seat], c)
got = append(got, c)
}
got := s.drawCards(seat, n, evs, rng)
if len(got) == 0 {
return got
}
@@ -858,7 +919,28 @@ func (s *State) deal(seat, n int, forced bool, evs *[]Event, rng *rand.Rand) []C
c := got[0]
e.Card = &c // your own card, and only yours, comes face up
}
*evs = append(*evs, e)
*evs = append(*evs, s.mine(e))
return got
}
// drawCards is deal without the announcement: cards come off the deck (with a
// reshuffle under them if it runs dry) and go into a hand, and nothing is said
// about it. deal wraps it to say the usual thing; a catch wraps it to say a
// different thing. It hands back what was actually drawn, which can be fewer
// cards than asked for when the table has nothing left to give.
func (s *State) drawCards(seat, n int, evs *[]Event, rng *rand.Rand) []Card {
got := make([]Card, 0, n)
for i := 0; i < n; i++ {
if len(s.Deck) == 0 && !s.reshuffle(evs, rng) {
break
}
c, ok := s.pop()
if !ok {
break
}
s.Hands[seat] = append(s.Hands[seat], c)
got = append(got, c)
}
return got
}
@@ -1105,6 +1187,7 @@ func (s State) clone() State {
s.Discard = append([]Card(nil), s.Discard...)
s.Bots = append([]string(nil), s.Bots...)
s.Out = append([]bool(nil), s.Out...)
s.Called = append([]bool(nil), s.Called...)
return s
}

View File

@@ -49,6 +49,7 @@ type unoSeatView struct {
Cards int `json:"cards"`
You bool `json:"you"`
Uno bool `json:"uno"` // down to one card
Called bool `json:"called"` // …and said so. A seat where Uno is true and this isn't is a seat you can catch
Out bool `json:"out"` // No Mercy: buried at twenty-five, and not coming back
}
@@ -63,6 +64,14 @@ type unoView struct {
Color string `json:"color"` // the colour in play, which a wild renames
Deck int `json:"deck"` // cards left to draw
// The UNO call. UnoAt is which of your cards would leave you holding exactly
// one if you played it — the table asks for the call on those, and it asks the
// engine which they are because No Mercy's "discard all" makes counting the
// hand the wrong answer. Catchable is which seats are sitting on one card they
// never announced.
UnoAt []int `json:"uno_at"`
Catchable []int `json:"catchable"`
Turn int `json:"turn"`
Dir int `json:"dir"`
@@ -104,7 +113,10 @@ func viewUno(g uno.State) unoView {
// alone, which is why a buried seat is asked about rather than inferred.
for i, n := range g.Counts() {
live := g.Live(i)
seat := unoSeatView{Cards: n, You: i == uno.You, Uno: live && n == 1, Out: !live}
seat := unoSeatView{
Cards: n, You: i == uno.You, Uno: live && n == 1, Out: !live,
Called: i < len(g.Called) && g.Called[i],
}
if i == uno.You {
seat.Name = "You"
} else if i-1 < len(g.Bots) {
@@ -127,6 +139,16 @@ func viewUno(g uno.State) unoView {
if v.Playable == nil {
v.Playable = []int{}
}
// Empty arrays, never null: the table indexes into these, and `null.indexOf`
// is a broken game rather than a quiet no-op.
v.UnoAt = g.UnoAt()
if v.UnoAt == nil {
v.UnoAt = []int{}
}
v.Catchable = g.Catchable()
if v.Catchable == nil {
v.Catchable = []int{}
}
return v
}
@@ -141,16 +163,32 @@ type unoEventView struct {
Color string `json:"color,omitempty"`
N int `json:"n,omitempty"`
Left int `json:"left"` // never omitempty: a seat that goes out leaves zero, and zero is the number the table has to draw
By int `json:"by"` // who did the catching. Seat zero is you, and "you" is a real answer, so never omitempty either
Text string `json:"text,omitempty"`
// Hand is your hand as it stands after this event, on the events that changed
// it. The table redraws your fan from this as the lap plays back — see the
// engine's Event.Hand for why. It is your own hand, so there is nothing here
// the browser wasn't already holding.
Hand []unoCardView `json:"hand,omitempty"`
}
func viewUnoEvents(evs []uno.Event) []unoEventView {
out := make([]unoEventView, 0, len(evs))
for _, e := range evs {
v := unoEventView{Kind: e.Kind, Seat: e.Seat, N: e.N, Left: e.Left, Text: e.Text}
v := unoEventView{Kind: e.Kind, Seat: e.Seat, N: e.N, Left: e.Left, By: e.By, Text: e.Text}
if e.Color != uno.Wild {
v.Color = e.Color.String()
}
// The engine only stamps a hand on an event about seat zero. This is the belt
// to that brace: whatever the engine thinks it's doing, no hand but yours
// leaves this building.
if e.Seat == uno.You && e.Hand != nil {
v.Hand = make([]unoCardView, 0, len(e.Hand))
for _, c := range e.Hand {
v.Hand = append(v.Hand, viewUnoCard(c))
}
}
if e.Card != nil {
// The engine only ever attaches a card to an event the seat is entitled
// to see it in — a card played face up, or one *you* drew. This check is
@@ -268,6 +306,8 @@ func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
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"
case errors.Is(err, uno.ErrNoCatch):
msg = "there's nobody in that seat to catch"
}
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
return

View File

@@ -1563,6 +1563,10 @@ html[data-phase="night"] {
animation-delay: calc(var(--i, 0) * 45ms);
transition: transform 0.16s ease, filter 0.16s ease;
}
/* A card that was already in your hand before this redraw doesn't get dealt to
you again. The hand is redrawn several times a lap now (every event that
changes it), and without this every one of them re-fans the whole thing. */
.pete-uno-hand .pete-uno-card[data-settled="1"] { animation: none; }
.pete-uno-hand .pete-uno-card[data-on="1"] {
cursor: pointer;
transform: translateY(-0.5rem);
@@ -1750,6 +1754,85 @@ html[data-phase="night"] {
.pete-uno-swatch[data-c="yellow"] { --sw: #e0b02c; color: #2b2118; }
.pete-uno-swatch[data-c="green"] { --sw: #46a86b; }
/* ---- calling it, and catching the seat that didn't -------------------------
The call is a button with a clock on it. It wants to be the loudest thing on
the felt for the two-and-a-bit seconds it exists, because the whole rule is
that you can miss it. */
.pete-uno-call {
margin-top: 0.85rem;
padding: 0.9rem 2.6rem;
border-radius: 999px;
background: #e0b02c;
color: #2b2118;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 1.7rem;
font-weight: 700;
letter-spacing: 0.02em;
box-shadow: 0 5px 0 rgba(0,0,0,0.3), 0 0 0 0 rgba(224,176,44,0.55);
animation: pete-uno-call 0.9s ease-in-out infinite;
}
.pete-uno-call:hover { filter: brightness(1.07); }
.pete-uno-call:active { transform: translateY(2px); box-shadow: 0 3px 0 rgba(0,0,0,0.3); }
@keyframes pete-uno-call {
0%, 100% { transform: scale(1); box-shadow: 0 5px 0 rgba(0,0,0,0.3), 0 0 0 0 rgba(224,176,44,0.5); }
50% { transform: scale(1.05); box-shadow: 0 5px 0 rgba(0,0,0,0.3), 0 0 0 0.9rem rgba(224,176,44,0); }
}
/* How long you've got, draining left to right. The duration is set on the
element: the window lives in uno.js, and a bar that disagrees with the clock
it's drawing is worse than no bar. */
.pete-uno-timer {
margin-top: 0.8rem;
height: 0.35rem;
border-radius: 999px;
background: rgba(255,255,255,0.16);
overflow: hidden;
}
.pete-uno-timer > span {
display: block;
height: 100%;
width: 100%;
background: #e0b02c;
transform-origin: left center;
animation: pete-uno-drain linear forwards;
}
@keyframes pete-uno-drain {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
/* A seat on one card that never said so. It gets a button, and the button is
the only thing on the felt that says it — there's no badge, because not
announcing is exactly what it did. */
.pete-uno-catch {
position: absolute;
top: -0.55rem;
left: 50%;
transform: translateX(-50%);
z-index: 6;
padding: 0.22rem 0.7rem;
border-radius: 999px;
background: #cc3d4a;
color: #fff;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.72rem;
font-weight: 700;
white-space: nowrap;
box-shadow: 0 3px 0 rgba(0,0,0,0.25);
animation: pete-uno-catch 1.1s ease-in-out infinite;
transition: filter 0.12s ease;
}
.pete-uno-catch:hover { filter: brightness(1.12); }
.pete-uno-catch:disabled { opacity: 0.45; animation: none; }
@keyframes pete-uno-catch {
0%, 100% { transform: translateX(-50%) scale(1); }
50% { transform: translateX(-50%) scale(1.08); }
}
@media (prefers-reduced-motion: reduce) {
.pete-uno-call, .pete-uno-catch { animation: none; }
}
/* ---- 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

File diff suppressed because one or more lines are too long

View File

@@ -164,7 +164,13 @@
// The one thing in this room that gets confetti. A natural is rare, it pays
// 3:2, and if everything celebrated then nothing would.
//
// The *sound* is not so precious: a win is a win and you should hear it. So
// the fanfare rides on the money, not on the confetti.
if (v.outcome === "blackjack") FX.burst(verdictEl, { count: 34 });
else if (v.net > 0) FX.sfx("win");
else if (v.net < 0) FX.sfx("lose");
else FX.sfx("push");
}
// setPhase swaps the controls: bet between hands, act during one.
@@ -221,10 +227,12 @@
playerEl.innerHTML = "";
playerEl.dataset.won = "0";
verdictEl.classList.add("hidden");
FX.sfx("shuffle");
return;
case "player_card":
playerEl.appendChild(cardEl(e.card));
FX.sfx("deal");
return wait(DEAL_MS);
case "dealer_card":
@@ -235,12 +243,14 @@
drew = true;
return beat.then(function () {
dealerEl.appendChild(cardEl(e.card));
FX.sfx("deal", { v: 1 });
return wait(DEAL_MS);
});
case "dealer_hole":
hole = cardEl(null);
dealerEl.appendChild(hole);
FX.sfx("deal", { v: 2 });
return wait(DEAL_MS);
case "reveal":

View File

@@ -155,11 +155,17 @@
}
// turnOver flips a face-down card up, now that we've been told what it is.
//
// Unlike el(), this is only ever a *gesture* — a card that was down and is now
// up, because something happened. It never runs on a repaint, which is why the
// flip sound can live here and be right for every table at once, and why the
// deal sound cannot: el() runs every time a board is redrawn.
function turnOver(wrap, face) {
if (!wrap) return;
paint(wrap.querySelector(".pete-card-front"), face);
wrap.dataset.face = "up";
wrap.dataset.key = face.label;
if (window.PeteSFX) window.PeteSFX.play("flip");
}
window.PeteCards = {

View File

@@ -18,6 +18,14 @@
var reduced =
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// The room's noise, if it's loaded. Everything that flies through this file
// makes a sound when it lands, which is how every table got sound at once
// without any of them being told about it: a chip is a chip wherever it's
// thrown from. A game that wants something more specific says so per call.
function sfx(name, opts) {
if (window.PeteSFX) window.PeteSFX.play(name, opts);
}
var layer = null;
function stage() {
if (!layer) {
@@ -107,13 +115,23 @@
// than averaging itself back down to nothing.
var sMid = Math.max(s0, s1) * 1.12;
var wait = reduced ? 0 : opts.delay || 0;
// It makes a noise when it lands, and the noise is scheduled on the audio
// clock for the moment it lands — not fired by a timer that goes off when the
// animation ends. A timer that drifts thirty milliseconds is a card you hear
// before you see, and that is worse than silence.
if (opts.sound) {
sfx(opts.sound, { delay: (wait + dur) / 1000, v: opts.index || 0 });
}
var anim = node.animate(
[
{ transform: t(a.x, a.y, s0, opts.fromSpin || 0), opacity: 1, offset: 0 },
{ transform: t(midX, midY, sMid, spin * 0.6), opacity: 1, offset: 0.5 },
{ transform: t(b.x, b.y, s1, spin), opacity: opts.fade ? 0 : 1, offset: 1 },
],
{ duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: reduced ? 0 : opts.delay || 0, fill: "both" }
{ duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: wait, fill: "both" }
);
return anim.finished
@@ -127,6 +145,9 @@
// was — UNO wanted the same throw with a card in it, so the throw moved out.
function fly(from, to, opts) {
opts = opts || {};
// A chip clinks. Every table in the room bets, pays and sweeps through this
// one function, so saying it once here is every table having chip sounds.
if (opts.sound === undefined) opts = Object.assign({ sound: "chip" }, opts);
return flyNode(disc(opts.denom || 25), from, to, opts);
}
@@ -225,6 +246,7 @@
var n = amount == null ? api.amount : amount;
var left = api.amount - n;
if (n <= 0) return Promise.resolve();
sfx("sweep"); // the pile leaving the felt, under the chips landing
var chain = flyMany(els.spot, to, chipsFor(n, 8), Object.assign({ gap: 40, lift: 0.8 }, opts || {}));
api.render(left > 0 ? left : 0);
return chain;
@@ -233,8 +255,10 @@
return api;
}
// burst: confetti out of a point. Saved for the things worth celebrating.
// burst: confetti out of a point. Saved for the things worth celebrating — so
// it is also, everywhere in the room, the sound of winning.
function burst(target, opts) {
sfx("win"); // before the reduced-motion bail: no confetti still deserves the fanfare
if (reduced) return Promise.resolve();
opts = opts || {};
var c = centre(target);
@@ -326,6 +350,7 @@
window.PeteFX = {
reduced: reduced,
sfx: sfx, // so a table can make a noise without reaching past this file
chipsFor: chipsFor,
disc: disc,
jitter: jitter,

View File

@@ -0,0 +1,265 @@
// The noise the room makes.
//
// There are no audio files here and there is nothing to download. Every sound in
// this casino is *made* — an oscillator, a burst of filtered noise, an envelope —
// the same bargain the weather engine takes with its clouds. A card is a short
// slap of noise through a bandpass; a chip is two detuned sines with a click on
// the front; a win is four notes going up. It costs about six kilobytes and no
// round trips, and it means a sound can be pitched, stretched and detuned per
// call instead of being the same wav 300 times.
//
// Two rules hold the whole file up.
//
// **Nothing is built until it's asked for.** A browser will not let a page make a
// noise before the user has touched it, and a page that builds an AudioContext on
// load gets one in the "suspended" state and a warning in the console for its
// trouble. So the context is made on the first sound anybody actually asks for —
// which, in a casino, is a click on a chip.
//
// **Muted means silent, not quiet.** When the room is muted nothing is
// constructed, nothing is scheduled and no context is opened. Mute is the
// default-off switch for the entire file, checked before anything else happens.
//
// Exposed as window.PeteSFX. Nothing in here knows what blackjack is.
(function () {
"use strict";
var KEY = "pete.sfx.off";
var ctx = null;
var master = null;
var noiseBuf = null;
var muted = false;
var listeners = [];
try { muted = localStorage.getItem(KEY) === "1"; } catch (e) {}
// ---- the instrument --------------------------------------------------------
function boot() {
if (ctx) return ctx;
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
try {
ctx = new AC();
} catch (e) {
return null;
}
master = ctx.createGain();
master.gain.value = 0.45; // the room is furniture, not a nightclub
master.connect(ctx.destination);
return ctx;
}
// A second of white noise, made once and played at different speeds and through
// different filters for the rest of the session. Card flicks, riffles, the
// transient on a chip: all of them are this buffer wearing a different coat.
function noise() {
if (noiseBuf) return noiseBuf;
var n = ctx.sampleRate;
noiseBuf = ctx.createBuffer(1, n, n);
var d = noiseBuf.getChannelData(0);
for (var i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
return noiseBuf;
}
// env is the shape of every sound in here: up fast, down slow, and never to
// zero — an exponential ramp to actual zero is undefined, and a browser that
// hits one either throws or clicks.
function env(g, t0, attack, decay, peak) {
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(Math.max(0.0001, peak), t0 + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + attack + decay);
}
// tone is one note.
function tone(t0, freq, o) {
o = o || {};
var osc = ctx.createOscillator();
var g = ctx.createGain();
osc.type = o.type || "sine";
osc.frequency.setValueAtTime(freq, t0);
if (o.to) osc.frequency.exponentialRampToValueAtTime(o.to, t0 + (o.glide || o.decay || 0.1));
env(g, t0, o.attack || 0.004, o.decay || 0.12, o.gain == null ? 0.3 : o.gain);
osc.connect(g).connect(master);
osc.start(t0);
osc.stop(t0 + (o.attack || 0.004) + (o.decay || 0.12) + 0.02);
}
// hiss is a burst of the noise buffer through a filter, which is what a card,
// a riffle and the knock on a chip all are.
function hiss(t0, o) {
o = o || {};
var src = ctx.createBufferSource();
src.buffer = noise();
src.playbackRate.value = o.rate || 1;
var f = ctx.createBiquadFilter();
f.type = o.filter || "bandpass";
f.frequency.setValueAtTime(o.freq || 2000, t0);
if (o.sweepTo) f.frequency.exponentialRampToValueAtTime(o.sweepTo, t0 + (o.decay || 0.1));
f.Q.value = o.q == null ? 1.1 : o.q;
var g = ctx.createGain();
env(g, t0, o.attack || 0.003, o.decay || 0.09, o.gain == null ? 0.3 : o.gain);
src.connect(f).connect(g).connect(master);
src.start(t0);
src.stop(t0 + (o.attack || 0.003) + (o.decay || 0.09) + 0.02);
}
// ---- the sounds ------------------------------------------------------------
//
// Each one takes the time it starts at, and a `v` — a small per-call variation,
// usually the index of the card or chip in a run. Four cards dealt with the
// identical sample four times is a machine gun; four cards each a semitone off
// is a hand dealing.
var SOUNDS = {
// A card landing: mostly air, with a slap on the front of it.
card: function (t, v) {
hiss(t, { freq: 1800 + v * 140, q: 0.9, decay: 0.075, rate: 1 + v * 0.05, gain: 0.3 });
hiss(t, { filter: "highpass", freq: 4200, decay: 0.02, gain: 0.16 });
},
// Dealing is the same card, lower and softer: it's being placed, not thrown.
deal: function (t, v) {
hiss(t, { freq: 1250 + v * 110, q: 1.1, decay: 0.085, rate: 0.9, gain: 0.24 });
},
// A card turning over — shorter, brighter, and it comes in two halves, which
// is what a flip actually sounds like.
flip: function (t) {
hiss(t, { freq: 2600, q: 1.4, decay: 0.035, gain: 0.22 });
hiss(t + 0.045, { freq: 1500, q: 1.0, decay: 0.06, gain: 0.24 });
},
// The riffle. One long sweep of noise with the filter climbing through it,
// and a rattle laid over the top so it isn't just a shhh.
shuffle: function (t) {
hiss(t, { freq: 700, sweepTo: 3400, q: 0.7, attack: 0.02, decay: 0.34, gain: 0.2 });
for (var i = 0; i < 9; i++) {
hiss(t + 0.03 + i * 0.032, { freq: 2400 + i * 130, q: 2.2, decay: 0.02, gain: 0.09 });
}
},
// A chip landing on a chip. Two detuned partials well above the staff, with a
// knock underneath — the knock is most of what makes it read as *clay* rather
// than as a bell.
chip: function (t, v) {
var f = 1750 + v * 55;
tone(t, f, { type: "sine", decay: 0.07, gain: 0.16 });
tone(t, f * 1.34, { type: "sine", decay: 0.05, gain: 0.1 });
hiss(t, { filter: "lowpass", freq: 900, decay: 0.03, gain: 0.3, q: 0.5 });
},
// Chips sliding away across felt.
sweep: function (t) {
hiss(t, { freq: 1600, sweepTo: 500, q: 0.6, attack: 0.015, decay: 0.26, gain: 0.16 });
},
// Four notes up. This is the only sound in the room allowed to be pleased.
win: function (t) {
[523.25, 659.25, 783.99, 1046.5].forEach(function (f, i) {
tone(t + i * 0.085, f, { type: "triangle", decay: 0.26, gain: 0.24 });
});
},
// Two notes down, and the second one is flat. Nobody needs telling twice.
lose: function (t) {
tone(t, 311.13, { type: "triangle", decay: 0.24, gain: 0.22 });
tone(t + 0.15, 233.08, { type: "triangle", decay: 0.42, gain: 0.22 });
},
// Nothing happened and nothing was lost.
push: function (t) {
tone(t, 392, { type: "sine", decay: 0.2, gain: 0.16 });
},
// A button. Short enough that you feel it rather than hear it.
blip: function (t) {
tone(t, 880, { type: "sine", decay: 0.055, gain: 0.12 });
},
// UNO, called. A bright stab plus a zip upwards: it is a shout, and it is the
// one thing on that table you want to feel good about pressing.
uno: function (t) {
[659.25, 830.61, 987.77].forEach(function (f) {
tone(t, f, { type: "triangle", decay: 0.3, gain: 0.2 });
});
tone(t, 500, { type: "sawtooth", to: 1600, glide: 0.16, decay: 0.18, gain: 0.09 });
},
// Got them. A rising snap with a thump on the end — the sound of a finger
// being pointed.
catch: function (t) {
tone(t, 300, { type: "square", to: 900, glide: 0.11, decay: 0.12, gain: 0.13 });
hiss(t + 0.1, { filter: "lowpass", freq: 1100, decay: 0.1, gain: 0.34, q: 0.7 });
tone(t + 0.1, 174.61, { type: "triangle", decay: 0.22, gain: 0.2 });
},
// Something bad landed on you: a stack, a +4, a catch you walked into.
bad: function (t) {
tone(t, 155.56, { type: "square", to: 98, glide: 0.18, decay: 0.22, gain: 0.14 });
hiss(t, { filter: "lowpass", freq: 700, decay: 0.14, gain: 0.3, q: 0.6 });
},
// A clock you can hear. Used sparingly, and never in a run of more than a few.
tick: function (t) {
hiss(t, { freq: 3200, q: 3, decay: 0.02, gain: 0.14 });
},
};
// ---- the door --------------------------------------------------------------
// A browser will not make a noise until the user has touched the page, and a
// context built before that arrives suspended. This wakes it on the first real
// gesture — after which play() can schedule freely.
function wake() {
if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {});
}
["pointerdown", "keydown", "touchstart"].forEach(function (ev) {
window.addEventListener(ev, wake, { passive: true });
});
// play makes a sound, or — if the room is muted — makes nothing at all, opens no
// context and builds no graph.
//
// `v` is the variation: pass the index of the card or chip in a run and the
// sound moves a little each time, which is the difference between a hand and a
// machine. `delay` schedules it ahead in seconds, so a caller that knows a card
// lands in 400ms can say so rather than sleeping.
function play(name, opts) {
if (muted) return;
var s = SOUNDS[name];
if (!s) return;
if (!boot()) return;
wake();
if (ctx.state !== "running") return; // not yet touched: no sound, and no error
try {
s(ctx.currentTime + ((opts && opts.delay) || 0), ((opts && opts.v) || 0));
} catch (e) {
/* a sound is never worth throwing over */
}
}
function setMuted(on) {
muted = !!on;
try {
if (muted) localStorage.setItem(KEY, "1");
else localStorage.removeItem(KEY);
} catch (e) {}
if (window.PetePrefs) window.PetePrefs.push();
listeners.forEach(function (fn) { fn(muted); });
if (!muted) play("blip"); // so you know what you just turned back on
}
window.PeteSFX = {
play: play,
muted: function () { return muted; },
toggle: function () { setMuted(!muted); return muted; },
set: setMuted,
// onChange lets the mute button re-label itself without owning the state.
onChange: function (fn) { listeners.push(fn); fn(muted); },
};
})();

View File

@@ -154,6 +154,25 @@
});
}
// The room's volume. The button lives in the chip bar, which is the one thing
// every table has — the sound belongs to the room, not to any one game — and
// the state itself belongs to PeteSFX, which persists it. This only draws it.
var sfxBtn = document.querySelector("[data-sfx-toggle]");
if (sfxBtn && window.PeteSFX) {
var onIcon = sfxBtn.querySelector("[data-sfx-on]");
var offIcon = sfxBtn.querySelector("[data-sfx-off]");
var sfxLbl = sfxBtn.querySelector("[data-sfx-label]");
window.PeteSFX.onChange(function (muted) {
sfxBtn.setAttribute("aria-pressed", muted ? "true" : "false");
sfxBtn.title = muted ? "Sound is off" : "Sound is on";
if (onIcon) onIcon.classList.toggle("hidden", muted);
if (offIcon) offIcon.classList.toggle("hidden", !muted);
if (sfxLbl) sfxLbl.textContent = muted ? "Turn the sound on" : "Mute the room";
});
sfxBtn.addEventListener("click", function () { window.PeteSFX.toggle(); });
}
window.PeteGames = {
// onUpdate registers a listener called on every fresh view of the money.
onUpdate: function (fn) { listeners.push(fn); if (view) fn(view); },

View File

@@ -117,6 +117,9 @@
at.forEach(function (i, n) {
var t = boardEl.querySelector('.pete-tile[data-at="' + i + '"]');
if (!t) return;
// One note per tile, climbing: a letter that turns up three times should
// sound like it's worth three times as much, because it is.
FX.sfx("tick", { delay: pace(n * 90) / 1000, v: n });
setTimeout(function () {
// Left as it comes: the tile is uppercased in CSS, and doing it here too
// would mean the resume path (which paints the phrase's own casing) and
@@ -126,6 +129,7 @@
t.classList.add("pete-tile-hit");
}, pace(n * 90));
});
FX.sfx("flip");
return wait(FLIP_MS + (at.length - 1) * 90);
}
@@ -292,6 +296,8 @@
// Confetti for a phrase guessed outright — the one call you make on your own
// rather than by grinding the alphabet.
if (v.outcome === "solved" && v.net > 0) FX.burst(verdictEl, { count: 30 });
else if (v.net > 0) FX.sfx("win");
else if (v.net < 0) FX.sfx("lose");
}
function setPhase(v) {
@@ -362,6 +368,7 @@
// they are one event: this is what a wrong guess costs, all of it.
drawGallows(countMisses(events, e), true);
shake();
FX.sfx("bad");
if (final) knock(final);
return wait(MISS_MS);

View File

@@ -301,6 +301,7 @@
pot.render(0);
potTotal.textContent = "0";
sideEl.classList.add("hidden");
FX.sfx("shuffle");
return wait(140);
case "rebuy":
@@ -377,6 +378,7 @@
var face = (i === 0 && s.cards) ? s.cards[round] : null;
var card = Cards.el(face, { deal: true, tilt: i !== 0 });
built.cards.appendChild(card);
FX.sfx("deal", { delay: 0.07 * i, v: i });
beats.push(wait(70 * i));
});
return Promise.all(beats).then(function () { return wait(180); });
@@ -394,10 +396,12 @@
if (e.text === "fold") {
s.root.dataset.state = "folded";
s.cards.dataset.mucked = "1";
FX.sfx("card", { v: 2 }); // cards going into the muck
return wait(320);
}
if (e.text === "check") {
flash(s.root);
FX.sfx("blip"); // a knuckle on the table
return wait(320);
}
// call, raise, allin: chips leave their stack for their spot.
@@ -437,9 +441,10 @@
// The board turns one card at a time, even the flop. Three cards appearing
// at once is a screenshot; three cards appearing in a row is a flop.
var chain = Promise.resolve();
(e.cards || []).forEach(function (c) {
(e.cards || []).forEach(function (c, i) {
chain = chain.then(function () {
boardEl.appendChild(Cards.el(c, { deal: true, tilt: false }));
FX.sfx("card", { v: i });
return wait(240);
});
});
@@ -478,12 +483,15 @@
});
if (busted) {
show("You're out of chips. Sit down again when you're ready.", "lose");
FX.sfx("lose");
return;
}
if (!events.some(function (e) { return e.kind === "end"; })) return;
var me = final.seats[0];
if (won > 0) {
// The pot coming your way already burst (and so already cheered) back in
// the "win" event. This is only the words.
show(showed
? "You win " + money(won) + " with " + article(handName(events)) + "."
: "They folded. You take " + money(won) + ".", "win");
@@ -491,6 +499,7 @@
show("Folded.", "lose");
} else {
show("No good this time.", "lose");
FX.sfx("lose");
}
function show(text, tone) {

View File

@@ -10,7 +10,7 @@
(function () {
// The localStorage keys we sync. The weather *cache* is deliberately excluded:
// it's transient and per-device.
var SYNCED = ["pete.disabledSources.v1", "pete.weather.loc.v1", "pete-weather-off"];
var SYNCED = ["pete.disabledSources.v1", "pete.weather.loc.v1", "pete-weather-off", "pete.sfx.off"];
var user = window.PETE_USER || null;
var serverPrefs = window.PETE_PREFS || null;

View File

@@ -396,6 +396,36 @@
});
}
// noises is the board's soundtrack, walked off the *same* STEP_MS ladder the
// animation is walked off. That matters more than which sound goes where: a
// card you hear land a step before it lands is worse than a card that lands in
// silence, so this counts its way through the script exactly as planOf and
// flashHome do, and any change to their timing has to be made here too.
function noises(events) {
var at = 0;
(events || []).forEach(function (e) {
switch (e.kind) {
case "draw":
FX.sfx("card", { delay: at / 1000 });
break;
case "recycle":
FX.sfx("shuffle", { delay: at / 1000 });
break;
case "move":
FX.sfx("deal", { delay: (at + MOVE_MS) / 1000 });
at += STEP_MS;
break;
case "home":
// A card reaching a foundation is the only move that pays, so it is the
// only one that gets a note on the end of it.
FX.sfx("deal", { delay: (at + MOVE_MS) / 1000 });
FX.sfx("blip", { delay: (at + MOVE_MS + 60) / 1000 });
at += STEP_MS;
break;
}
});
}
// ---- the money -------------------------------------------------------------
// bank moves the spot to what the server says the board is worth. Up is chips
@@ -548,6 +578,9 @@
// Clearing 52 cards out of a Vegas deal is the rarest thing that happens in
// this room, so it's the one that gets the confetti.
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 40 });
else if (v.net > 0) FX.sfx("win");
else if (v.net < 0) FX.sfx("lose");
else FX.sfx("push");
}
// play walks a server response onto the felt: the board is re-rendered, the
@@ -570,6 +603,7 @@
render(v);
var plan = planOf(events);
flashHome(events);
noises(events);
return animate(before, plan);
})
.then(function () {

View File

@@ -372,10 +372,16 @@
renderQuestion(final);
renderLadder(final);
if (final && final.phase === "playing") startClock(final.left, final.limit);
FX.sfx("blip");
return;
case "right":
reveal(e.choice, e.correct);
// Three notes up, one per rung climbed — the ladder, in sound.
[0, 0.09, 0.18].forEach(function (d, i) {
FX.sfx("tick", { delay: d, v: i });
});
FX.sfx("win", { delay: 0.1 });
if (final) {
// The rung lighting and the multiple climbing are one event,
// because they are one event: this is what the answer was worth.
@@ -387,10 +393,13 @@
case "wrong":
reveal(e.choice, e.correct);
FX.sfx("bad");
return wait(1100);
case "timeout":
// The clock beat you to it, which is a different kind of bad.
reveal(-1, e.correct);
FX.sfx("lose");
return wait(1100);
case "settle":

View File

@@ -39,6 +39,9 @@
var tableEl = root.querySelector("[data-table-name]");
var wildEl = root.querySelector("[data-wild]");
var gateEl = root.querySelector("[data-unogate]");
var callBtn = root.querySelector("[data-uno-call]");
var timerEl = root.querySelector("[data-uno-timer]");
// 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]");
@@ -72,6 +75,14 @@
var played = -1; // the card you just played, so the script lifts that one out
// of the hand and not merely the first one that lit up
// The UNO call. CALL_MS is how long you get to say it once the card is on its
// way down — long enough that nobody playing the game misses it, short enough
// that it is still a thing you have to *do*. The engine has the other half of
// this rule (see call.go); this is only the window.
var CALL_MS = 2600;
var gate = null; // the play waiting on a call: {index, color}
var gateTimer = 0;
var reduced = FX.reduced;
function pace(ms) { return reduced ? 0 : ms; }
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
@@ -207,6 +218,9 @@
function renderSeats(v) {
seatsEl.innerHTML = "";
if (!v) return;
var catchable = {};
(v.catchable || []).forEach(function (i) { catchable[i] = true; });
v.seats.forEach(function (s, i) {
if (s.you) return; // you are the hand at the bottom, not a seat up here
var el = document.createElement("div");
@@ -216,6 +230,24 @@
if (s.uno) el.dataset.uno = "1";
if (s.out) el.dataset.out = "1"; // No Mercy buried them; they are not coming back
// One card, and it never said so. This button is the only thing on the felt
// that gives it away — a bot that forgets to call is silent by definition,
// and the tell was always meant to be the count beside its fan. The button
// is the table doing you the courtesy of putting a target on it.
if (catchable[i]) {
var got = document.createElement("button");
got.type = "button";
got.className = "pete-uno-catch";
got.dataset.catch = String(i);
got.textContent = "Catch!";
got.setAttribute("aria-label", "Catch " + s.name + " — one card, never called");
got.addEventListener("click", function (e) {
e.stopPropagation();
catchSeat(i);
});
el.appendChild(got);
}
var fan = document.createElement("div");
fan.className = "pete-uno-fan";
var show = Math.min(s.cards, FAN);
@@ -252,23 +284,65 @@
return el ? el.querySelector(".pete-uno-fan") : deckEl;
}
function renderHand(v) {
handEl.innerHTML = "";
if (!v || !v.hand) return;
var playable = {};
(v.playable || []).forEach(function (i) { playable[i] = true; });
var yours = v.turn === 0 && v.phase !== "done";
// shownHand is the faces currently on screen. paintHand diffs against it so that
// only cards that are actually new fan in — without it, every redraw re-deals
// the whole hand in front of you, and a hand gets redrawn several times a lap.
var shownHand = [];
v.hand.forEach(function (c, i) {
function key(c) { return c.color + "/" + c.value; }
// paintHand draws your fan. `live` is whether the cards are yours to click,
// which mid-script they are not: the game on screen is one the server has
// already moved past, and a card clicked during the lap would be a move against
// a board that no longer exists.
function paintHand(cards, playable, live) {
cards = cards || [];
// What was already on the table stays put. Matched by face and consumed, so a
// hand holding two red fives doesn't count one of them twice.
var pool = shownHand.map(key);
handEl.innerHTML = "";
var fresh = 0;
cards.forEach(function (c, i) {
var el = card(c, { button: true });
el.style.setProperty("--i", i);
el.dataset.at = String(i);
var ok = yours && playable[i];
var ok = !!live && !!playable[i];
el.dataset.on = ok ? "1" : "0";
el.disabled = !ok || busy;
if (ok) el.addEventListener("click", function () { pick(i, c); });
var was = pool.indexOf(key(c));
if (was >= 0) {
pool.splice(was, 1);
el.dataset.settled = "1"; // it was already in your hand: don't deal it again
} else {
el.style.setProperty("--i", fresh++);
}
handEl.appendChild(el);
});
shownHand = cards.slice();
}
function renderHand(v) {
if (!v || !v.hand) { handEl.innerHTML = ""; shownHand = []; return; }
var playable = {};
(v.playable || []).forEach(function (i) { playable[i] = true; });
paintHand(v.hand, playable, v.turn === 0 && v.phase !== "done");
}
// showHand redraws your fan from the hand the server stamped on the event that
// just changed it — see Event.Hand in the engine.
//
// This is the fix for a hand that used to sit there stale for the whole lap.
// bump() keeps the *bots'* fans honest and has always refused seat zero, so
// when a +4 landed on you at the top of a lap you watched four backs fly into
// your hand and then nothing: the cards themselves only turned up seconds
// later, when the script ended and paint() finally ran. You were looking at a
// hand you no longer held.
function showHand(cards) {
if (!cards) return;
paintHand(cards, {}, false);
// And the line above the fan counts what's in it, so it moves too.
countEl.textContent = cards.length + (cards.length === 1 ? " card — UNO!" : " cards");
}
function renderPiles(v) {
@@ -357,7 +431,10 @@
else if (v.net < 0) text += " " + v.net.toLocaleString();
verdictEl.textContent = text;
verdictEl.classList.remove("hidden");
// burst() is the room's win sound as well as its confetti; anything else is
// a night that didn't go your way.
if (v.outcome === "won") FX.burst(verdictEl, { count: 34 });
else FX.sfx("lose");
}
// controls is the one place that decides what you can click: whose turn the
@@ -373,6 +450,11 @@
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
b.disabled = busy || !yours || b.dataset.on !== "1";
});
// Catching is free, but it is still a move: it can't go up while one of yours
// is in flight, and it can't go up on somebody else's turn.
seatsEl.querySelectorAll("[data-catch]").forEach(function (b) {
b.disabled = busy || !yours;
});
// 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;
@@ -398,6 +480,7 @@
betting.classList.toggle("hidden", live);
playing.classList.toggle("hidden", !live);
hideWild();
hideGate();
if (drawBtn) drawBtn.classList.toggle("hidden", drawn || stack);
if (takeBtn) takeBtn.classList.toggle("hidden", !stack);
@@ -406,7 +489,13 @@
// 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 (hintEl && live) {
// A seat sitting on one card it never called is the most valuable thing on
// the table, and it is worth more than whatever the phase was going to say.
hintEl.textContent = (v.catchable || []).length
? "Somebody's down to one card and never said so. Catch them before you play."
: (HINTS[v.phase] || HINTS.play);
}
if (!live && v) rulesFor(v); // it's over: leave the panel where the game was
controls();
if (!v || !v.outcome) verdictEl.classList.add("hidden");
@@ -426,7 +515,9 @@
// ---- the script ------------------------------------------------------------
// throwCard sends a card from one place to another across the felt.
// throwCard sends a card from one place to another across the felt. It lands
// with a slap — "card" for one going down on the pile, "deal" (softer, lower)
// for one coming off the deck into a hand, which is a card being *placed*.
function throwCard(node, from, to, opts) {
opts = opts || {};
return FX.flyNode(node, from, to, {
@@ -435,12 +526,17 @@
spin: opts.spin == null ? FX.jitter((opts.index || 0) + 3, 14) : opts.spin,
fromScale: opts.fromScale == null ? 0.9 : opts.fromScale,
delay: opts.delay || 0,
index: opts.index || 0,
sound: opts.sound === undefined ? "card" : opts.sound,
});
}
// bump keeps a bot's fan honest during the script: the server's count is the
// truth, but between events the fan has to grow and shrink as cards move, or
// the flight lands on a pile that hasn't changed.
//
// Seat zero is not a bot and is not bumped — a fan of backs is no use to you,
// you want the faces. showHand does yours, from the hand the event carries.
function bump(seat, left) {
if (seat === 0 || left == null) return;
var el = seatEl(seat);
@@ -499,6 +595,10 @@
// The deal isn't animated card by card: seven cards to each of four
// seats is 28 flights and a player who wants to play. The hand fans in
// on its own (CSS), which reads as being dealt without taking as long.
// The sound does the same job: a riffle, then cards landing, rather
// than twenty-eight separate slaps.
FX.sfx("shuffle");
for (var c = 0; c < 5; c++) FX.sfx("deal", { delay: 0.28 + c * 0.06, v: c });
paint(final);
return wait(320);
@@ -525,6 +625,7 @@
colourEl.dataset.c = e.color;
feltEl.dataset.c = e.color;
}
showHand(e.hand); // the card has landed; close the gap it left
return wait(e.seat === 0 ? 120 : 300);
});
}
@@ -535,14 +636,16 @@
var to = seatAnchor(e.seat);
var backs = [];
for (var i = 0; i < Math.min(e.n, 4); i++) {
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90 }));
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90, sound: "deal" }));
}
var punished = e.kind === "forced";
if (punished) FX.sfx("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 () {
bump(e.seat, e.left);
showHand(e.hand); // your cards, face up, as they land
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
// Your own drawn card comes face up — it's yours, and the server
// sent its face for exactly this.
@@ -551,12 +654,58 @@
});
}
// ---- the call, and the catch ---------------------------------------
//
// Somebody went down to one card without saying so, and somebody else
// noticed. Two cards off the deck, and the seat that took them wears the
// name of whoever spotted it — because "caught" with nobody attached is
// just a mystery.
case "caught": {
spotlight(e.seat);
var mine = e.seat === 0;
var caughtBy = (game && game.seats && game.seats[e.by]) || {};
var whom = mine
? "Caught! " + (caughtBy.name || "They") + " saw it"
: "Caught! +" + e.n;
// Catching a bot is a pointed finger; being caught is a thud.
FX.sfx(mine ? "bad" : "catch");
var lands = [];
var at = seatAnchor(e.seat);
for (var k = 0; k < e.n; k++) {
lands.push(throwCard(back(), deckEl, at, { index: k, delay: k * 100, sound: "deal" }));
}
badge(e.seat, whom, "bad");
return Promise.all(lands).then(function () {
bump(e.seat, e.left);
showHand(e.hand);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
return wait(520);
});
}
// You called a seat that had nothing to hide. Two cards, and they're
// yours: the price of the catch button not being a thing you just mash.
case "miscall": {
FX.sfx("bad");
var wrong = [];
for (var w = 0; w < e.n; w++) {
wrong.push(throwCard(back(), deckEl, handEl, { index: w, delay: w * 100, sound: "deal" }));
}
badge(0, "They called it. +" + e.n, "bad");
return Promise.all(wrong).then(function () {
showHand(e.hand);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
return wait(520);
});
}
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);
FX.sfx("bad");
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
case "skipall":
@@ -580,7 +729,10 @@
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); });
return Promise.all(dumped).then(function () {
showHand(e.hand);
return wait(220);
});
}
case "roulette": {
@@ -595,6 +747,7 @@
badge(e.seat, "Roulette +" + e.n, "bad");
return Promise.all(spun).then(function () {
bump(e.seat, e.left);
showHand(e.hand);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
return wait(400);
});
@@ -606,7 +759,9 @@
var grave = seatEl(e.seat);
if (grave) grave.dataset.out = "1";
bump(e.seat, 0);
showHand(e.hand); // if it was you, the hand goes with you
pending(0); // a dead seat pays no bill and leaves none behind
FX.sfx("bad");
return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); });
}
@@ -618,6 +773,7 @@
return badge(e.seat, "Reverse").then(function () { return wait(60); });
case "uno":
FX.sfx("uno");
return badge(e.seat, "UNO!", "uno");
case "reshuffle":
@@ -626,6 +782,7 @@
// and counting down from a deck that still reads empty gets nowhere.
deckCntEl.textContent = String(e.n);
deckEl.classList.add("pete-uno-shuffle");
FX.sfx("shuffle");
return wait(420).then(function () {
deckEl.classList.remove("pete-uno-shuffle");
});
@@ -699,8 +856,72 @@
function pick(i, c) {
if (busy || !game || game.phase === "done" || game.turn !== 0) return;
if (c.wild) { askColour(i); return; }
move({ kind: "play", index: i });
if (c.wild) { askColour(i); return; } // the colour first; the call comes after
offer(i, 0);
}
// offer is the last thing between a card and the pile. If playing it leaves you
// holding one card, the table wants a word out of you first.
//
// Which cards those are is the server's answer, not a count of your hand: No
// Mercy's "discard all" takes every card of its colour with it, so a six-card
// hand can land on one, and a browser subtracting one from six would let you
// walk into a catch it never warned you about.
function offer(i, color) {
if (game && (game.uno_at || []).indexOf(i) >= 0) {
openGate(i, color);
return;
}
move({ kind: "play", index: i, color: color });
}
function openGate(i, color) {
gate = { index: i, color: color };
gateEl.classList.remove("hidden");
if (timerEl) {
// Restart the drain. The bar is a CSS animation and this element gets
// reused, so without kicking the browser between the two assignments it
// stays where the last one finished — a full bar that never moves, which
// is worse than no bar at all.
timerEl.style.animation = "none";
void timerEl.offsetWidth;
timerEl.style.animation = "";
timerEl.style.animationDuration = CALL_MS + "ms";
}
if (callBtn) callBtn.focus();
gateTimer = setTimeout(function () { shut(false); }, CALL_MS);
}
// shut plays the card the gate was holding, with or without the word. Letting
// the clock run out is a real answer — it is the answer that gets you caught.
function shut(called) {
if (!gate) return;
var g = gate;
gate = null;
clearTimeout(gateTimer);
gateTimer = 0;
gateEl.classList.add("hidden");
move({ kind: "play", index: g.index, color: g.color, uno: called });
}
// hideGate drops the gate without playing anything. Only for the paths that tear
// the board down under it (an error, a resume) — a gate that outlived its game
// would play a card into the next one.
function hideGate() {
if (gateTimer) clearTimeout(gateTimer);
gateTimer = 0;
gate = null;
if (gateEl) gateEl.classList.add("hidden");
}
if (callBtn) callBtn.addEventListener("click", function () { shut(true); });
// Catching a seat that went quiet. It is not a turn — right or wrong, the turn
// stays with you — but it is a move, so it goes through the same door as one.
function catchSeat(seat) {
if (busy || !game || game.phase === "done" || game.turn !== 0) return;
if ((game.catchable || []).indexOf(seat) < 0) return;
move({ kind: "catch", seat: seat });
}
function askColour(i) {
@@ -724,7 +945,7 @@
var i = pendingWild;
var c = COLOURS[b.dataset.colourPick];
hideWild();
move({ kind: "play", index: i, color: c });
offer(i, c); // named the colour; now, is it your last but one?
});
});

View File

@@ -37,6 +37,25 @@
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Cash out
</button>
<!-- The room's volume. It lives in the chip bar because the chip bar is the
one thing every table has, and the sound is the room's rather than any
one game's. -->
<button type="button" data-sfx-toggle aria-pressed="false"
class="grid h-10 w-10 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/5 active:translate-y-px transition">
<svg data-sfx-on viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path>
</svg>
<svg data-sfx-off viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="hidden h-5 w-5" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<line x1="23" y1="9" x2="17" y2="15"></line>
<line x1="17" y1="9" x2="23" y2="15"></line>
</svg>
<span data-sfx-label class="sr-only">Mute the room</span>
</button>
</div>
</div>

View File

@@ -73,6 +73,11 @@
Play for what you can lose. Cash out whenever you like.
</footer>
<!-- The room's noise. Loaded here rather than per-table because every table
wants it and none of them owns it, and because it has to be defined before
casino-fx.js — which reaches for it on every chip it throws. `defer` runs
these in document order, so that's guaranteed. -->
<script src="/static/js/casino-sfx.js" defer></script>
{{block "scripts" .}}{{end}}
</body>
</html>{{end}}

View File

@@ -104,6 +104,22 @@
<button type="button" data-colour-cancel class="mt-3 text-xs font-semibold text-white/50 hover:text-white/80 transition">Play something else</button>
</div>
</div>
<!-- Calling UNO. The card is already on its way down — this is the word you
owe the table for it, and the bar underneath is how long you've got.
Let it run out and the bots take you for two.
There is no cancel here, and there doesn't need to be: clicking a card
has always played it on this table, so a gate that makes you press one
more thing is strictly more warning than you used to get. -->
<div data-unogate class="pete-uno-wild hidden" role="dialog" aria-label="Call UNO">
<div class="pete-uno-wild-box">
<p class="font-display text-lg font-bold text-white">Down to one card</p>
<button type="button" data-uno-call class="pete-uno-call">UNO!</button>
<div class="pete-uno-timer" aria-hidden="true"><span data-uno-timer></span></div>
<p class="mt-2 text-xs text-white/50">Say it, or they'll catch you for two.</p>
</div>
</div>
</section>
<!-- Playing: shown while a game is live. -->