Compare commits

...

4 Commits

Author SHA1 Message Date
prosolis
a5b7e41929 games: the card the dealer never turned over, and the bet that came back doubled
Bust every hand and the dealer doesn't draw, which is right, but it was also
not turning over: reveal is only emitted by dealerPlay, and busting out skips
the dealer entirely. The browser kept the hole card face down while the settled
state printed the dealer's whole total under it. Emit the reveal on that path.

And standing your bet back up after a reload read the hand's bet straight off
the settled state, which a double has already doubled. Reload, double 200, and
the next hand starts with 400 on the spot.

Plus: the share card was hand-writing a Content-Length that ServeContent
overwrites anyway, and serving a zero-byte 200 for a room with no card.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 14:22:26 -07:00
prosolis
57c445ff29 games: the plan catches up with the door and the split
The handover was still saying "only blackjack is live", which was wrong two
sessions ago and is wronger now. It records what is actually on the box (03524ae)
and what is waiting on it: the UNO redraw fix, the front door and share card, and
blackjack's split. The user has seen that list and is holding the deploy, so the
next session should ask rather than assume.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 13:55:56 -07:00
prosolis
6f34a89622 games: the hand that becomes two, and the bet that has to follow it
Blackjack has a split. It was the last rule missing from a game that has been
live for a week, and it is the only move in blackjack that takes chips out of
your stack *after* the cards are out — which is most of what there is to get
wrong about it.

So the state stops pretending. State.Player is gone; there is a slice of Hands,
each with its own cards, its own bet, its own outcome and its own payout, and an
Active index the player works left to right. Settle runs per hand and rakes per
hand: netting them against each other first would mean a player who won one and
lost one paid no rake at all, which is not a rake, it's a discount for
splitting. The web layer takes the second bet before the move and hands it
straight back if the engine refuses — the same shape double already used, except
double was staking st.Bet, the whole table's stake, which was the same number as
the hand's until today and is now emphatically not. DoubleCost/SplitCost are the
active hand's, and the felt would have found this by charging you 300 to double
the third hand of a split.

The rules that cost money if you guess them: split aces get one card each and no
say (a pair of aces is otherwise the best hand in the game, forever), 21 on a
split hand is twenty-one and not a natural (it does not pay 3:2 — the test that
pins this is the most expensive one in the file), same rank rather than same
value (a king and a queen are not a pair), four hands maximum, double after
split allowed, and if every hand busts the dealer does not turn over.

A live hand outlives a deploy, so State.UnmarshalJSON still reads the old blobs:
"player" with no "hands" becomes one hand holding the whole stake. Without it, a
player mid-hand at restart is a player whose cards vanished — which is not a
decode error, and would not have looked like one.

On the felt a hand is now a box with its own spot, and a split is a card lifting
out of one hand into a new one with a second stack of chips flying after it from
your pile. Verified in a browser against a real pair: chips 4738 -> 4638 on the
split, two hands played out, one push and one loss, "Down on the deal. -100",
4738 back. Three hands stack without collision at 390px. Settled hands come back
to full brightness — dimming means "not your turn", and when the deal is over
they are the thing you are reading.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 13:54:55 -07:00
prosolis
7ca1f7a030 games: the door you can see from outside, and the picture on it
We never had Open Graph on the casino, and adding meta tags would not have
fixed it. Every route was behind requirePlayer, so a link pasted into a chat
window got a 302 to sign-in and unfurled as whatever the auth screen said:
"parodia.dev", no image, no description. Tags on a page a stranger cannot
fetch are tags nobody reads. So the casino now has a front door — a real page,
served to anybody, that says what the place is and offers a way in. You still
can't play from it, and every table still bounces you to sign-in.

The share card is drawn in Go rather than checked in as a picture, because the
casino has two names on a clock and the card keeps the joke: paste the link in
daylight and you get Casinopolis on green felt, paste it after six and the neon
is on and it says Casino Night Zone. Same roomAt() rule as everywhere else,
except the clock that decides is the server's — an unfurl bot has no evening of
its own. Both cards are drawn once, at first ask, and kept.

Two things worth keeping from building it. color.RGBA is alpha-premultiplied,
and the lamp over the table wrote raw channels next to a low alpha, which is
not a dim glow but an invalid colour: image/draw ran it past 255 and wrapped
the hue, and the first card came out with a blue dome over a green stripe. If
a colour here ever comes out impossible, look for a missing premultiply. And
og:image has to be an absolute URL that actually resolves, which is two
different addresses depending on how you arrived: /og.png on the games host
(hostRouter puts the /games back on) and /games/og.png anywhere else. The dev
rig advertised the first while serving only the second. The test now reads the
URL off the page and goes and fetches it, on both hosts, because an og:image
that 404s is worth exactly as much as no og:image.

Fredoka is vendored (OFL) — the page can reach for a font over the network and
a server drawing a PNG cannot.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 13:30:52 -07:00
18 changed files with 1981 additions and 243 deletions

View File

