Compare commits
4 Commits
39ed293f4f
...
a5b7e41929
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5b7e41929 | ||
|
|
57c445ff29 | ||
|
|
6f34a89622 | ||
|
|
7ca1f7a030 |
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
402
internal/games/blackjack/split_test.go
Normal file
402
internal/games/blackjack/split_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
internal/web/assets/fonts/Fredoka-SemiBold.ttf
Normal file
BIN
internal/web/assets/fonts/Fredoka-SemiBold.ttf
Normal file
Binary file not shown.
8
internal/web/assets/fonts/LICENSE.txt
Normal file
8
internal/web/assets/fonts/LICENSE.txt
Normal 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
444
internal/web/games_og.go
Normal 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)
|
||||||
|
}
|
||||||
166
internal/web/games_og_test.go
Normal file
166
internal/web/games_og_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
70
internal/web/templates/games_door.html
Normal file
70
internal/web/templates/games_door.html
Normal 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}}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user