@@ -15,6 +15,7 @@
package blackjack package blackjack
import ( import (
"encoding/json"
"errors" "errors"
"math" "math"
"math/rand/v2" "math/rand/v2"
@@ -29,15 +30,21 @@ var (
ErrNotYourTurn = errors.New("blackjack: it is not the player's turn to act") ErrNotYourTurn = errors.New("blackjack: it is not the player's turn to act")
ErrUnknownMove = errors.New("blackjack: unknown move") ErrUnknownMove = errors.New("blackjack: unknown move")
ErrCantDouble = errors.New("blackjack: double is only allowed on the opening two cards") ErrCantDouble = errors.New("blackjack: double is only allowed on the opening two cards")
ErrCantSplit = errors.New("blackjack: split is only allowed on two cards of the same rank")
ErrDeckExhausted = errors.New("blackjack: the shoe is empty") ErrDeckExhausted = errors.New("blackjack: the shoe is empty")
ErrBadBet = errors.New("blackjack: bet must be positive") ErrBadBet = errors.New("blackjack: bet must be positive")
) )
// MaxHands is how many hands one deal can turn into: the opening hand, plus
// three splits. Four is the usual house limit and it is also the point past
// which the felt runs out of room.
const MaxHands = 4
// Phase is whose turn it is. // Phase is whose turn it is.
type Phase string type Phase string
const ( const (
PhasePlayer Phase = "player" // the player is acting PhasePlayer Phase = "player" // the player is acting on the active hand
PhaseDealer Phase = "dealer" // transient: the dealer is drawing out PhaseDealer Phase = "dealer" // transient: the dealer is drawing out
PhaseDone Phase = "done" // settled, Outcome and Payout are final PhaseDone Phase = "done" // settled, Outcome and Payout are final
) )
@@ -72,37 +79,77 @@ type Rules struct {
// DefaultRules match the blackjack gogobee has been dealing in Matrix for years: // DefaultRules match the blackjack gogobee has been dealing in Matrix for years:
// six decks, 3:2 on a natural, dealer hits soft 17. The rake is the one new term // six decks, 3:2 on a natural, dealer hits soft 17. The rake is the one new term
// — see Settle for exactly what it touches. // — see settle for exactly what it touches.
func DefaultRules() Rules { func DefaultRules() Rules {
return Rules{Decks: 6, BlackjackPays: 1.5, DealerHitsSoft17: true, RakePct: 0.05} return Rules{Decks: 6, BlackjackPays: 1.5, DealerHitsSoft17: true, RakePct: 0.05}
} }
// State is one hand of heads-up blackjack: one player, one dealer. Splitting // Hand is one hand the player is holding, with the chips that are on it.
// isn't in v1, so there's exactly one player hand. //
// A deal starts with one. A split turns one into two, and the new hand carries a
// bet of its own — which is the whole reason split is not just a card trick: it
// is the only move in the game that takes more chips out of a player's stack
// *after* the cards are out.
type Hand struct {
Cards []cards.Card `json:"cards"`
Bet int64 `json:"bet"`
Doubled bool `json:"doubled"`
// Split marks a hand that came out of a split. It exists for one rule: 21 on
// a split hand is twenty-one, not a natural, and is paid 1:1 like any other.
// Otherwise splitting aces would print money.
Split bool `json:"split"`
// Done means this hand will not be acted on again: it stood, it busted, it
// doubled, or it is a split ace, which gets exactly one card and no say.
Done bool `json:"done"`
Outcome Outcome `json:"outcome"`
Payout int64 `json:"payout"` // stake plus winnings, net of rake. Zero on a loss.
Rake int64 `json:"rake"`
}
// Value totals the hand. See HandValue.
func (h Hand) Value() (int, bool) { return HandValue(h.Cards) }
// Natural reports a blackjack: 21 on the opening two cards of a hand that was
// dealt, not split.
func (h Hand) Natural() bool { return !h.Split && IsBlackjack(h.Cards) }
// State is one deal of heads-up blackjack: the player's hands against the
// dealer's one.
type State struct { type State struct {
Rules Rules `json:"rules"` Rules Rules `json:"rules"`
Deck cards.Deck `json:"deck"` // the shoe, top card first — never shown to the browser Deck cards.Deck `json:"deck"` // the shoe, top card first — never shown to the browser
Player []cards.Card `json:"player"`
Dealer []cards.Card `json:"dealer"` Dealer []cards.Card `json:"dealer"`
Bet int64 `json:"bet"` // chips at risk; doubles on a double-down // Hands is always at least one, and Active indexes the one being played. The
Doubled bool `json:"doubled"` // player works left to right: a hand is finished before the next is looked at,
// which is both how a real table does it and what keeps the felt legible.
Hands []Hand `json:"hands"`
Active int `json:"active"`
Phase Phase `json:"phase"` Phase Phase `json:"phase"`
Outcome Outcome `json:"outcome"` Outcome Outcome `json:"outcome"` // the deal as a whole; per-hand outcomes live on the hands
// Payout is what returns to the player's chip stack when the hand is done: // Bet, Payout and Rake are the totals across every hand: what the player has
// stake plus winnings, net of rake. Zero on a loss. Rake is the house's cut, // staked, what comes back, and what the house kept. The ledger and the chip
// recorded so the ledger can account for every chip that left the table. // stack only ever deal in these.
Bet int64 `json:"bet"`
Payout int64 `json:"payout"` Payout int64 `json:"payout"`
Rake int64 `json:"rake"` Rake int64 `json:"rake"`
} }
// Event is something the table can narrate or animate. The engine emits them // Event is something the table can narrate or animate. The engine emits them
// instead of drawing anything itself. // instead of drawing anything itself.
//
// Hand is which of the player's hands an event landed on — meaningless for the
// dealer's, and the reason it is here at all is that after a split the browser
// has to know which fan a card is flying to.
type Event struct { type Event struct {
Kind string `json:"kind"` // "deal" | "player_card" | "dealer_card" | "reveal" | "settle" Kind string `json:"kind"` // "deal" | "player_card" | "dealer_card" | "split" | "double" | "reveal" | "settle"
Card *cards.Card `json:"card,omitempty"` Card *cards.Card `json:"card,omitempty"`
Hand int `json:"hand"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
} }
@@ -113,6 +160,7 @@ const (
Hit Move = "hit" Hit Move = "hit"
Stand Move = "stand" Stand Move = "stand"
Double Move = "double" Double Move = "double"
Split Move = "split"
) )
// HandValue totals a hand, counting each ace as 11 until that would bust, then // HandValue totals a hand, counting each ace as 11 until that would bust, then
@@ -161,36 +209,52 @@ func New(bet int64, r Rules, rng *rand.Rand) (State, []Event, error) {
deck := cards.NewDeck(r.Decks) deck := cards.NewDeck(r.Decks)
deck.Shuffle(rng) deck.Shuffle(rng)
s := State{Rules: r, Deck: deck, Bet: bet, Phase: PhasePlayer} s := State{
Rules: r,
Deck: deck,
Hands: []Hand{{Bet: bet}},
Bet: bet,
Phase: PhasePlayer,
}
evs := []Event{{Kind: "deal"}} evs := []Event{{Kind: "deal"}}
for i := 0; i < 2; i++ { for i := 0; i < 2; i++ {
if err := s.draw(&s.Player, "player_card", &evs); err != nil { if err := s.hit(0, &evs); err != nil {
return State{}, nil, err return State{}, nil, err
} }
if err := s.draw(&s.Dealer, "dealer_card", &evs); err != nil { if err := s.drawDealer(&evs); err != nil {
return State{}, nil, err return State{}, nil, err
} }
} }
// A natural on either side ends it before the player ever acts. // A natural on either side ends it before the player ever acts.
if IsBlackjack(s.Player) || IsBlackjack(s.Dealer) { if s.Hands[0].Natural() || IsBlackjack(s.Dealer) {
s.settle(&evs) s.settle(&evs)
} }
return s, evs, nil return s, evs, nil
} }
// draw takes one card off the shoe onto the given hand and records the event. // hit puts one card on a player hand. Pointer receiver: it mutates the deck and
// Pointer receiver: it mutates the deck and the hand together, and neither may // the hand together, and neither may end up applied to a stale copy of the state.
// end up applied to a stale copy of the state. func (s *State) hit(i int, evs *[]Event) error {
func (s *State) draw(hand *[]cards.Card, kind string, evs *[]Event) error {
c, ok := s.Deck.Draw() c, ok := s.Deck.Draw()
if !ok { if !ok {
return ErrDeckExhausted return ErrDeckExhausted
} }
*hand = append(*hand, c) s.Hands[i].Cards = append(s.Hands[i].Cards, c)
card := c card := c
*evs = append(*evs, Event{Kind: kind, Card: &card}) *evs = append(*evs, Event{Kind: "player_card", Card: &card, Hand: i})
return nil
}
func (s *State) drawDealer(evs *[]Event) error {
c, ok := s.Deck.Draw()
if !ok {
return ErrDeckExhausted
}
s.Dealer = append(s.Dealer, c)
card := c
*evs = append(*evs, Event{Kind: "dealer_card", Card: &card})
return nil return nil
} }
@@ -210,41 +274,127 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
if s.Phase != PhasePlayer { if s.Phase != PhasePlayer {
return s, nil, ErrNotYourTurn return s, nil, ErrNotYourTurn
} }
if m == Double && len(s.Player) != 2 { switch m {
case Hit, Stand, Double, Split:
default:
return s, nil, ErrUnknownMove
}
if m == Double && !s.CanDouble() {
// Doubling means doubling the stake for exactly one more card. Only ever // Doubling means doubling the stake for exactly one more card. Only ever
// legal on the opening two — after that you're just describing a hit. // legal on the opening two — after that you're just describing a hit.
return s, nil, ErrCantDouble return s, nil, ErrCantDouble
} }
if m != Hit && m != Stand && m != Double { if m == Split && !s.CanSplit() {
return s, nil, ErrUnknownMove return s, nil, ErrCantSplit
} }
i := s.Active
evs := []Event{} evs := []Event{}
if m == Double { switch m {
s.Bet *= 2 case Split:
s.Doubled = true // The second card moves to a hand of its own, carrying a bet the same size
} // as the one it came from, and both hands are topped up to two cards.
h := &s.Hands[i]
moved := h.Cards[1]
h.Cards = h.Cards[:1]
h.Split = true
if m == Hit || m == Double { fresh := Hand{Cards: []cards.Card{moved}, Bet: h.Bet, Split: true}
if err := s.draw(&s.Player, "player_card", &evs); err != nil { s.Hands = append(s.Hands, Hand{})
copy(s.Hands[i+2:], s.Hands[i+1:]) // the new hand sits immediately to the right
s.Hands[i+1] = fresh
s.Bet += fresh.Bet
evs = append(evs, Event{Kind: "split", Hand: i})
if err := s.hit(i, &evs); err != nil {
return s, nil, err return s, nil, err
} }
if v, _ := HandValue(s.Player); v > 21 { if err := s.hit(i+1, &evs); err != nil {
s.settle(&evs) // bust; the dealer never has to play return s, nil, err
return s, evs, nil
} }
if m == Hit {
return s, evs, nil // still the player's turn // Split aces get one card each and no say in it. Without this rule a pair
// of aces is the best hand in the game and everybody would split them
// forever; with it, splitting aces is a gamble like everything else.
if moved.Rank == cards.Ace {
s.Hands[i].Done = true
s.Hands[i+1].Done = true
} }
// A hand that has just been dealt a card can still be sitting on 21, and a
// 21 has nothing left to decide.
s.finishIfDone(i)
s.finishIfDone(i + 1)
case Double:
h := &s.Hands[i]
s.Bet += h.Bet
h.Bet *= 2
h.Doubled = true
// Announced before the card, because that is the order it happens in: the
// chips go down, and *then* you find out what you bought with them.
evs = append(evs, Event{Kind: "double", Hand: i})
if err := s.hit(i, &evs); err != nil {
return s, nil, err
}
h.Done = true // one card, and that is the deal you made
case Hit:
if err := s.hit(i, &evs); err != nil {
return s, nil, err
}
s.finishIfDone(i)
case Stand:
s.Hands[i].Done = true
} }
// Stand, or a double that survived its card: the dealer draws out. s.advance(&evs)
s.Phase = PhaseDealer
s.dealerPlay(&evs)
return s, evs, nil return s, evs, nil
} }
// finishIfDone closes a hand that has nothing left to decide: it busted, or it
// is sitting on 21 and would only be hitting it to be polite.
func (s *State) finishIfDone(i int) {
if v, _ := s.Hands[i].Value(); v >= 21 {
s.Hands[i].Done = true
}
}
// advance moves to the next hand still owed a decision. When there are none, the
// dealer plays — unless every hand busted, in which case there is nothing to beat
// and the dealer does not draw.
func (s *State) advance(evs *[]Event) {
for i := s.Active; i < len(s.Hands); i++ {
if !s.Hands[i].Done {
s.Active = i
return
}
}
s.Active = len(s.Hands) - 1
if s.allBust() {
// The dealer draws no cards, but the hole card still turns over: the
// browser has been showing a face-down card since the deal, and the settled
// state it is about to be handed has the dealer's full total on it. Without
// the reveal you get a nineteen printed under a card nobody has looked at.
*evs = append(*evs, Event{Kind: "reveal"})
s.settle(evs)
return
}
s.Phase = PhaseDealer
s.dealerPlay(evs)
}
func (s *State) allBust() bool {
for _, h := range s.Hands {
if v, _ := h.Value(); v <= 21 {
return false
}
}
return true
}
// dealerPlay draws the dealer out to the house rule, then settles. The dealer // dealerPlay draws the dealer out to the house rule, then settles. The dealer
// has no choices to make — that's the game — so this needs no move. // has no choices to make — that's the game — so this needs no move.
func (s *State) dealerPlay(evs *[]Event) { func (s *State) dealerPlay(evs *[]Event) {
@@ -255,74 +405,104 @@ func (s *State) dealerPlay(evs *[]Event) {
if v >= 17 && !hitSoft17 { if v >= 17 && !hitSoft17 {
break break
} }
if err := s.draw(&s.Dealer, "dealer_card", evs); err != nil { if err := s.drawDealer(evs); err != nil {
break // shoe ran dry mid-draw; settle on what's on the table break // shoe ran dry mid-draw; settle on what's on the table
} }
} }
s.settle(evs) s.settle(evs)
} }
// settle decides the outcome and the payout, and is the only place chips are // settle decides every hand against the dealer and adds up what comes back. It
// computed. // is the only place chips are computed.
// //
// The rake comes off winnings, never off the stake: a player who pushes gets // The rake comes off winnings, never off the stake: a player who pushes gets
// exactly their bet back, and a player who loses is never charged for the // exactly their bet back, and a player who loses is never charged for the
// privilege. The house only takes a cut of money the house was going to hand // privilege. The house only takes a cut of money the house was going to hand
// over anyway. That's a rake, as opposed to a fee for showing up. // over anyway. That's a rake, as opposed to a fee for showing up.
//
// Each hand is raked on its own winnings. Netting the hands against each other
// first would let a player who won one and lost one pay no rake at all, which is
// not a rake, it's a discount for splitting.
func (s *State) settle(evs *[]Event) { func (s *State) settle(evs *[]Event) {
playerVal, _ := HandValue(s.Player)
dealerVal, _ := HandValue(s.Dealer) dealerVal, _ := HandValue(s.Dealer)
playerBJ := IsBlackjack(s.Player)
dealerBJ := IsBlackjack(s.Dealer) dealerBJ := IsBlackjack(s.Dealer)
// profit is what the player wins on top of their stake. Negative means the s.Payout, s.Rake = 0, 0
// stake is gone. for i := range s.Hands {
var profit int64 h := &s.Hands[i]
playerVal, _ := h.Value()
switch { // profit is what this hand wins on top of its stake. Negative means the
case playerVal > 21: // stake is gone.
s.Outcome = OutcomeBust var profit int64
profit = -s.Bet
case playerBJ && dealerBJ:
s.Outcome = OutcomePush
case playerBJ:
s.Outcome = OutcomeBlackjack
profit = int64(math.Floor(float64(s.Bet) * s.Rules.BlackjackPays))
case dealerBJ:
s.Outcome = OutcomeLose
profit = -s.Bet
case dealerVal > 21:
s.Outcome = OutcomeDealerBust
profit = s.Bet
case playerVal > dealerVal:
s.Outcome = OutcomeWin
profit = s.Bet
case playerVal == dealerVal:
s.Outcome = OutcomePush
default:
s.Outcome = OutcomeLose
profit = -s.Bet
}
if profit > 0 { switch {
s.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct)) case playerVal > 21:
if s.Rake < 0 { h.Outcome = OutcomeBust
s.Rake = 0 profit = -h.Bet
case h.Natural() && dealerBJ:
h.Outcome = OutcomePush
case h.Natural():
h.Outcome = OutcomeBlackjack
profit = int64(math.Floor(float64(h.Bet) * s.Rules.BlackjackPays))
case dealerBJ:
h.Outcome = OutcomeLose
profit = -h.Bet
case dealerVal > 21:
h.Outcome = OutcomeDealerBust
profit = h.Bet
case playerVal > dealerVal:
h.Outcome = OutcomeWin
profit = h.Bet
case playerVal == dealerVal:
h.Outcome = OutcomePush
default:
h.Outcome = OutcomeLose
profit = -h.Bet
} }
profit -= s.Rake
} if profit > 0 {
if profit < 0 { h.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct))
s.Payout = 0 // stake is lost; nothing comes back if h.Rake < 0 {
} else { h.Rake = 0
s.Payout = s.Bet + profit }
profit -= h.Rake
}
if profit < 0 {
h.Payout = 0 // stake is lost; nothing comes back
} else {
h.Payout = h.Bet + profit
}
s.Payout += h.Payout
s.Rake += h.Rake
} }
s.Outcome = s.overall()
s.Phase = PhaseDone s.Phase = PhaseDone
*evs = append(*evs, Event{Kind: "settle", Text: string(s.Outcome)}) *evs = append(*evs, Event{Kind: "settle", Text: string(s.Outcome)})
} }
// Net is what the hand did to the player's chip stack: payout minus the stake // overall is the deal's outcome as one word, which is what the ledger and the
// they put up. Negative on a loss, zero on a push. // history line want. With one hand it is simply that hand's. With several there
// is no honest single word for "won one, lost one", so it reports what the deal
// did to the player's chips, which is the thing anybody actually means.
func (s State) overall() Outcome {
if len(s.Hands) == 1 {
return s.Hands[0].Outcome
}
switch net := s.Payout - s.Bet; {
case net > 0:
return OutcomeWin
case net < 0:
return OutcomeLose
default:
return OutcomePush
}
}
// Net is what the deal did to the player's chip stack: everything paid out minus
// everything staked. Negative on a loss, zero on a push.
func (s State) Net() int64 { func (s State) Net() int64 {
if s.Phase != PhaseDone { if s.Phase != PhaseDone {
return 0 return 0
@@ -330,17 +510,85 @@ func (s State) Net() int64 {
return s.Payout - s.Bet return s.Payout - s.Bet
} }
// Hand returns the hand being played. There is always one.
func (s State) Hand() Hand {
if s.Active < 0 || s.Active >= len(s.Hands) {
return Hand{}
}
return s.Hands[s.Active]
}
// CanDouble reports whether Double is legal right now — the shell asks this to // CanDouble reports whether Double is legal right now — the shell asks this to
// decide whether to light the button up. // decide whether to light the button up.
func (s State) CanDouble() bool { func (s State) CanDouble() bool {
return s.Phase == PhasePlayer && len(s.Player) == 2 h := s.Hand()
return s.Phase == PhasePlayer && !h.Done && len(h.Cards) == 2
} }
// CanSplit reports whether Split is legal: two cards of the same rank, and room
// at the table for another hand.
//
// Same *rank*, not same value: a king and a queen are both worth ten and are not
// a pair, which is the stricter of the two house rules and the one that doesn't
// need explaining on the felt.
func (s State) CanSplit() bool {
h := s.Hand()
if s.Phase != PhasePlayer || h.Done || len(h.Cards) != 2 || len(s.Hands) >= MaxHands {
return false
}
return h.Cards[0].Rank == h.Cards[1].Rank
}
// SplitCost is what splitting the active hand would take out of the player's
// stack: another bet the same size as the one already on it. The shell has to
// take these chips before the move, because a split the player cannot cover is
// not a legal move.
func (s State) SplitCost() int64 { return s.Hand().Bet }
// DoubleCost is the same idea for a double: the stake again.
func (s State) DoubleCost() int64 { return s.Hand().Bet }
// clone deep-copies the slices so a derived state shares no backing array with // clone deep-copies the slices so a derived state shares no backing array with
// the one it came from. // the one it came from.
func (s State) clone() State { func (s State) clone() State {
s.Deck = append(cards.Deck(nil), s.Deck...) s.Deck = append(cards.Deck(nil), s.Deck...)
s.Player = append([]cards.Card(nil), s.Player...)
s.Dealer = append([]cards.Card(nil), s.Dealer...) s.Dealer = append([]cards.Card(nil), s.Dealer...)
hands := make([]Hand, len(s.Hands))
copy(hands, s.Hands)
for i := range hands {
hands[i].Cards = append([]cards.Card(nil), hands[i].Cards...)
}
s.Hands = hands
return s return s
} }
// UnmarshalJSON reads a state, including one written before split existed.
//
// A live hand outlives a deploy: it is a blob in the database, and somebody is
// mid-hand when the new binary starts. Those blobs have a "player" array and no
// "hands", and without this they would come back as a state with no hands at all
// — which is not a decoding error, it's a player whose cards vanished. So the old
// shape is read as what it always was: one hand, holding the whole stake.
func (s *State) UnmarshalJSON(b []byte) error {
type state State // shed the method, or this recurses forever
var v struct {
state
Player []cards.Card `json:"player"`
}
if err := json.Unmarshal(b, &v); err != nil {
return err
}
*s = State(v.state)
if len(s.Hands) == 0 && len(v.Player) > 0 {
s.Hands = []Hand{{
Cards: v.Player,
Bet: s.Bet,
Outcome: s.Outcome,
Payout: s.Payout,
Rake: s.Rake,
Done: s.Phase == PhaseDone,
}}
s.Active = 0
}
return nil
}

View File

@@ -60,7 +60,7 @@ func TestIsBlackjack_OnlyOnTwoCards(t *testing.T) {
// deal so the payout math can be checked case by case. // deal so the payout math can be checked case by case.
func settleWith(t *testing.T, r Rules, bet int64, player, dealer []cards.Card) State { func settleWith(t *testing.T, r Rules, bet int64, player, dealer []cards.Card) State {
t.Helper() t.Helper()
s := State{Rules: r, Bet: bet, Player: player, Dealer: dealer} s := State{Rules: r, Bet: bet, Hands: []Hand{{Cards: player, Bet: bet}}, Dealer: dealer}
evs := []Event{} evs := []Event{}
s.settle(&evs) s.settle(&evs)
if s.Phase != PhaseDone { if s.Phase != PhaseDone {
@@ -164,8 +164,8 @@ func TestNew_DealsFourCardsAndAskThePlayer(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(s.Player) != 2 || len(s.Dealer) != 2 { if len(s.Hands[0].Cards) != 2 || len(s.Dealer) != 2 {
t.Fatalf("dealt %d/%d cards, want 2/2", len(s.Player), len(s.Dealer)) t.Fatalf("dealt %d/%d cards, want 2/2", len(s.Hands[0].Cards), len(s.Dealer))
} }
if len(s.Deck) != 6*52-4 { if len(s.Deck) != 6*52-4 {
t.Fatalf("shoe has %d cards left, want %d", len(s.Deck), 6*52-4) t.Fatalf("shoe has %d cards left, want %d", len(s.Deck), 6*52-4)
@@ -174,7 +174,7 @@ func TestNew_DealsFourCardsAndAskThePlayer(t *testing.T) {
t.Fatal("no deal event") t.Fatal("no deal event")
} }
// Unless somebody was dealt a natural, it's the player's move. // Unless somebody was dealt a natural, it's the player's move.
if !IsBlackjack(s.Player) && !IsBlackjack(s.Dealer) && s.Phase != PhasePlayer { if !IsBlackjack(s.Hands[0].Cards) && !IsBlackjack(s.Dealer) && s.Phase != PhasePlayer {
t.Fatalf("phase = %q, want %q", s.Phase, PhasePlayer) t.Fatalf("phase = %q, want %q", s.Phase, PhasePlayer)
} }
} }
@@ -195,7 +195,7 @@ func TestNew_NaturalSettlesImmediately(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if IsBlackjack(s.Player) { if IsBlackjack(s.Hands[0].Cards) {
if s.Phase != PhaseDone { if s.Phase != PhaseDone {
t.Fatalf("seed %d: player has a natural but phase = %q", seed, s.Phase) t.Fatalf("seed %d: player has a natural but phase = %q", seed, s.Phase)
} }
@@ -225,7 +225,7 @@ func TestApplyMove_HitUntilBustSettles(t *testing.T) {
if s.Phase != PhaseDone { if s.Phase != PhaseDone {
t.Fatal("hitting a dozen times never ended the hand") t.Fatal("hitting a dozen times never ended the hand")
} }
if v, _ := HandValue(s.Player); v <= 21 { if v, _ := HandValue(s.Hands[0].Cards); v <= 21 {
t.Fatalf("player stopped at %d without busting — the loop should have gone over", v) t.Fatalf("player stopped at %d without busting — the loop should have gone over", v)
} }
if s.Outcome != OutcomeBust || s.Payout != 0 { if s.Outcome != OutcomeBust || s.Payout != 0 {
@@ -285,11 +285,11 @@ func TestApplyMove_DoubleTakesOneCardThenStands(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !s.Doubled || s.Bet != 200 { if !s.Hands[0].Doubled || s.Bet != 200 {
t.Fatalf("bet = %d doubled = %v, want 200/true", s.Bet, s.Doubled) t.Fatalf("bet = %d doubled = %v, want 200/true", s.Bet, s.Hands[0].Doubled)
} }
if len(s.Player) != 3 { if len(s.Hands[0].Cards) != 3 {
t.Fatalf("player has %d cards after a double, want exactly 3", len(s.Player)) t.Fatalf("player has %d cards after a double, want exactly 3", len(s.Hands[0].Cards))
} }
if s.Phase != PhaseDone { if s.Phase != PhaseDone {
t.Fatal("a double must end the player's turn") t.Fatal("a double must end the player's turn")
@@ -378,9 +378,9 @@ func TestNew_IsReproducibleFromItsSeed(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if cards.Hand(a.Player) != cards.Hand(b.Player) || cards.Hand(a.Dealer) != cards.Hand(b.Dealer) { if cards.Hand(a.Hands[0].Cards) != cards.Hand(b.Hands[0].Cards) || cards.Hand(a.Dealer) != cards.Hand(b.Dealer) {
t.Fatalf("same seed dealt different hands: %s/%s vs %s/%s", t.Fatalf("same seed dealt different hands: %s/%s vs %s/%s",
cards.Hand(a.Player), cards.Hand(a.Dealer), cards.Hand(b.Player), cards.Hand(b.Dealer)) cards.Hand(a.Hands[0].Cards), cards.Hand(a.Dealer), cards.Hand(b.Hands[0].Cards), cards.Hand(b.Dealer))
} }
} }
@@ -395,21 +395,21 @@ func TestApplyMove_DerivedStatesDoNotShareCards(t *testing.T) {
if s.Phase == PhaseDone { if s.Phase == PhaseDone {
t.Skip("dealt a natural") t.Skip("dealt a natural")
} }
before := cards.Hand(s.Player) before := cards.Hand(s.Hands[0].Cards)
a, _, err := ApplyMove(s, Hit) a, _, err := ApplyMove(s, Hit)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
aHand := cards.Hand(a.Player) aHand := cards.Hand(a.Hands[0].Cards)
if _, _, err := ApplyMove(s, Hit); err != nil { // same start, applied again if _, _, err := ApplyMove(s, Hit); err != nil { // same start, applied again
t.Fatal(err) t.Fatal(err)
} }
if got := cards.Hand(a.Player); got != aHand { if got := cards.Hand(a.Hands[0].Cards); got != aHand {
t.Fatalf("the first hand changed under us: %q became %q", aHand, got) t.Fatalf("the first hand changed under us: %q became %q", aHand, got)
} }
if got := cards.Hand(s.Player); got != before { if got := cards.Hand(s.Hands[0].Cards); got != before {
t.Fatalf("ApplyMove mutated the state it was given: %q became %q", before, got) t.Fatalf("ApplyMove mutated the state it was given: %q became %q", before, got)
} }
} }

View File

@@ -0,0 +1,402 @@
package blackjack
import (
"encoding/json"
"testing"
"pete/internal/games/cards"
)
// Split is the only move in blackjack that takes chips out of a player's stack
// after the cards are already out, so most of what can go wrong with it is money
// rather than cards. These tests are mostly about the money.
// dealt builds a state mid-hand: the player holding `player`, the dealer showing
// `dealer`, and a shoe stacked with `shoe` so the next cards are known. It skips
// New() because a split needs a pair, and waiting for one out of a shuffled shoe
// is not a test, it's a slot machine.
func dealt(bet int64, player, dealer, shoe []cards.Card) State {
return State{
Rules: DefaultRules(),
Deck: cards.Deck(shoe),
Dealer: dealer,
Hands: []Hand{{Cards: player, Bet: bet}},
Bet: bet,
Phase: PhasePlayer,
}
}
func TestSplitDealsTwoHandsAndTakesASecondBet(t *testing.T) {
s := dealt(100,
hand(8, 8),
hand(cards.King, 6),
hand(3, 9, 5, 5), // one card to each hand, then whatever the dealer needs
)
if !s.CanSplit() {
t.Fatal("CanSplit says no to a pair of eights")
}
if s.SplitCost() != 100 {
t.Fatalf("SplitCost = %d, want the same bet again (100)", s.SplitCost())
}
s, evs, err := ApplyMove(s, Split)
if err != nil {
t.Fatal(err)
}
if len(s.Hands) != 2 {
t.Fatalf("split made %d hands, want 2", len(s.Hands))
}
for i, h := range s.Hands {
if len(h.Cards) != 2 {
t.Errorf("hand %d holds %d cards after the split, want 2", i, len(h.Cards))
}
if h.Cards[0].Rank != 8 {
t.Errorf("hand %d didn't keep an eight: %s", i, cards.Hand(h.Cards))
}
if h.Bet != 100 {
t.Errorf("hand %d carries a bet of %d, want 100", i, h.Bet)
}
if !h.Split {
t.Errorf("hand %d isn't marked as split", i)
}
}
// The whole point, in one line: there is twice as much on the table as there
// was, and the shell has to have taken it.
if s.Bet != 200 {
t.Fatalf("total stake = %d after splitting a 100 hand, want 200", s.Bet)
}
if s.Active != 0 {
t.Fatalf("active hand = %d, want the left one first", s.Active)
}
var split, cardsDealt int
for _, e := range evs {
switch e.Kind {
case "split":
split++
case "player_card":
cardsDealt++
}
}
if split != 1 || cardsDealt != 2 {
t.Fatalf("the split emitted %d split and %d card events, want 1 and 2", split, cardsDealt)
}
}
// Split aces get one card each and no further say. Without that rule a pair of
// aces is the best hand in the game every single time.
func TestSplitAcesGetOneCardEachAndNoMore(t *testing.T) {
s := dealt(50,
hand(cards.Ace, cards.Ace),
hand(9, 7), // dealer sits on 16, has to draw
hand(cards.King, 9, 5),
)
s, _, err := ApplyMove(s, Split)
if err != nil {
t.Fatal(err)
}
for i, h := range s.Hands {
if len(h.Cards) != 2 {
t.Errorf("split ace %d holds %d cards, want exactly 2", i, len(h.Cards))
}
if !h.Done {
t.Errorf("split ace %d is still being asked for a decision", i)
}
}
// Both hands are finished the moment they're dealt, so the dealer plays and
// the deal settles without the player ever acting again.
if s.Phase != PhaseDone {
t.Fatalf("phase = %q after splitting aces, want the deal to have run to the end", s.Phase)
}
}
// An ace and a ten on a split hand is twenty-one. It is not a blackjack, and the
// house does not pay 3:2 for it — which is the single most expensive rule in this
// file, because splitting aces makes 21s for a living.
func TestTwentyOneOnASplitHandIsNotANatural(t *testing.T) {
s := dealt(100,
hand(cards.Ace, cards.Ace),
hand(cards.King, 7), // dealer stands on 17
hand(cards.King, cards.Queen),
)
s, _, err := ApplyMove(s, Split)
if err != nil {
t.Fatal(err)
}
if s.Phase != PhaseDone {
t.Fatalf("phase = %q, want done", s.Phase)
}
for i, h := range s.Hands {
if v, _ := h.Value(); v != 21 {
t.Fatalf("hand %d is %d, want the 21 this test is about", i, v)
}
if h.Natural() {
t.Errorf("hand %d counts as a natural — a split 21 must not pay 3:2", i)
}
if h.Outcome != OutcomeWin {
t.Errorf("hand %d settled as %q, want a plain win", i, h.Outcome)
}
// 100 staked, 100 profit, 5% rake off the profit: 195 back, not 245.
if h.Payout != 195 {
t.Errorf("hand %d paid %d, want 195 (a 1:1 win less the rake), not a 3:2 payout", i, h.Payout)
}
}
if s.Payout != 390 {
t.Fatalf("the deal paid %d, want 390", s.Payout)
}
}
// Same rank, not same value. A king and a queen are both worth ten and are not a
// pair.
func TestSplitNeedsAPairOfTheSameRank(t *testing.T) {
s := dealt(100, hand(cards.King, cards.Queen), hand(9, 9), hand(5))
if s.CanSplit() {
t.Error("CanSplit says a king and a queen are a pair")
}
if _, _, err := ApplyMove(s, Split); err != ErrCantSplit {
t.Errorf("splitting K+Q gave %v, want ErrCantSplit", err)
}
// And you cannot split a hand you have already hit.
s = dealt(100, hand(8, 8, 5), hand(9, 9), hand(5))
if s.CanSplit() {
t.Error("CanSplit says yes to a three-card hand")
}
}
func TestSplittingStopsAtFourHands(t *testing.T) {
// Every card an eight, so every hand splits again for as long as it's allowed.
shoe := make([]cards.Card, 0, 12)
for i := 0; i < 12; i++ {
shoe = append(shoe, cards.Card{Rank: 8, Suit: cards.Spades})
}
s := dealt(100, hand(8, 8), hand(9, 7), shoe)
for i := 0; i < MaxHands-1; i++ {
var err error
if s, _, err = ApplyMove(s, Split); err != nil {
t.Fatalf("split %d: %v", i+1, err)
}
}
if len(s.Hands) != MaxHands {
t.Fatalf("ended with %d hands, want %d", len(s.Hands), MaxHands)
}
if s.CanSplit() {
t.Fatalf("CanSplit says yes at %d hands", MaxHands)
}
if _, _, err := ApplyMove(s, Split); err != ErrCantSplit {
t.Errorf("the fifth split gave %v, want ErrCantSplit", err)
}
if s.Bet != 400 {
t.Errorf("four hands of 100 = %d staked, want 400", s.Bet)
}
}
// Each hand is raked on its own winnings. Netting the hands against each other
// first would mean a player who wins one and loses one pays no rake at all, which
// is not a rake, it's a discount for splitting.
func TestEachSplitHandIsSettledAndRakedOnItsOwn(t *testing.T) {
s := dealt(100,
hand(8, 8),
hand(cards.King, 9), // dealer stands on 19
// left hand gets a 2 (10, then it hits to 20 and stands);
// right hand gets a 3 (11) and will bust on a king.
hand(2, 3, cards.King, cards.King),
)
s, _, err := ApplyMove(s, Split)
if err != nil {
t.Fatal(err)
}
if s, _, err = ApplyMove(s, Hit); err != nil { // left: 8+2+K = 20
t.Fatal(err)
}
if v, _ := s.Hands[0].Value(); v != 20 {
t.Fatalf("left hand is %d, want 20", v)
}
if s, _, err = ApplyMove(s, Stand); err != nil {
t.Fatal(err)
}
if s.Active != 1 {
t.Fatalf("standing on the left hand left the active hand at %d, want 1", s.Active)
}
if s, _, err = ApplyMove(s, Hit); err != nil { // right: 8+3+K = 21... no: 8+3=11, +K = 21
t.Fatal(err)
}
if s.Phase != PhaseDone {
t.Fatalf("phase = %q, want done", s.Phase)
}
left, right := s.Hands[0], s.Hands[1]
if left.Outcome != OutcomeWin { // 20 beats 19
t.Errorf("left hand settled %q, want a win", left.Outcome)
}
if right.Outcome != OutcomeWin { // 21 beats 19
t.Errorf("right hand settled %q, want a win", right.Outcome)
}
// Two 100 hands, each winning 100, each raked 5 on its own profit.
if left.Rake != 5 || right.Rake != 5 || s.Rake != 10 {
t.Errorf("rake = %d/%d (total %d), want 5/5 (10) — each hand raked on its own winnings", left.Rake, right.Rake, s.Rake)
}
if s.Payout != 390 || s.Net() != 190 {
t.Errorf("payout = %d net = %d, want 390 and 190", s.Payout, s.Net())
}
}
// A hand that busts loses its own bet and nothing else. The other hand is a
// separate bet and settles on its own merits.
func TestBustingOneSplitHandDoesNotTouchTheOther(t *testing.T) {
s := dealt(100,
hand(9, 9),
hand(cards.King, 8), // dealer stands on 18
hand(5, 2, cards.King, cards.King),
)
s, _, err := ApplyMove(s, Split) // left: 9+5=14, right: 9+2=11
if err != nil {
t.Fatal(err)
}
if s, _, err = ApplyMove(s, Hit); err != nil { // left: 14+K = 24, bust
t.Fatal(err)
}
if s.Hands[0].Outcome != "" && s.Hands[0].Outcome != OutcomeBust {
t.Fatalf("left hand outcome %q before settling", s.Hands[0].Outcome)
}
if !s.Hands[0].Done || s.Active != 1 {
t.Fatalf("a bust hand didn't hand over: done=%v active=%d", s.Hands[0].Done, s.Active)
}
if s, _, err = ApplyMove(s, Hit); err != nil { // right: 11+K = 21
t.Fatal(err)
}
if s.Hands[0].Outcome != OutcomeBust || s.Hands[0].Payout != 0 {
t.Errorf("left hand = %q paying %d, want a bust paying nothing", s.Hands[0].Outcome, s.Hands[0].Payout)
}
if s.Hands[1].Outcome != OutcomeWin || s.Hands[1].Payout != 195 {
t.Errorf("right hand = %q paying %d, want a win paying 195", s.Hands[1].Outcome, s.Hands[1].Payout)
}
// Staked 200, got 195 back: down 5 on the deal, and the word for that is lose.
if s.Payout != 195 || s.Net() != -5 || s.Outcome != OutcomeLose {
t.Errorf("deal settled %q paying %d (net %d), want lose/195/-5", s.Outcome, s.Payout, s.Net())
}
}
// If every hand busts there is nothing left to beat, and the dealer does not turn
// over — same as it always was with one hand.
func TestTheDealerDoesNotPlayWhenEveryHandIsBust(t *testing.T) {
s := dealt(100,
hand(9, 9),
hand(cards.King, 6),
hand(8, 8, cards.King, cards.King, 4), // both hands reach 17 then bust on a king
)
s, _, err := ApplyMove(s, Split)
if err != nil {
t.Fatal(err)
}
if s, _, err = ApplyMove(s, Hit); err != nil { // left 9+8+K = 27
t.Fatal(err)
}
if s, _, err = ApplyMove(s, Hit); err != nil { // right 9+8+K = 27
t.Fatal(err)
}
if s.Phase != PhaseDone {
t.Fatalf("phase = %q, want done", s.Phase)
}
if len(s.Dealer) != 2 {
t.Errorf("the dealer drew to %d cards with every hand already bust", len(s.Dealer))
}
if s.Payout != 0 {
t.Errorf("two bust hands paid %d, want nothing", s.Payout)
}
}
// The dealer not drawing is not the same as the dealer not turning over. The
// browser has been showing a face-down card since the deal, and the settled state
// it gets handed has the dealer's whole total on it — so a bust-out still owes it
// a reveal, or the felt prints a nineteen under a card nobody has looked at.
func TestBustingOutStillTurnsTheHoleCardOver(t *testing.T) {
s := dealt(100,
hand(cards.King, 6),
hand(cards.King, 9),
hand(cards.King), // 16 + K = 26
)
s, evs, err := ApplyMove(s, Hit)
if err != nil {
t.Fatal(err)
}
if s.Phase != PhaseDone || s.Outcome != OutcomeBust {
t.Fatalf("phase/outcome = %q/%q, want done/bust", s.Phase, s.Outcome)
}
var reveal bool
for _, e := range evs {
if e.Kind == "reveal" {
reveal = true
}
}
if !reveal {
t.Error("the player busted out and the hole card was never revealed")
}
}
// Doubling after a split doubles *that hand's* bet, not the whole table's.
func TestDoublingAfterASplitOnlyDoublesThatHand(t *testing.T) {
s := dealt(100,
hand(5, 5),
hand(cards.King, 7),
hand(6, 4, 9, cards.King),
)
s, _, err := ApplyMove(s, Split) // left: 5+6=11, right: 5+4=9
if err != nil {
t.Fatal(err)
}
if s.DoubleCost() != 100 {
t.Fatalf("DoubleCost = %d on a split hand of 100, want 100", s.DoubleCost())
}
s, _, err = ApplyMove(s, Double) // left doubles: 11 + 9 = 20
if err != nil {
t.Fatal(err)
}
if s.Hands[0].Bet != 200 || s.Hands[1].Bet != 100 {
t.Fatalf("bets are %d/%d after doubling the left hand, want 200/100", s.Hands[0].Bet, s.Hands[1].Bet)
}
if s.Bet != 300 {
t.Fatalf("total staked = %d, want 300 (100 + a doubled 100 + 100)", s.Bet)
}
if !s.Hands[0].Done || s.Active != 1 {
t.Fatal("a doubled hand takes one card and hands over")
}
}
// A live hand outlives a deploy. The blobs written before split existed have a
// "player" array and no "hands", and a state that decodes to no hands at all is a
// player whose cards vanished mid-deal.
func TestALiveHandDealtBeforeSplitExistedStillLoads(t *testing.T) {
legacy := []byte(`{
"rules": {"decks": 6, "blackjack_pays": 1.5, "dealer_hits_soft17": true, "rake_pct": 0.05},
"deck": [],
"player": [{"r": 10, "s": 1}, {"r": 7, "s": 2}],
"dealer": [{"r": 9, "s": 0}, {"r": 5, "s": 3}],
"bet": 250,
"doubled": false,
"phase": "player"
}`)
var s State
if err := json.Unmarshal(legacy, &s); err != nil {
t.Fatal(err)
}
if len(s.Hands) != 1 {
t.Fatalf("an old live hand decoded to %d hands, want 1 — the player's cards went missing", len(s.Hands))
}
if len(s.Hands[0].Cards) != 2 {
t.Fatalf("the revived hand holds %d cards, want 2", len(s.Hands[0].Cards))
}
if s.Hands[0].Bet != 250 || s.Bet != 250 {
t.Fatalf("the revived hand carries %d of the %d staked, want all of it", s.Hands[0].Bet, s.Bet)
}
if v, _ := s.Hands[0].Value(); v != 17 {
t.Fatalf("the revived hand is worth %d, want 17", v)
}
// And it can still be played to the end.
if _, _, err := ApplyMove(s, Stand); err != nil {
t.Fatalf("the revived hand could not be stood on: %v", err)
}
}

Binary file not shown.

View File

@@ -0,0 +1,8 @@
Fredoka is copyright the Fredoka Project Authors
(https://github.com/hafontia/Fredoka), licensed under the SIL Open Font
License, Version 1.1: https://openfontlicense.org
It is vendored here because the share card (games_og.go) is drawn on the
server, and a server cannot reach for a font over the network the way the
page does. The site itself still loads Fredoka from Google's CDN, so this is
the same typeface arriving by a second road, not a second typeface.

444
internal/web/games_og.go Normal file
View File

@@ -0,0 +1,444 @@
package web
import (
"bytes"
_ "embed"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"log/slog"
"math"
"net/http"
"sync"
"time"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"golang.org/x/image/vector"
)
// The card that shows up when somebody pastes the casino into a chat window.
//
// It is drawn here, in Go, rather than checked in as a picture, because the
// casino has two names on a clock and the share card keeps the joke: paste the
// link in daylight and you get Casinopolis on green felt; paste it after six and
// the neon is on and the sign says Casino Night Zone. Same tables, different room,
// same rule as roomAt() everywhere else.
//
// The clock that decides is the *server's*, because an unfurl bot has no evening
// of its own. That's the one place the room rule can't be the player's own clock.
//go:embed assets/fonts/Fredoka-SemiBold.ttf
var fredokaTTF []byte
// Share cards are 1200x630: the size every unfurler crops to and the one thing
// about Open Graph that everybody agrees on.
const (
ogWidth = 1200
ogHeight = 630
)
// ogPalette is a room's colours, lifted from the CSS block of the same name in
// input.css. Two copies of a palette is a thing that drifts, so if you retune a
// room, retune it here as well — TestTheShareCardKnowsBothRooms will not catch a
// colour, only a missing room.
type ogPalette struct {
bg color.RGBA // the room, behind the table
feltA color.RGBA // the felt, lit
feltC color.RGBA // the felt, in shadow
ink color.RGBA // type
accent color.RGBA // the sign, and the lamp over the table
}
var ogPalettes = map[string]ogPalette{
roomDay.Slug: {
bg: rgb(0x16, 0x21, 0x1c),
feltA: rgb(0x2f, 0x7d, 0x5b),
feltC: rgb(0x1c, 0x4d, 0x3c),
ink: rgb(0xf6, 0xec, 0xd8),
accent: rgb(0xf2, 0xb5, 0x3d),
},
roomNight.Slug: {
bg: rgb(0x14, 0x0f, 0x2e),
feltA: rgb(0x4a, 0x2f, 0xa8),
feltC: rgb(0x24, 0x16, 0x59),
ink: rgb(0xf2, 0xec, 0xff),
accent: rgb(0xff, 0xcc, 0x2f),
},
}
// A card is drawn once per room and then kept. There are two of them, they never
// change, and an unfurl bot is not worth a rasterizer.
var (
ogOnce sync.Once
ogCards map[string][]byte // room slug -> PNG
ogErr error
)
func (s *Server) handleGamesOG(w http.ResponseWriter, r *http.Request) {
if !s.gamesReady() {
http.NotFound(w, r)
return
}
// No requirePlayer here, and that is the whole point: the thing fetching this
// is a chat server's link preview, which has never signed in and never will.
ogOnce.Do(func() { ogCards, ogErr = drawShareCards() })
room := roomAt(time.Now().Hour())
card := ogCards[room.Slug]
if ogErr != nil || len(card) == 0 {
// A room with no card is a room somebody added to roomAt and not to
// ogPalettes. Say so rather than serving a zero-byte PNG, which an unfurler
// will happily cache as the casino's face.
slog.Error("games: no share card", "room", room.Slug, "err", ogErr)
http.Error(w, "share card unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/png")
// Long enough that a busy channel isn't redrawing it, short enough that the
// room actually turns over: the lights come on at six and the card follows
// within the hour.
w.Header().Set("Cache-Control", "public, max-age=900")
// ServeContent sets Content-Length itself, and gets it right for a range
// request, which a hand-written one would not.
http.ServeContent(w, r, "og.png", time.Time{}, bytes.NewReader(card))
}
// drawShareCards renders every room's card up front. If the font is broken every
// card is broken, so they fail together or not at all.
func drawShareCards() (map[string][]byte, error) {
f, err := opentype.Parse(fredokaTTF)
if err != nil {
return nil, fmt.Errorf("parse fredoka: %w", err)
}
out := make(map[string][]byte, len(ogPalettes))
for _, rm := range []room{roomDay, roomNight} {
img, err := drawShareCard(f, rm, ogPalettes[rm.Slug])
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("encode %s: %w", rm.Slug, err)
}
out[rm.Slug] = buf.Bytes()
}
return out, nil
}
func drawShareCard(f *opentype.Font, rm room, p ogPalette) (image.Image, error) {
title, err := face(f, 96)
if err != nil {
return nil, err
}
defer title.Close()
line, err := face(f, 33)
if err != nil {
return nil, err
}
defer line.Close()
small, err := face(f, 25)
if err != nil {
return nil, err
}
defer small.Close()
img := image.NewRGBA(image.Rect(0, 0, ogWidth, ogHeight))
draw.Draw(img, img.Bounds(), &image.Uniform{p.bg}, image.Point{}, draw.Src)
// The table: a rounded rect of felt, lit from the top and falling into shadow
// at the rail, which is the same trick the room's CSS plays with a gradient.
table := func(rz *vector.Rasterizer) {
roundRect(rz, 36, 36, ogWidth-72, ogHeight-72, 44, nil)
}
fillPath(img, verticalGradient(p.feltA, p.feltC), table)
// The lamp over it. Nothing else in the picture explains where the light is
// coming from, and a felt with no lamp reads as a green rectangle. It is
// painted through the table's own shape, because light that spills onto the
// floor outside the rail is not a lamp, it's a mistake.
fillPath(img, lamp(ogWidth/2, 40, 520, p.accent, 0.26), table)
// The sign over the door.
centerText(img, title, p.accent, rm.Name, ogHeight/2-42)
centerText(img, line, alpha(p.ink, 0.92), "Blackjack, Hold'em, UNO, Trivia, Hangman, Solitaire", ogHeight/2+26)
centerText(img, small, alpha(p.ink, 0.62), "Played for real gogobee euros", ogHeight/2+78)
// Bottom left: two cards, one face down and one face up, because a casino that
// shows you only the backs is a casino that isn't dealing.
drawCardBack(img, p, 118, 430, -13)
drawCardFace(img, 196, 420, 7)
// Bottom right: what you're playing for.
chips := []color.RGBA{p.accent, rgb(0xd9, 0x4f, 0x4f), rgb(0xf6, 0xf1, 0xe6), p.accent}
for i, c := range chips {
drawChip(img, 1042, float32(536-i*23), 46, c, p)
}
// The address, small, in the corner. A share card is also a signpost.
rightText(img, small, alpha(p.ink, 0.45), "games.parodia.dev", ogWidth-70, 96)
return img, nil
}
// ---- shapes -----------------------------------------------------------------
//
// Everything below draws through a vector.Rasterizer the size of the whole card,
// which is wasteful and completely fine: it happens twice, at boot, forever.
// xform moves a point before it's rasterized. It is how anything here gets to sit
// at an angle — font.Drawer can't rotate, but a path can be rotated on its way in.
type xform func(x, y float32) (float32, float32)
func rotate(cx, cy, deg float32) xform {
rad := float64(deg) * math.Pi / 180
sin, cos := float32(math.Sin(rad)), float32(math.Cos(rad))
return func(x, y float32) (float32, float32) {
dx, dy := x-cx, y-cy
return cx + dx*cos - dy*sin, cy + dx*sin + dy*cos
}
}
func (t xform) at(x, y float32) (float32, float32) {
if t == nil {
return x, y
}
return t(x, y)
}
// fillPath rasterizes a path and paints src through it.
func fillPath(dst *image.RGBA, src image.Image, path func(*vector.Rasterizer)) {
rz := vector.NewRasterizer(ogWidth, ogHeight)
path(rz)
rz.Draw(dst, dst.Bounds(), src, image.Point{})
}
func fill(dst *image.RGBA, c color.Color, path func(*vector.Rasterizer)) {
fillPath(dst, &image.Uniform{c}, path)
}
// roundRect lays a rounded rectangle into the rasterizer, optionally through a
// transform, so the same helper draws a table and a card held at an angle.
func roundRect(rz *vector.Rasterizer, x, y, w, h, r float32, t xform) {
move := func(px, py float32) { rz.MoveTo(t.at(px, py)) }
line := func(px, py float32) { rz.LineTo(t.at(px, py)) }
quad := func(cx, cy, px, py float32) {
qx, qy := t.at(cx, cy)
ex, ey := t.at(px, py)
rz.QuadTo(qx, qy, ex, ey)
}
move(x+r, y)
line(x+w-r, y)
quad(x+w, y, x+w, y+r)
line(x+w, y+h-r)
quad(x+w, y+h, x+w-r, y+h)
line(x+r, y+h)
quad(x, y+h, x, y+h-r)
line(x, y+r)
quad(x, y, x+r, y)
rz.ClosePath()
}
func circle(rz *vector.Rasterizer, cx, cy, r float32) {
// Four cubics, the usual 0.5523 magic number for a circle out of beziers.
const k = 0.5523
rz.MoveTo(cx+r, cy)
rz.CubeTo(cx+r, cy+r*k, cx+r*k, cy+r, cx, cy+r)
rz.CubeTo(cx-r*k, cy+r, cx-r, cy+r*k, cx-r, cy)
rz.CubeTo(cx-r, cy-r*k, cx-r*k, cy-r, cx, cy-r)
rz.CubeTo(cx+r*k, cy-r, cx+r, cy-r*k, cx+r, cy)
rz.ClosePath()
}
// hexagon is Pete's mark — the same six-sided badge as the favicon, which is what
// the back of the house's cards is printed with.
func hexagon(rz *vector.Rasterizer, cx, cy, r float32, t xform) {
for i := 0; i < 6; i++ {
ang := float64(i)*math.Pi/3 - math.Pi/2
x := cx + r*float32(math.Cos(ang))
y := cy + r*float32(math.Sin(ang))
px, py := t.at(x, y)
if i == 0 {
rz.MoveTo(px, py)
} else {
rz.LineTo(px, py)
}
}
rz.ClosePath()
}
const (
cardW = 132
cardH = 186
)
// drawCardBack is the house's card: dark, with the hexagon on it.
func drawCardBack(dst *image.RGBA, p ogPalette, x, y, deg float32) {
t := rotate(x+cardW/2, y+cardH/2, deg)
fill(dst, color.RGBA{0, 0, 0, 70}, func(rz *vector.Rasterizer) {
roundRect(rz, x+5, y+8, cardW, cardH, 14, t)
})
fill(dst, alpha(p.ink, 0.96), func(rz *vector.Rasterizer) {
roundRect(rz, x, y, cardW, cardH, 14, t)
})
fill(dst, darken(p.feltC, 0.55), func(rz *vector.Rasterizer) {
roundRect(rz, x+9, y+9, cardW-18, cardH-18, 9, t)
})
fill(dst, p.accent, func(rz *vector.Rasterizer) {
hexagon(rz, x+cardW/2, y+cardH/2, 34, t)
})
fill(dst, darken(p.feltC, 0.55), func(rz *vector.Rasterizer) {
hexagon(rz, x+cardW/2, y+cardH/2, 20, t)
})
}
// drawCardFace is the one that's been turned over: an ace of hearts, near enough.
func drawCardFace(dst *image.RGBA, x, y, deg float32) {
t := rotate(x+cardW/2, y+cardH/2, deg)
red := rgb(0xd9, 0x3b, 0x3b)
fill(dst, color.RGBA{0, 0, 0, 80}, func(rz *vector.Rasterizer) {
roundRect(rz, x+5, y+9, cardW, cardH, 14, t)
})
fill(dst, rgb(0xfb, 0xf7, 0xef), func(rz *vector.Rasterizer) {
roundRect(rz, x, y, cardW, cardH, 14, t)
})
heart(dst, red, x+cardW/2, y+cardH/2+4, 40, t)
// The pip in the corner, which is how you know it's a card and not a tile.
heart(dst, red, x+24, y+30, 11, t)
}
// heart, as two lobes and a point.
func heart(dst *image.RGBA, c color.RGBA, cx, cy, r float32, t xform) {
fill(dst, c, func(rz *vector.Rasterizer) {
move := func(px, py float32) { rz.MoveTo(t.at(px, py)) }
cube := func(ax, ay, bx, by, px, py float32) {
a1, a2 := t.at(ax, ay)
b1, b2 := t.at(bx, by)
e1, e2 := t.at(px, py)
rz.CubeTo(a1, a2, b1, b2, e1, e2)
}
bottom := cy + r*0.95
move(cx, bottom)
cube(cx-r*1.15, cy+r*0.18, cx-r*1.0, cy-r*0.95, cx, cy-r*0.28)
cube(cx+r*1.0, cy-r*0.95, cx+r*1.15, cy+r*0.18, cx, bottom)
rz.ClosePath()
})
}
// drawChip is a chip seen face on: a disc, a ring of notches around the rim, and a
// pale centre. Stack four and it reads as money.
func drawChip(dst *image.RGBA, cx, cy, r float32, c color.RGBA, p ogPalette) {
fill(dst, color.RGBA{0, 0, 0, 60}, func(rz *vector.Rasterizer) { circle(rz, cx+3, cy+5, r) })
fill(dst, c, func(rz *vector.Rasterizer) { circle(rz, cx, cy, r) })
// Six notches on the rim, in the room's ink, the way a real chip is edge-marked.
for i := 0; i < 6; i++ {
ang := float64(i) * math.Pi / 3
nx := cx + (r-7)*float32(math.Cos(ang))
ny := cy + (r-7)*float32(math.Sin(ang))
fill(dst, alpha(p.ink, 0.85), func(rz *vector.Rasterizer) { circle(rz, nx, ny, 6) })
}
fill(dst, alpha(p.ink, 0.28), func(rz *vector.Rasterizer) { circle(rz, cx, cy, r-15) })
fill(dst, darken(c, 0.82), func(rz *vector.Rasterizer) { circle(rz, cx, cy, r-21) })
}
// lamp is the light over the table: the accent colour, brightest under the bulb
// and gone by the rail. It's an image rather than a paint, so it can be laid down
// through the table's shape and stop at the felt's edge.
//
// Note the alpha() on the way out. color.RGBA is *alpha-premultiplied*, and the
// first version of this wrote the raw channels next to a low alpha — which is not
// a dim honey glow, it's an invalid colour, and image/draw ran it straight past
// 255 and wrapped it. The card came out with a blue dome over a green stripe. If
// something here ever turns an impossible colour, look for a premultiply first.
func lamp(cx, cy, radius float32, c color.RGBA, strength float64) image.Image {
img := image.NewRGBA(image.Rect(0, 0, ogWidth, ogHeight))
for y := 0; y < ogHeight; y++ {
for x := 0; x < ogWidth; x++ {
dx, dy := float64(float32(x)-cx), float64(float32(y)-cy)
d := math.Sqrt(dx*dx+dy*dy) / float64(radius)
if d >= 1 {
continue
}
// Squared falloff: a lamp is bright under itself and dim at the edges.
img.SetRGBA(x, y, alpha(c, strength*(1-d)*(1-d)))
}
}
return img
}
// verticalGradient is the felt: lit at the top, in shadow at the rail.
func verticalGradient(top, bottom color.RGBA) image.Image {
g := image.NewRGBA(image.Rect(0, 0, 1, ogHeight))
for y := 0; y < ogHeight; y++ {
f := float64(y) / float64(ogHeight-1)
g.SetRGBA(0, y, color.RGBA{
R: lerp(top.R, bottom.R, f),
G: lerp(top.G, bottom.G, f),
B: lerp(top.B, bottom.B, f),
A: 255,
})
}
// One column, stretched sideways forever: the felt doesn't change across the table.
return &stretched{g}
}
type stretched struct{ src *image.RGBA }
func (s *stretched) ColorModel() color.Model { return s.src.ColorModel() }
func (s *stretched) Bounds() image.Rectangle { return image.Rect(0, 0, ogWidth, ogHeight) }
func (s *stretched) At(x, y int) color.Color { return s.src.At(0, y) }
// ---- type -------------------------------------------------------------------
func face(f *opentype.Font, size float64) (font.Face, error) {
return opentype.NewFace(f, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
}
func centerText(dst *image.RGBA, f font.Face, c color.Color, s string, baseline int) {
w := font.MeasureString(f, s)
drawText(dst, f, c, s, (ogWidth-w.Round())/2, baseline)
}
func rightText(dst *image.RGBA, f font.Face, c color.Color, s string, right, baseline int) {
w := font.MeasureString(f, s)
drawText(dst, f, c, s, right-w.Round(), baseline)
}
func drawText(dst *image.RGBA, f font.Face, c color.Color, s string, x, baseline int) {
d := &font.Drawer{
Dst: dst,
Src: &image.Uniform{c},
Face: f,
Dot: fixed.P(x, baseline),
}
d.DrawString(s)
}
// ---- colour -----------------------------------------------------------------
func rgb(r, g, b uint8) color.RGBA { return color.RGBA{r, g, b, 255} }
// alpha returns c at a fraction of its opacity, pre-multiplied, which is what
// image/draw wants.
func alpha(c color.RGBA, f float64) color.RGBA {
return color.RGBA{
R: uint8(float64(c.R) * f),
G: uint8(float64(c.G) * f),
B: uint8(float64(c.B) * f),
A: uint8(255 * f),
}
}
func darken(c color.RGBA, f float64) color.RGBA {
return color.RGBA{
R: uint8(float64(c.R) * f),
G: uint8(float64(c.G) * f),
B: uint8(float64(c.B) * f),
A: c.A,
}
}
func lerp(a, b uint8, f float64) uint8 {
return uint8(float64(a) + (float64(b)-float64(a))*f)
}

View File

@@ -0,0 +1,166 @@
package web
import (
"bytes"
"image/png"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
)
// The casino had no share card for as long as it existed, and the reason is worth
// keeping in a test rather than in a memory: every route was behind sign-in, so a
// link pasted into a chat window unfurled as whatever the *auth screen* said. Meta
// tags on a page nobody unauthenticated can fetch are meta tags nobody reads.
//
// So the two things these tests hold down are: a stranger gets a real page at the
// front door, and a stranger gets the picture. Everything else stays shut.
func TestTheDoorIsOpenToStrangers(t *testing.T) {
s := newCasino(t)
mux := http.NewServeMux()
s.casinoRoutes(mux)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/games", nil))
if w.Code != http.StatusOK {
t.Fatalf("GET /games as a stranger = %d, want 200: the front door has to be a page an unfurl bot can read", w.Code)
}
body := w.Body.String()
for _, want := range []string{
`property="og:image"`,
`property="og:title"`,
`property="og:description"`,
`name="twitter:card"`,
"og.png",
"Sign in",
} {
if !strings.Contains(body, want) {
t.Errorf("the door is missing %q", want)
}
}
if strings.Contains(body, "unknown page") {
t.Fatal("games_door is not in the games template set in server.go")
}
// A door you can play from is not a door.
if strings.Contains(body, `data-move=`) {
t.Error("the door is serving a table to somebody who has not signed in")
}
}
func TestTheTablesStayShutToStrangers(t *testing.T) {
s := newCasino(t)
mux := http.NewServeMux()
s.casinoRoutes(mux)
for _, path := range []string{"/games/blackjack", "/games/holdem", "/games/uno", "/games/trivia", "/games/hangman", "/games/solitaire"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != http.StatusFound {
t.Errorf("GET %s as a stranger = %d, want a 302 to sign-in", path, w.Code)
}
}
}
func TestTheShareCardNeedsNoSignIn(t *testing.T) {
s := newCasino(t)
mux := http.NewServeMux()
s.casinoRoutes(mux)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/games/og.png", nil))
if w.Code != http.StatusOK {
t.Fatalf("GET /games/og.png as a stranger = %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "image/png" {
t.Errorf("Content-Type = %q, want image/png", ct)
}
img, err := png.Decode(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("the share card is not a PNG: %v", err)
}
if b := img.Bounds(); b.Dx() != ogWidth || b.Dy() != ogHeight {
t.Errorf("share card is %dx%d, want %dx%d — every unfurler crops to that", b.Dx(), b.Dy(), ogWidth, ogHeight)
}
}
// An og:image that 404s is worth exactly as much as no og:image, and the two ways
// the casino is reachable disagree about where the picture lives: on the games host
// it's /og.png (hostRouter puts the /games back on), and anywhere else it's
// /games/og.png. So take the URL the page actually advertises and go and get it.
func TestTheAdvertisedShareCardIsReallyThere(t *testing.T) {
for _, host := range []string{"", "games.parodia.dev"} {
name := "no games host"
if host != "" {
name = host
}
t.Run(name, func(t *testing.T) {
s := newCasino(t)
s.cfg.Games.Host = host
mux := http.NewServeMux()
s.casinoRoutes(mux)
srv := s.hostRouter(mux) // what production actually serves
// Ask the door where its picture is.
w := httptest.NewRecorder()
door := httptest.NewRequest("GET", "/games", nil)
if host != "" {
door.Host = host
}
srv.ServeHTTP(w, door)
img := ogImageURL(t, w.Body.String())
// Then go and fetch it, the way a chat server would.
u, err := url.Parse(img)
if err != nil {
t.Fatalf("og:image %q is not a URL: %v", img, err)
}
if u.Host == "" {
t.Fatalf("og:image %q is relative — no unfurler will resolve it", img)
}
w = httptest.NewRecorder()
get := httptest.NewRequest("GET", u.Path, nil)
get.Host = u.Host
srv.ServeHTTP(w, get)
if w.Code != http.StatusOK {
t.Fatalf("the door advertises og:image %s, and fetching it gives %d", img, w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "image/png" {
t.Errorf("og:image %s served %q, want image/png", img, ct)
}
})
}
}
func ogImageURL(t *testing.T, body string) string {
t.Helper()
m := regexp.MustCompile(`property="og:image" content="([^"]+)"`).FindStringSubmatch(body)
if m == nil {
t.Fatal("the door serves no og:image at all")
}
return m[1]
}
// Both rooms have to have a card, because the room is picked at request time and a
// missing one serves zero bytes rather than an error.
func TestTheShareCardKnowsBothRooms(t *testing.T) {
cards, err := drawShareCards()
if err != nil {
t.Fatal(err)
}
for _, rm := range []room{roomDay, roomNight} {
if len(cards[rm.Slug]) == 0 {
t.Errorf("no share card for %s", rm.Slug)
}
}
if len(cards) != len(ogPalettes) {
t.Errorf("drew %d cards for %d palettes", len(cards), len(ogPalettes))
}
}

View File

@@ -2,6 +2,7 @@ package web
import ( import (
"net/http" "net/http"
"strings"
"time" "time"
"pete/internal/games/blackjack" "pete/internal/games/blackjack"
@@ -67,7 +68,12 @@ func roomAt(hour int) room {
// Giving it its own page struct is what stops the news app's furniture drifting // Giving it its own page struct is what stops the news app's furniture drifting
// back in one convenient field at a time. // back in one convenient field at a time.
type gamesPage struct { type gamesPage struct {
Room room Room room
// URL and OGImage are here for the share card, and they are absolute because
// Open Graph will not resolve a relative one: the thing reading those tags is
// a chat server, and it has no page to resolve against. See casinoURL.
URL string // this page, at the address a player would type
OGImage string // the share card, at the same
User *SessionUser User *SessionUser
Cap int64 Cap int64
RakePct int RakePct int
@@ -94,6 +100,7 @@ type gamesPage struct {
// silently stops including the newest game. // silently stops including the newest game.
func (s *Server) casinoRoutes(mux *http.ServeMux) { func (s *Server) casinoRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /games", s.handleLobby) mux.HandleFunc("GET /games", s.handleLobby)
mux.HandleFunc("GET /games/og.png", s.handleGamesOG) // the share card, and the one games page with no door on it
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack) mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /games/hangman", s.handleHangman) mux.HandleFunc("GET /games/hangman", s.handleHangman)
mux.HandleFunc("GET /games/solitaire", s.handleSolitaire) mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
@@ -140,10 +147,38 @@ func (s *Server) requirePlayer(w http.ResponseWriter, r *http.Request) bool {
return false return false
} }
// casinoURL turns a route on the mux ("/games/uno") into the absolute address a
// player would actually type, which is what an unfurl bot has to be handed.
//
// The two halves of it are the same fact seen twice. On the games host the casino
// sits at the root and hostRouter puts the /games prefix back on the way in, so
// the public address of a page is its route with that prefix taken off. Without a
// games host — development, and only development — there is no rewrite and no
// prefix to remove, and the route *is* the address. Getting this backwards points
// og:image at a URL that 404s, which is exactly as visible as no og:image at all.
func (s *Server) casinoURL(r *http.Request, route string) string {
if h := strings.TrimSpace(s.cfg.Games.Host); h != "" {
path := strings.TrimPrefix(route, "/games")
if path == "" {
path = "/"
}
return "https://" + h + path
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return scheme + "://" + r.Host + route
}
func (s *Server) gamesPage(r *http.Request) gamesPage { func (s *Server) gamesPage(r *http.Request) gamesPage {
return gamesPage{ return gamesPage{
Room: roomAt(time.Now().Hour()), Room: roomAt(time.Now().Hour()),
User: s.auth.userFromRequest(r), // requirePlayer ran first, so this is non-nil URL: s.casinoURL(r, r.URL.Path),
OGImage: s.casinoURL(r, "/games/og.png"),
// Nil on the front door, and only there — every other page ran requirePlayer
// first. A template that reads .User has to guard it.
User: s.auth.userFromRequest(r),
Cap: storage.MaxChipsOnTable, Cap: storage.MaxChipsOnTable,
RakePct: int(blackjack.DefaultRules().RakePct * 100), RakePct: int(blackjack.DefaultRules().RakePct * 100),
Soon: comingSoon, Soon: comingSoon,
@@ -162,8 +197,21 @@ func (s *Server) gamesPage(r *http.Request) gamesPage {
} }
} }
// handleLobby is the one page in the casino that answers a stranger.
//
// Every table bounces an anonymous visitor straight to sign-in, which is right:
// there is money on them. But the front door cannot do that, because the front
// door is what people paste into a chat window, and a 302 to an auth screen has
// nothing in it to make a preview out of — the casino unfurled as the bare word
// "parodia.dev" for as long as it has existed. So the door is a real page, served
// to anybody, carrying the share card and a way in. You still can't play from it.
func (s *Server) handleLobby(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLobby(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) { if !s.gamesReady() {
http.NotFound(w, r)
return
}
if u := s.auth.userFromRequest(r); u == nil || u.MatrixUser(s.cfg.Games.MatrixServer) == "" {
s.render(w, "games_door", s.gamesPage(r))
return return
} }
s.render(w, "games", s.gamesPage(r)) s.render(w, "games", s.gamesPage(r))

View File

@@ -91,35 +91,62 @@ func viewCard(c cards.Card) cardView {
// genuinely not in the payload. A field the browser is told to ignore is a field // genuinely not in the payload. A field the browser is told to ignore is a field
// somebody reads in devtools. // somebody reads in devtools.
type handView struct { type handView struct {
Phase string `json:"phase"` Phase string `json:"phase"`
Bet int64 `json:"bet"` // everything staked this deal, across every hand
Hands []spotView `json:"hands"`
Active int `json:"active"`
Dealer []cardView `json:"dealer"`
Hole bool `json:"hole"` // true: the dealer has a face-down card
DTotal int `json:"dealer_total"` // what the *shown* dealer cards add to
Outcome string `json:"outcome,omitempty"`
Payout int64 `json:"payout,omitempty"`
Rake int64 `json:"rake,omitempty"`
Net int64 `json:"net"`
Double bool `json:"can_double"`
Split bool `json:"can_split"`
}
// spotView is one of the player's hands: its cards, its own chips, its own
// verdict. Before split there was only ever one and it was flattened into the
// hand itself; a split is the moment that stops being true.
type spotView struct {
Cards []cardView `json:"cards"`
Bet int64 `json:"bet"` Bet int64 `json:"bet"`
Player []cardView `json:"player"`
Dealer []cardView `json:"dealer"`
Hole bool `json:"hole"` // true: the dealer has a face-down card
Total int `json:"total"` Total int `json:"total"`
Soft bool `json:"soft"` Soft bool `json:"soft"`
DTotal int `json:"dealer_total"` // what the *shown* dealer cards add to Doubled bool `json:"doubled"`
Done bool `json:"done"`
Outcome string `json:"outcome,omitempty"` Outcome string `json:"outcome,omitempty"`
Payout int64 `json:"payout,omitempty"` Payout int64 `json:"payout,omitempty"`
Rake int64 `json:"rake,omitempty"`
Net int64 `json:"net"`
Double bool `json:"can_double"`
} }
func viewHand(st blackjack.State) handView { func viewHand(st blackjack.State) handView {
v := handView{ v := handView{
Phase: string(st.Phase), Phase: string(st.Phase),
Bet: st.Bet, Bet: st.Bet,
Active: st.Active,
Outcome: string(st.Outcome), Outcome: string(st.Outcome),
Payout: st.Payout, Payout: st.Payout,
Rake: st.Rake, Rake: st.Rake,
Net: st.Net(), Net: st.Net(),
Double: st.CanDouble(), Double: st.CanDouble(),
Split: st.CanSplit(),
} }
for _, c := range st.Player { for _, h := range st.Hands {
v.Player = append(v.Player, viewCard(c)) s := spotView{
Bet: h.Bet,
Doubled: h.Doubled,
Done: h.Done,
Outcome: string(h.Outcome),
Payout: h.Payout,
}
for _, c := range h.Cards {
s.Cards = append(s.Cards, viewCard(c))
}
s.Total, s.Soft = h.Value()
v.Hands = append(v.Hands, s)
} }
v.Total, v.Soft = blackjack.HandValue(st.Player)
dealer := st.Dealer dealer := st.Dealer
if st.Phase == blackjack.PhasePlayer && len(dealer) > 1 { if st.Phase == blackjack.PhasePlayer && len(dealer) > 1 {
@@ -139,6 +166,7 @@ func viewHand(st blackjack.State) handView {
type eventView struct { type eventView struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Card *cardView `json:"card,omitempty"` Card *cardView `json:"card,omitempty"`
Hand int `json:"hand"` // which of the player's hands the card landed on
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
} }
@@ -149,7 +177,7 @@ func viewEvents(evs []blackjack.Event, phase blackjack.Phase) []eventView {
out := make([]eventView, 0, len(evs)) out := make([]eventView, 0, len(evs))
dealerCards := 0 dealerCards := 0
for _, e := range evs { for _, e := range evs {
v := eventView{Kind: e.Kind, Text: e.Text} v := eventView{Kind: e.Kind, Text: e.Text, Hand: e.Hand}
if e.Card != nil { if e.Card != nil {
c := viewCard(*e.Card) c := viewCard(*e.Card)
v.Card = &c v.Card = &c
@@ -492,31 +520,45 @@ func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) {
move := blackjack.Move(req.Move) move := blackjack.Move(req.Move)
// A double doubles the stake, so the extra chips have to be taken before the // Double and split are the two moves that put more chips on the table *after*
// move is applied — and if they aren't there, the move simply isn't legal. // the cards are out, so the money has to move before the move does — and if the
// Take them first: if the engine then refuses the move, they go straight back. // chips aren't there, the move simply isn't legal. Take them first: if the
doubled := false // engine then refuses the move, they go straight back.
if move == blackjack.Double { //
// Both cost the active hand's bet, not the whole stake. Once a hand can be
// split those are different numbers, and doubling the third hand of a split for
// the total of all three would be quite a thing to discover in production.
var staked int64
switch move {
case blackjack.Double:
if !st.CanDouble() { if !st.CanDouble() {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only double on the first two cards"}) writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only double on the first two cards of a hand"})
return return
} }
if err := storage.Stake(user, st.Bet); err != nil { staked = st.DoubleCost()
case blackjack.Split:
if !st.CanSplit() {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only split two cards of the same rank, and only up to four hands"})
return
}
staked = st.SplitCost()
}
if staked > 0 {
if err := storage.Stake(user, staked); err != nil {
if errors.Is(err, storage.ErrInsufficientChips) { if errors.Is(err, storage.ErrInsufficientChips) {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to double"}) writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that"})
return return
} }
slog.Error("games: stake double", "user", user, "err", err) slog.Error("games: stake raise", "user", user, "move", move, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
doubled = true
} }
next, evs, err := blackjack.ApplyMove(st, move) next, evs, err := blackjack.ApplyMove(st, move)
if err != nil { if err != nil {
if doubled { if staked > 0 {
_ = storage.Award(user, st.Bet) // the move didn't happen; neither did the raise _ = storage.Award(user, staked) // the move didn't happen; neither did the raise
} }
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that move isn't legal here"}) writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that move isn't legal here"})
return return

View File

@@ -99,8 +99,8 @@ func TestDealTakesTheStakeAndHidesTheHoleCard(t *testing.T) {
if v.Hand == nil { if v.Hand == nil {
t.Fatal("deal returned no hand") t.Fatal("deal returned no hand")
} }
if len(v.Hand.Player) != 2 { if len(v.Hand.Hands[0].Cards) != 2 {
t.Fatalf("player was dealt %d cards, want 2", len(v.Hand.Player)) t.Fatalf("player was dealt %d cards, want 2", len(v.Hand.Hands[0].Cards))
} }
// A natural settles on the spot and legitimately shows both dealer cards. // A natural settles on the spot and legitimately shows both dealer cards.
@@ -181,12 +181,14 @@ func TestOneHandAtATime(t *testing.T) {
t.Fatalf("the live hand went missing: %v", err) t.Fatalf("the live hand went missing: %v", err)
} }
var st struct { var st struct {
Player []struct{} `json:"player"` Hands []struct {
Cards []struct{} `json:"cards"`
} `json:"hands"`
} }
if err := json.Unmarshal(live.State, &st); err != nil { if err := json.Unmarshal(live.State, &st); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(st.Player) != len(first.Hand.Player) { if len(st.Hands) != 1 || len(st.Hands[0].Cards) != len(first.Hand.Hands[0].Cards) {
t.Fatal("the refused deal replaced the hand in progress") t.Fatal("the refused deal replaced the hand in progress")
} }
} }

View File

@@ -95,7 +95,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
pages []string pages []string
}{ }{
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}}, {"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}}, {"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
} }
tpls := make(map[string]*template.Template) tpls := make(map[string]*template.Template)
for _, set := range sets { for _, set := range sets {

View File

@@ -767,6 +767,55 @@ html[data-phase="night"] {
} }
.pete-spot[data-live="1"] .pete-spot-label { opacity: 0; } .pete-spot[data-live="1"] .pete-spot-label { opacity: 0; }
/* The player's hands at blackjack. There is one of them, until you split.
Then there are up to four, side by side, each with its own chips on its own
spot — because after a split they are not one bet in two piles, they are two
bets that win and lose separately. */
.bj-hands {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem 1rem;
}
.bj-hand {
display: flex;
align-items: center;
gap: 0.85rem;
border-radius: 1.5rem;
padding: 0.4rem 0.7rem 0.4rem 0.4rem;
transition: opacity 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
}
/* Which hand you are actually playing. The rest are still on the table; they
are simply not your problem this second. */
.bj-hand[data-live="0"] { opacity: 0.5; }
.bj-hand[data-live="1"] {
background: rgba(0,0,0,0.16);
box-shadow: 0 0 0 2px rgba(var(--glow, 242,181,61), 0.5);
}
/* Four hands do not fit at one hand's worth of card, and the fix is to tell the
row what a card *is* rather than to squash the box a card is in: everything
about a card is sized off these two vars. The spot shrinks with them, or the
chips end up bigger than the cards they're riding on. */
.bj-hands[data-count="2"] { --card-h: 6.6rem; --card-w: 4.7rem; }
.bj-hands[data-count="3"],
.bj-hands[data-count="4"] { --card-h: 5rem; --card-w: 3.6rem; }
.bj-hands[data-count="2"] .pete-hand { min-height: 6.8rem; }
.bj-hands[data-count="3"] .pete-hand,
.bj-hands[data-count="4"] .pete-hand { min-height: 5.2rem; gap: 0.35rem; }
.bj-hands[data-count="2"] .pete-spot { height: 5.5rem; width: 5.5rem; }
.bj-hands[data-count="3"] .pete-spot,
.bj-hands[data-count="4"] .pete-spot { height: 4.25rem; width: 4.25rem; }
.bj-hands[data-count="3"] .pete-spot-label,
.bj-hands[data-count="4"] .pete-spot-label { display: none; }
/* The card a split is about to take away. It lifts, and then it's gone —
otherwise a card simply teleports into a hand that didn't exist yet. */
.bj-splitting { animation: bj-split-lift 0.32s ease forwards; }
@keyframes bj-split-lift {
from { transform: translateY(0) rotate(0); }
to { transform: translateY(-1.1rem) rotate(6deg); }
}
/* The stack in the spot. Each chip is offset up the pile by its index, so ten /* The stack in the spot. Each chip is offset up the pile by its index, so ten
chips look like ten chips rather than one chip with a number on it. */ chips look like ten chips rather than one chip with a number on it. */
.pete-stack { .pete-stack {

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,8 @@
// The blackjack table. // The blackjack table.
// //
// The browser holds no game. It sends intents — deal, hit, stand, double — and // The browser holds no game. It sends intents — deal, hit, stand, double, split
// the server answers with the cards you're allowed to see plus the *script* of // — and the server answers with the cards you're allowed to see plus the *script*
// how they got there: one event per card off the shoe, in the order the shoe // of how they got there: one event per card off the shoe, in the order the shoe
// gave them up. This file's job is to play that script back at a human speed // gave them up. This file's job is to play that script back at a human speed
// rather than snapping the finished hand into place. // rather than snapping the finished hand into place.
// //
@@ -16,6 +16,10 @@
// taken away. Nothing about the money changes on this table without something // taken away. Nothing about the money changes on this table without something
// crossing the felt to make it change — including the count in the chip bar, // crossing the felt to make it change — including the count in the chip bar,
// which is deliberately not updated until the chips that justify it have landed. // which is deliberately not updated until the chips that justify it have landed.
//
// Since split, "the spot" is not one place. A hand has a spot, and a split makes
// a second hand with a second bet on a second spot — two bets that win and lose
// separately, which is the whole point of splitting and has to look like it.
(function () { (function () {
"use strict"; "use strict";
@@ -25,9 +29,7 @@
var FX = window.PeteFX; var FX = window.PeteFX;
var dealerEl = root.querySelector("[data-dealer]"); var dealerEl = root.querySelector("[data-dealer]");
var playerEl = root.querySelector("[data-player]");
var dTotalEl = root.querySelector("[data-dealer-total]"); var dTotalEl = root.querySelector("[data-dealer-total]");
var pTotalEl = root.querySelector("[data-player-total]");
var dLabelEl = root.querySelector("[data-dealer-label]"); var dLabelEl = root.querySelector("[data-dealer-label]");
var verdictEl = root.querySelector("[data-verdict]"); var verdictEl = root.querySelector("[data-verdict]");
var betting = root.querySelector("[data-betting]"); var betting = root.querySelector("[data-betting]");
@@ -36,26 +38,22 @@
var dealBtn = root.querySelector("[data-deal]"); var dealBtn = root.querySelector("[data-deal]");
var msgEl = root.querySelector("[data-table-msg]"); var msgEl = root.querySelector("[data-table-msg]");
// The three places a chip can be: your pile (in the bar above), the spot in // Where the chips live: your pile (in the bar above), a spot in front of each
// front of you, and the house's rack on the felt. // hand, and the house's rack on the felt.
var purseEl = document.querySelector("[data-chips]"); var purseEl = document.querySelector("[data-chips]");
var spotEl = root.querySelector("[data-spot]");
var stackEl = root.querySelector("[data-stack]");
var spotTotalEl = root.querySelector("[data-spot-total]");
var houseEl = root.querySelector("[data-house]"); var houseEl = root.querySelector("[data-house]");
var handsEl = root.querySelector("[data-hands]");
// The spot owns the chips on the felt and the number under them — see PeteFX. var handTpl = root.querySelector("[data-hand-template]");
// Nothing is bet until a chip is on it, so `bet` starts at nothing rather than
// at a default stake nobody put down.
var spot = FX.spot({ spot: spotEl, stack: stackEl, total: spotTotalEl });
var bet = 0; // what you're building between hands var bet = 0; // what you're building between hands
var base = 0; // what one hand of the last deal cost, for standing it back up
var busy = false; // a request is in flight, or cards are still landing var busy = false; // a request is in flight, or cards are still landing
var hand = null; // the hand as the server last described it var hand = null; // the deal as the server last described it
var DEAL_MS = 380; // one card's flight, and the gap before the next var DEAL_MS = 380; // one card's flight, and the gap before the next
var FLIP_MS = 450; var FLIP_MS = 450;
var BEAT_MS = 600; // the dealer thinking before they draw out var BEAT_MS = 600; // the dealer thinking before they draw out
var SPLIT_MS = 340; // the card lifting out of the hand it's leaving
var reduced = FX.reduced; var reduced = FX.reduced;
function pace(ms) { return reduced ? 0 : ms; } function pace(ms) { return reduced ? 0 : ms; }
@@ -80,46 +78,137 @@
function cardEl(face) { return CARDS.el(face); } function cardEl(face) { return CARDS.el(face); }
var turnOver = CARDS.turnOver; var turnOver = CARDS.turnOver;
// ---- the hands -------------------------------------------------------------
//
// A box per hand: its chips, its cards, its total, its own verdict. There is
// always at least one, and before a deal that one is where you build your bet —
// so the chips you stack are already sitting on the hand they're about to buy.
var hands = [];
function makeHand(at) {
var el = handTpl.content.firstElementChild.cloneNode(true);
var spotEl = el.querySelector("[data-spot]");
var box = {
el: el,
spotEl: spotEl, // where a chip flies to, as opposed to what counts it
cards: el.querySelector("[data-cards]"),
total: el.querySelector("[data-total]"),
verdict: el.querySelector("[data-hand-outcome]"),
ranks: [], // what's in it, for a running total while the cards are landing
spot: FX.spot({
spot: spotEl,
stack: el.querySelector("[data-stack]"),
total: el.querySelector("[data-spot-total]"),
}),
};
if (at === undefined || at >= hands.length) {
handsEl.appendChild(el);
hands.push(box);
} else {
handsEl.insertBefore(el, hands[at].el);
hands.splice(at, 0, box);
}
handsEl.dataset.count = hands.length;
return box;
}
// reset tears the row down to n empty hands. It does not preserve chips: a
// caller that has some to keep (a deal, whose stake is already on the spot)
// reads the amount first and puts it back.
function reset(n) {
handsEl.innerHTML = "";
hands = [];
for (var i = 0; i < (n || 1); i++) makeHand();
}
function ensure(n) { while (hands.length < n) makeHand(); }
// The bet you build between hands is hand zero's.
function betSpot() { return hands[0].spot; }
// live marks the hand you're being asked about, and dims the ones you aren't.
//
// -1 means nobody is acting — the dealer is drawing, or the deal is over — and
// that is *not* the same as every hand being inactive: a settled hand is the
// thing you are reading, so it goes back to full brightness rather than sitting
// there greyed out. Hence the empty string: it matches neither CSS rule.
function live(i) {
hands.forEach(function (h, k) {
h.el.dataset.live = i < 0 ? "" : k === i ? "1" : "0";
});
}
// The running total under a hand, worked out from the cards you can see. The
// server sends totals too, but only with the finished state — and a hand you're
// playing needs to know what it's holding *now*, not once it's over. It's a
// readout, never a decision: every ruling is the server's.
var TEN = { K: 10, Q: 10, J: 10 };
function totalOf(ranks) {
var total = 0, aces = 0;
ranks.forEach(function (r) {
if (r === "A") { aces++; total += 11; }
else total += TEN[r] || parseInt(r, 10) || 0;
});
while (total > 21 && aces > 0) { total -= 10; aces--; }
return { total: total, soft: aces > 0 };
}
function showTotal(box) {
if (!box.ranks.length) { box.total.classList.add("hidden"); return; }
var v = totalOf(box.ranks);
box.total.textContent = v.total + (v.soft ? " (soft)" : "");
box.total.classList.remove("hidden");
}
var SHORT = {
blackjack: "Blackjack", win: "Won", dealer_bust: "Won",
lose: "Lost", bust: "Bust", push: "Push",
};
// A hand's own result, on the hand. With one hand this says the same thing as
// the pill above it and is a bit redundant; with four it is the only way to
// read what happened, because "you win" is not true of all of them.
function showHandVerdict(box, h) {
var text = h && SHORT[h.outcome];
if (!text) { box.verdict.classList.add("hidden"); return; }
box.verdict.textContent = text;
box.verdict.classList.remove("hidden");
}
// ---- the money on the felt ------------------------------------------------- // ---- the money on the felt -------------------------------------------------
// stake moves chips from your pile onto the spot: the bet you build before a
// deal, and the second bet a double puts down beside it.
function stake(amount, from) {
return spot.pour(from || purseEl, amount);
}
// settleChips is what the felt does about the outcome, after the cards have // settleChips is what the felt does about the outcome, after the cards have
// finished telling you what it is. It reads the same two numbers the ledger // finished telling you what it is. Each hand is paid on its own, in order, left
// moved: `bet` (already off your pile since the deal) and `payout` (what comes // to right — because each hand *is* its own bet, and a split that lost one and
// back — stake plus winnings less rake, or nothing at all). // won the other has to be watchable as exactly that.
function settleChips(final) { function settleChips(final) {
var payout = final.payout || 0; var chain = Promise.resolve();
var back = payout - final.bet; // what the house is adding, if anything (final.hands || []).forEach(function (h, i) {
chain = chain.then(function () {
var box = hands[i];
if (!box) return;
var payout = h.payout || 0;
if (payout <= 0) { if (payout <= 0) {
// The house takes it. The stack goes to the rack and doesn't come back. // The house takes it. The stack goes to the rack and doesn't come back.
return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true }); return box.spot.sweep(houseEl, h.bet, { gap: 45, lift: 0.6, fade: true });
} }
// The house pays first, into the spot beside the stake, so you watch the
// The house pays first, into the spot beside your stake, so you watch the // winnings arrive on top of the bet that earned them.
// winnings arrive on top of the bet that earned them. var back = payout - h.bet;
return spot return box.spot
.pour(houseEl, back, { gap: 60 }) .pour(houseEl, back, { gap: 60 })
.then(function () { return wait(back > 0 ? 380 : 200); }) .then(function () { return wait(back > 0 ? 340 : 160); })
// Paid, then swept up: the whole lot comes back to your pile, and only then .then(function () { return box.spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
// does the number in the bar move. });
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); }); });
return chain;
} }
function totals(v) { // While the hole card is down, the dealer's total is only what's showing — so
if (v.total) { // say so, rather than printing a number that quietly means something else.
pTotalEl.textContent = v.total + (v.soft ? " (soft)" : ""); function dealerTotal(v) {
pTotalEl.classList.remove("hidden");
} else {
pTotalEl.classList.add("hidden");
}
// While the hole card is down, the dealer's total is only what's showing —
// so say so, rather than printing a number that quietly means something else.
if (v.dealer && v.dealer.length) { if (v.dealer && v.dealer.length) {
dTotalEl.textContent = v.hole ? v.dealer_total + " showing" : String(v.dealer_total); dTotalEl.textContent = v.hole ? v.dealer_total + " showing" : String(v.dealer_total);
dTotalEl.classList.remove("hidden"); dTotalEl.classList.remove("hidden");
@@ -128,19 +217,28 @@
} }
} }
// paint puts a hand on the felt with no animation. This is the resume path: // paint puts a deal on the felt with no animation. This is the resume path: you
// you reloaded, or Pete restarted, and your cards are simply there — including // reloaded, or Pete restarted, and your cards are simply there — including the
// the stake, which is still on the spot because the server still has it. // stakes, which are still on their spots because the server still has them.
function paint(v) { function paint(v) {
dealerEl.innerHTML = ""; dealerEl.innerHTML = "";
playerEl.innerHTML = ""; if (!v) { reset(1); setPhase(null); return; }
if (!v) { setPhase(null); spot.render(0); return; }
reset(v.hands.length || 1);
v.hands.forEach(function (h, i) {
var box = hands[i];
(h.cards || []).forEach(function (c) {
box.cards.appendChild(cardEl(c));
box.ranks.push(c.rank);
});
box.spot.render(v.phase === "done" ? 0 : h.bet);
showTotal(box);
showHandVerdict(box, v.phase === "done" ? h : null);
});
v.player.forEach(function (c) { playerEl.appendChild(cardEl(c)); });
v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); }); v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); });
if (v.hole) dealerEl.appendChild(cardEl(null)); if (v.hole) dealerEl.appendChild(cardEl(null));
spot.render(v.phase === "done" ? 0 : v.bet); dealerTotal(v);
totals(v);
setPhase(v); setPhase(v);
} }
@@ -155,12 +253,17 @@
function verdict(v) { function verdict(v) {
var text = VERDICTS[v.outcome] || ""; var text = VERDICTS[v.outcome] || "";
// Across several hands "you win" is a claim about the money, not about the
// cards: you can win one, lose one, and still be up. The pill reports the
// deal; the badge on each hand reports the hand.
if ((v.hands || []).length > 1) {
text = v.net > 0 ? "You're up on the deal." : v.net < 0 ? "Down on the deal." : "All square.";
}
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();
verdictEl.textContent = text; verdictEl.textContent = text;
verdictEl.classList.remove("hidden"); verdictEl.classList.remove("hidden");
playerEl.dataset.won = v.net > 0 ? "1" : v.net < 0 ? "-1" : "0";
// The one thing in this room that gets confetti. A natural is rare, it pays // The one thing in this room that gets confetti. A natural is rare, it pays
// 3:2, and if everything celebrated then nothing would. // 3:2, and if everything celebrated then nothing would.
@@ -176,15 +279,20 @@
// setPhase swaps the controls: bet between hands, act during one. // setPhase swaps the controls: bet between hands, act during one.
function setPhase(v) { function setPhase(v) {
hand = v; hand = v;
var live = !!v && v.phase === "player"; var acting = !!v && v.phase === "player";
betting.classList.toggle("hidden", live); betting.classList.toggle("hidden", acting);
actions.classList.toggle("hidden", !live); actions.classList.toggle("hidden", !acting);
if (live) { if (acting) {
var dbl = actions.querySelector('[data-move="double"]'); var dbl = actions.querySelector('[data-move="double"]');
var spl = actions.querySelector('[data-move="split"]');
if (dbl) dbl.disabled = !v.can_double; if (dbl) dbl.disabled = !v.can_double;
if (spl) spl.disabled = !v.can_split;
live(v.active || 0);
} else {
live(-1);
} }
if (!v || v.phase !== "player") verdictEl.classList.toggle("hidden", !(v && v.outcome)); if (!acting) verdictEl.classList.toggle("hidden", !(v && v.outcome));
} }
// ---- the script ----------------------------------------------------------- // ---- the script -----------------------------------------------------------
@@ -209,38 +317,77 @@
if (!settles) money(); if (!settles) money();
// Whatever the server says the stake is, that's what has to be on the spot. // A deal whose bet was typed rather than stacked (you kept last hand's number
// Two things get here: a double, which puts a second bet down beside the // and just pressed Deal) has chips to put down before the first card does.
// first, and a deal whose bet was typed rather than stacked (you kept last // Everything else that costs money — a double, a split — announces itself as
// hand's number and just pressed Deal). Either way the chips go down before // an event, and pays for itself there.
// the card they're buying does. if (final && final.hands && final.hands.length && events.length && events[0].kind === "deal") {
if (final && final.bet > spot.amount) { var want = final.hands[0].bet;
var extra = final.bet - spot.amount; var have = betSpot().amount;
chain = chain.then(function () { return stake(extra); }); if (want > have) {
chain = chain.then(function () { return betSpot().pour(purseEl, want - have); });
}
} }
events.forEach(function (e) { events.forEach(function (e) {
chain = chain.then(function () { chain = chain.then(function () {
var box;
switch (e.kind) { switch (e.kind) {
case "deal": case "deal":
// Clear the felt, but not the stake: those chips are yours, they are
// already on the spot, and they are what this deal is riding on.
var staked = betSpot().amount;
dealerEl.innerHTML = ""; dealerEl.innerHTML = "";
playerEl.innerHTML = ""; reset(1);
playerEl.dataset.won = "0"; betSpot().render(staked);
verdictEl.classList.add("hidden"); verdictEl.classList.add("hidden");
FX.sfx("shuffle"); FX.sfx("shuffle");
return; return;
case "player_card": case "player_card":
playerEl.appendChild(cardEl(e.card)); ensure(e.hand + 1);
box = hands[e.hand];
box.cards.appendChild(cardEl(e.card));
box.ranks.push(e.card.rank);
showTotal(box);
live(e.hand);
FX.sfx("deal"); FX.sfx("deal");
return wait(DEAL_MS); return wait(DEAL_MS);
case "split":
// The second card lifts out of the hand it was in and becomes a hand
// of its own, and the bet that follows it is a second bet: the same
// chips again, out of your pile, onto a spot that didn't exist a
// moment ago.
var src = hands[e.hand];
var moved = src.cards.lastElementChild;
var stake = src.spot.amount;
var fresh = makeHand(e.hand + 1);
if (moved) moved.classList.add("bj-splitting");
FX.sfx("deal", { v: 1 });
return wait(SPLIT_MS).then(function () {
if (moved) {
moved.classList.remove("bj-splitting");
fresh.cards.appendChild(moved);
}
fresh.ranks.push(src.ranks.pop());
showTotal(src);
showTotal(fresh);
return fresh.spot.pour(purseEl, stake);
});
case "double":
// The stake goes down again on that hand, and only that hand.
box = hands[e.hand];
return box.spot.pour(purseEl, box.spot.amount);
case "dealer_card": case "dealer_card":
// The dealer takes a moment before the first card they draw out. // The dealer takes a moment before the first card they draw out.
// Card, card, card with no breath in between is a machine dealing; // Card, card, card with no breath in between is a machine dealing;
// the pause is the only thing on this table that plays as suspense. // the pause is the only thing on this table that plays as suspense.
var beat = drew ? Promise.resolve() : think(); var beat = drew ? Promise.resolve() : think();
drew = true; drew = true;
live(-1);
return beat.then(function () { return beat.then(function () {
dealerEl.appendChild(cardEl(e.card)); dealerEl.appendChild(cardEl(e.card));
FX.sfx("deal", { v: 1 }); FX.sfx("deal", { v: 1 });
@@ -256,6 +403,7 @@
case "reveal": case "reveal":
// The hole card turns over. Its face is in the final hand — this is // The hole card turns over. Its face is in the final hand — this is
// the first moment the server has been willing to say what it was. // the first moment the server has been willing to say what it was.
live(-1);
if (!hole) hole = dealerEl.querySelector('.pete-card[data-face="down"]'); if (!hole) hole = dealerEl.querySelector('.pete-card[data-face="down"]');
if (hole && final && final.dealer && final.dealer[1]) { if (hole && final && final.dealer && final.dealer[1]) {
turnOver(hole, final.dealer[1]); turnOver(hole, final.dealer[1]);
@@ -271,12 +419,16 @@
return chain.then(function () { return chain.then(function () {
if (!final) { paint(null); money(); return; } if (!final) { paint(null); money(); return; }
totals(final); dealerTotal(final);
if (!settles) { setPhase(final); return; } if (!settles) { setPhase(final); return; }
// The hand is over: nothing is on offer while the money is moving. Hit and // The deal is over: nothing is on offer while the money is moving. The
// Stand go now, and Deal comes back at the far end. // actions go now, and Deal comes back at the far end.
actions.classList.add("hidden"); actions.classList.add("hidden");
live(-1);
(final.hands || []).forEach(function (h, i) {
if (hands[i]) showHandVerdict(hands[i], h);
});
verdict(final); verdict(final);
// The chips move, and the bar catches up with them when they arrive. The // The chips move, and the bar catches up with them when they arrive. The
// betting controls come back last, once the felt is clear: offering Deal // betting controls come back last, once the felt is clear: offering Deal
@@ -284,16 +436,30 @@
// has to refuse. // has to refuse.
return settleChips(final) return settleChips(final)
.then(money) .then(money)
.then(function () { return standing(final.bet); }) .then(function () { return standing(base || unitBet(final.hands[0])); })
.then(function () { setPhase(final); }); .then(function () { setPhase(final); });
}); });
} }
// standing leaves your bet up for the next hand, the way you would at a table: // standing leaves your bet up for the next hand, the way you would at a table:
// the stake that just settled goes straight back on the spot. It costs nothing // one hand's stake goes straight back on the spot. It costs nothing — chips on
// — chips on the spot are a proposal until you press Deal — and it's what keeps // the spot are a proposal until you press Deal — and it's what keeps the number
// the number in the panel honest, because otherwise a settled hand leaves // in the panel honest, because otherwise a settled hand leaves "your bet: 300"
// "your bet: 300" printed over an empty spot. // printed over an empty spot.
//
// It's one hand's worth, not the whole deal's: a split cost you four hundred,
// and standing four hundred back up on one spot would be betting a stranger's
// money on your behalf.
// unitBet is what one hand of a deal actually cost, read back off a settled
// hand. It exists for the reload: `base` only knows what you pressed Deal with,
// and if you reloaded mid-hand nobody pressed Deal. A doubled hand carries twice
// the stake it was dealt with, so standing its bet back up would quietly put you
// on double the number you thought you were playing.
function unitBet(h) {
if (!h) return 0;
return h.doubled ? h.bet / 2 : h.bet;
}
function standing(amount) { function standing(amount) {
var money = window.PeteGames.view(); var money = window.PeteGames.view();
if (!amount || !money || money.chips < amount) { if (!amount || !money || money.chips < amount) {
@@ -303,7 +469,7 @@
} }
bet = amount; bet = amount;
showBet(); showBet();
return stake(amount); return betSpot().pour(purseEl, amount);
} }
// think is the dealer's beat: a pause with something to look at, so it reads as // think is the dealer's beat: a pause with something to look at, so it reads as
@@ -331,7 +497,8 @@
say(err.message, "bad"); say(err.message, "bad");
// Whatever we thought was on the felt, the server is the authority on it. // Whatever we thought was on the felt, the server is the authority on it.
return window.PeteGames.refresh().then(function (v) { return window.PeteGames.refresh().then(function (v) {
if (v && !v.hand) spot.render(0); if (v && !v.hand) { reset(1); setPhase(null); }
else if (v && v.hand) paint(v.hand);
}); });
}) })
.then(function () { busy = false; }); .then(function () { busy = false; });
@@ -368,8 +535,9 @@
// but the spot's total moves now, so a Deal pressed mid-flight still knows // but the spot's total moves now, so a Deal pressed mid-flight still knows
// the chip is on its way and doesn't put a second one down. // the chip is on its way and doesn't put a second one down.
var target = bet; var target = bet;
var spot = betSpot();
spot.amount = bet; spot.amount = bet;
FX.fly(btn, spotEl, { denom: d }).then(function () { FX.fly(btn, hands[0].spotEl, { denom: d }).then(function () {
if (bet >= target) spot.render(target); // unless Clear got there first if (bet >= target) spot.render(target); // unless Clear got there first
}); });
}); });
@@ -378,8 +546,8 @@
var clearBtn = root.querySelector("[data-bet-clear]"); var clearBtn = root.querySelector("[data-bet-clear]");
if (clearBtn) { if (clearBtn) {
clearBtn.addEventListener("click", function () { clearBtn.addEventListener("click", function () {
if (busy || !spot.amount) { bet = 0; showBet(); return; } if (busy || !betSpot().amount) { bet = 0; showBet(); return; }
spot.sweep(purseEl, null, { gap: 40, lift: 0.7 }); betSpot().sweep(purseEl, null, { gap: 40, lift: 0.7 });
bet = 0; bet = 0;
showBet(); showBet();
}); });
@@ -388,6 +556,7 @@
if (dealBtn) { if (dealBtn) {
dealBtn.addEventListener("click", function () { dealBtn.addEventListener("click", function () {
if (bet <= 0) { say("Put something on it first.", "bad"); return; } if (bet <= 0) { say("Put something on it first.", "bad"); return; }
base = bet; // one hand's worth, which is what a split doubles and a stand puts back
send("/api/games/blackjack/deal", { bet: bet }); send("/api/games/blackjack/deal", { bet: bet });
}); });
} }
@@ -403,15 +572,17 @@
if (/^(input|textarea|select)$/i.test((e.target.tagName || ""))) return; if (/^(input|textarea|select)$/i.test((e.target.tagName || ""))) return;
if (!hand || hand.phase !== "player" || busy) return; if (!hand || hand.phase !== "player" || busy) return;
var move = { h: "hit", s: "stand", d: "double" }[e.key.toLowerCase()]; var move = { h: "hit", s: "stand", d: "double", p: "split" }[e.key.toLowerCase()];
if (!move) return; if (!move) return;
if (move === "double" && !hand.can_double) return; if (move === "double" && !hand.can_double) return;
if (move === "split" && !hand.can_split) return;
e.preventDefault(); e.preventDefault();
send("/api/games/blackjack/move", { move: move }); send("/api/games/blackjack/move", { move: move });
}); });
// The money bar owns the first fetch; the table picks up whatever it found, // The money bar owns the first fetch; the table picks up whatever it found,
// including a hand left sitting on the felt by a reload or a redeploy. // including a deal left sitting on the felt by a reload or a redeploy.
reset(1);
var resumed = false; var resumed = false;
window.PeteGames.onUpdate(function (v) { window.PeteGames.onUpdate(function (v) {
if (!resumed) { if (!resumed) {

View File

@@ -51,21 +51,36 @@
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p> <p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
</div> </div>
<!-- Player: the bet sits in front of the cards it's riding on. --> <!-- Player: the bet sits in front of the cards it's riding on.
The hands themselves are built by blackjack.js, because how many there
are is a thing that changes mid-hand: you split, and one hand with one
bet on it becomes two hands with a bet each. The template below is the
shape of one of them; the row starts with exactly one, which is also
the spot you build your bet on before any cards exist. -->
<div> <div>
<div class="mb-2 flex items-center gap-2"> <div class="mb-2 flex items-center gap-2">
<span class="text-xs font-bold uppercase tracking-wider text-white/60">You</span> <span class="text-xs font-bold uppercase tracking-wider text-white/60">You</span>
<span data-player-total class="hidden rounded-full bg-black/25 px-2.5 py-0.5 text-xs font-bold tabular-nums text-white"></span>
</div> </div>
<div class="flex items-center gap-5"> <div class="bj-hands" data-hands data-count="1" aria-live="polite"></div>
</div>
<template data-hand-template>
<div class="bj-hand" data-hand data-live="1">
<div class="pete-spot" data-spot> <div class="pete-spot" data-spot>
<span class="pete-spot-label">Bet</span> <span class="pete-spot-label">Bet</span>
<div class="pete-stack" data-stack></div> <div class="pete-stack" data-stack></div>
<span data-spot-total class="pete-spot-total hidden"></span> <span data-spot-total class="pete-spot-total hidden"></span>
</div> </div>
<div data-player class="pete-hand flex-1" aria-live="polite"></div> <div class="min-w-0">
<div class="mb-1 flex h-5 items-center gap-1.5">
<span data-total class="hidden rounded-full bg-black/25 px-2.5 py-0.5 text-xs font-bold tabular-nums text-white"></span>
<span data-hand-outcome class="hidden rounded-full bg-white/90 px-2 py-0.5 text-[0.65rem] font-bold uppercase tracking-wide text-[#2b2118]"></span>
</div>
<div data-cards class="pete-hand"></div>
</div>
</div> </div>
</div> </template>
</div> </div>
</section> </section>
@@ -116,9 +131,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">
Double Double
</button> </button>
<button type="button" data-move="split"
class="rounded-full bg-[color:var(--card)] px-8 py-3 font-display text-lg font-bold shadow-pete border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Split
</button>
</div> </div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40"> <p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
<kbd>h</kbd> hit · <kbd>s</kbd> stand · <kbd>d</kbd> double <kbd>h</kbd> hit · <kbd>s</kbd> stand · <kbd>d</kbd> double · <kbd>p</kbd> split
</p> </p>
</section> </section>

View File

@@ -0,0 +1,70 @@
{{define "title"}}{{.Room.Name}}{{end}}
{{/* The front door: the only page in here a stranger is allowed to see.
It exists because a link has to unfurl into something, and because a casino
whose first move is to shove you at an auth screen is a casino you'd assume
was broken. Nothing on this page can be played — every table below sends you
through sign-in first, and comes back to the table you picked. */}}
{{define "main"}}
<div class="space-y-8">
<section class="rounded-3xl bg-[color:var(--card)] p-6 sm:p-10 shadow-pete border-2 border-[color:var(--ink)]/10">
{{/* No decoration in here on purpose. The chip stacks want the casino's JS,
which the door doesn't load, and an emoji big enough to balance the hero
is an emoji that isn't on every machine. The share card carries the
pictures; the door carries the way in. */}}
<div class="flex flex-wrap items-center justify-between gap-6">
<div class="min-w-0 max-w-2xl">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[color:var(--accent)]">The doors are open</p>
<h1 class="mt-2 font-display text-3xl sm:text-5xl font-bold">{{.Room.Name}}</h1>
<p class="mt-3 text-[color:var(--ink)]/70">
Six tables, played against the house and its bots, for the same euros you
already have. A chip is worth a euro, the house takes {{.RakePct}}% of what you
win and nothing at all when you lose, and whatever you don't spend comes
back when you cash out.
</p>
<div class="mt-6 flex flex-wrap items-center gap-3">
<a href="/auth/login?next=/games"
class="inline-flex items-center gap-2 rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-[#20180c] shadow-pete hover:-translate-y-0.5 hover:shadow-pete-lg transition">
Sign in and sit down
</a>
<span class="text-sm text-[color:var(--ink)]/45">You'll need a parodia.dev account.</span>
</div>
</div>
</div>
</section>
<section>
<h2 class="font-display text-2xl font-bold mb-4">The tables</h2>
<div class="grid gap-4 sm:grid-cols-2">
{{template "_door_table" dict "Href" "/games/blackjack" "Emoji" "🃏" "Name" "Blackjack" "Line" "Six decks, blackjack pays 3:2."}}
{{template "_door_table" dict "Href" "/games/holdem" "Emoji" "♠️" "Name" "Texas hold'em" "Line" "A cash game against bots that were trained on it."}}
{{template "_door_table" dict "Href" "/games/uno" "Emoji" "🎴" "Name" "UNO" "Line" "Three tables, and a No Mercy deck that bites."}}
{{template "_door_table" dict "Href" "/games/trivia" "Emoji" "🧠" "Name" "Trivia" "Line" "A ladder. Climb it or bank it."}}
{{template "_door_table" dict "Href" "/games/hangman" "Emoji" "🔤" "Name" "Hangman" "Line" "Longer words pay more, for the obvious reason."}}
{{template "_door_table" dict "Href" "/games/solitaire" "Emoji" "🂡" "Name" "Solitaire" "Line" "Klondike, and the deal you pick sets the price."}}
</div>
</section>
<p class="pb-2 text-center text-sm text-[color:var(--ink)]/40">
{{if eq .Room.Slug "casinopolis"}}The lights come on at six, and the place changes its name.
{{else}}It goes back to being Casinopolis at six in the morning.{{end}}
</p>
</div>
{{end}}
{{define "_door_table"}}
<a href="{{.Href}}"
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">{{.Emoji}}</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold">{{.Name}}</h3>
<p class="text-sm text-[color:var(--ink)]/60">{{.Line}}</p>
</div>
</div>
</a>
{{end}}

View File

@@ -5,6 +5,23 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}{{.Room.Name}}{{end}}</title> <title>{{block "title" .}}{{.Room.Name}}{{end}}</title>
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
{{/* What the casino looks like when somebody pastes it into a chat window.
noindex keeps it out of a search engine and does not keep it out of a link
preview: those are two different bots and only one of them is reading this.
The image is drawn by games_og.go and follows the clock, so a link shared
after six unfurls with the neon on. */}}
<meta property="og:type" content="website">
<meta property="og:site_name" content="{{.Room.Name}}">
<meta property="og:title" content="{{block "og_title" .}}{{.Room.Name}}{{end}}">
<meta property="og:description" content="{{block "og_desc" .}}Blackjack, hold'em, UNO, trivia, hangman and solitaire, played for real gogobee euros. Sign in and pull up a chair.{{end}}">
<meta property="og:url" content="{{.URL}}">
<meta property="og:image" content="{{.OGImage}}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{{.Room.Name}}: two cards and a stack of chips on the felt.">
<meta name="twitter:card" content="summary_large_image">
<meta name="description" content="Blackjack, hold'em, UNO, trivia, hangman and solitaire, played for real gogobee euros.">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
@@ -61,6 +78,12 @@
<span class="grid h-6 w-6 place-items-center rounded-full bg-[color:var(--accent)] text-xs font-bold text-[#20180c]">{{.User.Initial}}</span> <span class="grid h-6 w-6 place-items-center rounded-full bg-[color:var(--accent)] text-xs font-bold text-[#20180c]">{{.User.Initial}}</span>
<span class="hidden sm:inline max-w-[7rem] truncate">{{.User.Display}}</span> <span class="hidden sm:inline max-w-[7rem] truncate">{{.User.Display}}</span>
</a> </a>
{{else}}
{{/* Nobody is signed in, which on any page but the front door can't happen. */}}
<a href="/auth/login?next=/games"
class="inline-flex shrink-0 items-center rounded-full bg-[color:var(--accent)] px-4 py-1.5 text-sm font-bold text-[#20180c] shadow-pete hover:-translate-y-0.5 transition">
Sign in
</a>
{{end}} {{end}}
</div> </div>
</header> </header>

View File

@@ -69,8 +69,53 @@ guessed: **No Mercy is easier than UNO at every table size** (naive wins 46.7% v
*bots* too, and every bot it buries is one fewer seat that can beat you to the last *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. 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 ### What this session did (2026-07-14, later)
you count the No Mercy dial) are on main and have never been deployed.
**The casino had no share card, and meta tags would not have fixed it.** Every
route was behind `requirePlayer`, so a link pasted into a chat window got a 302
to sign-in and unfurled as the word "parodia.dev" — no title, no image. Tags on a
page a stranger cannot fetch are tags nobody reads. The casino now has a **front
door**: `/games` serves a real page to anybody, with the sign-in button and the
six tables on it. The tables still bounce you to sign-in; you cannot play from
the door. The **share card is drawn in Go** (`games_og.go`, `/games/og.png`,
public) and follows the clock like everything else — Casinopolis on green felt by
day, Casino Night Zone in neon after six — except the clock that decides is the
*server's*, because an unfurl bot has no evening of its own. *(`7ca1f7a`)*
Two things from building it worth not re-learning. **`color.RGBA` is
alpha-premultiplied**: the lamp over the table wrote raw channels beside a low
alpha, `image/draw` ran the result past 255 and wrapped the hue, and the first
card came out with a blue dome over a green stripe. If a colour comes out
impossible, look for a missing premultiply. And **an `og:image` has to be an
absolute URL that actually resolves** — which is two different addresses
depending on how you arrived (`/og.png` on the games host, where hostRouter puts
the `/games` back on; `/games/og.png` anywhere else). The test now reads the URL
off the page and goes and fetches it, on both hosts.
**Blackjack has a split.** *(`6f34a89`)* `State.Player` is gone: there is a slice
of `Hands`, each with its own cards, bet, outcome and payout, played left to
right. It is the only move in the game that takes chips *after* the cards are
out, and that is where the bugs live — `handleMove` was staking `st.Bet` for a
double, which was the same number as the hand's bet until today and is now the
whole table's, so doubling the third hand of a split would have charged you for
all three. `DoubleCost`/`SplitCost` are the active hand's. Each hand is **raked
on its own winnings**: netting them first would mean winning one and losing one
costs no rake at all, which is a discount for splitting rather than a rake. The
rules that cost real money if guessed: split aces get one card each and no say;
**21 on a split hand is not a natural** and does not pay 3:2; same rank, not same
value (K+Q is not a pair); four hands max; double after split is allowed; and if
every hand busts the dealer never turns over. `State.UnmarshalJSON` still reads
pre-split blobs, because a live hand outlives a deploy and a player mid-hand at
restart would otherwise be a player whose cards vanished.
### Deployed, as of right now
The live box (`/opt/pete`) is on **`03524ae`**, and main is three commits past it.
Undeployed: the **UNO call-uno + live hand redraw** fix (`39ed293`), the **front
door and share card** (`7ca1f7a`), and **blackjack's split** (`6f34a89`). The UNO
one is a bug that is live right now: your own hand doesn't redraw during a lap.
The user has seen this list and chose to hold the deploy for now — *ask before
assuming it went out.*
### Decisions taken (these close §9's open questions) ### Decisions taken (these close §9's open questions)
@@ -603,18 +648,18 @@ you count the No Mercy dial) are on main and have never been deployed.
### Next, in order ### Next, in order
1. **Nothing is queued.** Every game in the header line is built, played, and 1. **A deploy is owed.** Three commits are on main and not on the box (see
deployed. What is left is the open list below — none of it blocking, none of it "Deployed, as of right now" above), one of them a live UNO bug. Everything is
promised. built and browser-verified; nothing is half-done.
2. The obvious candidates, in the order I'd take them: **blackjack has no split**; 2. Then the open list, none of it blocking, none of it promised: **hold'em has no
**hold'em has no multiway policy**; and the trivia bank refills on a 12h tick multiway policy**; the trivia bank refills on a 12h tick (`games: trivia bank
(`games: trivia bank refill started target=400`), so a player trying a ladder in refill started target=400`), so a player trying a ladder in the first minute
the first minute after a fresh deploy can still meet a 503. after a fresh deploy can still meet a 503.
Still open on hold'em, none of it blocking: the policy is **heads-up**, so a Still open on hold'em: the policy is **heads-up**, so a six-handed table is an
six-handed table is an approximation of it (the hit rate falls from 95% to about 17% approximation of it (the hit rate falls from 95% to about 17% at six seats, and
at six seats, and the rest is pot odds) — a multiway policy would want its own the rest is pot odds) — a multiway policy would want its own training run with
training run with more than two seats in the tree. Blackjack still has no **split**. more than two seats in the tree. Blackjack's split is **done**.
### How the browser half fits together ### How the browser half fits together