Compare commits
19 Commits
39ed293f4f
...
mischief-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4189c03a82 | ||
|
|
16711e13e6 | ||
|
|
2ac6ec6b91 | ||
|
|
983748ea98 | ||
|
|
e5af5326d5 | ||
|
|
dbde827f75 | ||
|
|
18049f6f59 | ||
|
|
927ed84163 | ||
|
|
f8b07d8e6c | ||
|
|
4ad96dcb5e | ||
|
|
5139385350 | ||
|
|
5b381b03ff | ||
|
|
004fca3f25 | ||
|
|
4b3e5fe4c5 | ||
|
|
1f1a6cb6e8 | ||
|
|
a5b7e41929 | ||
|
|
57c445ff29 | ||
|
|
6f34a89622 | ||
|
|
7ca1f7a030 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ pete
|
|||||||
config.yaml
|
config.yaml
|
||||||
config.toml
|
config.toml
|
||||||
node_modules/
|
node_modules/
|
||||||
|
holdem-train
|
||||||
|
|||||||
@@ -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,39 +274,125 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
s.advance(&evs)
|
||||||
return s, evs, nil
|
return s, evs, nil
|
||||||
}
|
}
|
||||||
if m == Hit {
|
|
||||||
return s, evs, nil // still the player's turn
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stand, or a double that survived its card: the dealer draws out.
|
// 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.Phase = PhaseDealer
|
||||||
s.dealerPlay(&evs)
|
s.dealerPlay(evs)
|
||||||
return s, evs, nil
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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
|
||||||
|
for i := range s.Hands {
|
||||||
|
h := &s.Hands[i]
|
||||||
|
playerVal, _ := h.Value()
|
||||||
|
|
||||||
|
// profit is what this hand wins on top of its stake. Negative means the
|
||||||
// stake is gone.
|
// stake is gone.
|
||||||
var profit int64
|
var profit int64
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case playerVal > 21:
|
case playerVal > 21:
|
||||||
s.Outcome = OutcomeBust
|
h.Outcome = OutcomeBust
|
||||||
profit = -s.Bet
|
profit = -h.Bet
|
||||||
case playerBJ && dealerBJ:
|
case h.Natural() && dealerBJ:
|
||||||
s.Outcome = OutcomePush
|
h.Outcome = OutcomePush
|
||||||
case playerBJ:
|
case h.Natural():
|
||||||
s.Outcome = OutcomeBlackjack
|
h.Outcome = OutcomeBlackjack
|
||||||
profit = int64(math.Floor(float64(s.Bet) * s.Rules.BlackjackPays))
|
profit = int64(math.Floor(float64(h.Bet) * s.Rules.BlackjackPays))
|
||||||
case dealerBJ:
|
case dealerBJ:
|
||||||
s.Outcome = OutcomeLose
|
h.Outcome = OutcomeLose
|
||||||
profit = -s.Bet
|
profit = -h.Bet
|
||||||
case dealerVal > 21:
|
case dealerVal > 21:
|
||||||
s.Outcome = OutcomeDealerBust
|
h.Outcome = OutcomeDealerBust
|
||||||
profit = s.Bet
|
profit = h.Bet
|
||||||
case playerVal > dealerVal:
|
case playerVal > dealerVal:
|
||||||
s.Outcome = OutcomeWin
|
h.Outcome = OutcomeWin
|
||||||
profit = s.Bet
|
profit = h.Bet
|
||||||
case playerVal == dealerVal:
|
case playerVal == dealerVal:
|
||||||
s.Outcome = OutcomePush
|
h.Outcome = OutcomePush
|
||||||
default:
|
default:
|
||||||
s.Outcome = OutcomeLose
|
h.Outcome = OutcomeLose
|
||||||
profit = -s.Bet
|
profit = -h.Bet
|
||||||
}
|
}
|
||||||
|
|
||||||
if profit > 0 {
|
if profit > 0 {
|
||||||
s.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct))
|
h.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct))
|
||||||
if s.Rake < 0 {
|
if h.Rake < 0 {
|
||||||
s.Rake = 0
|
h.Rake = 0
|
||||||
}
|
}
|
||||||
profit -= s.Rake
|
profit -= h.Rake
|
||||||
}
|
}
|
||||||
if profit < 0 {
|
if profit < 0 {
|
||||||
s.Payout = 0 // stake is lost; nothing comes back
|
h.Payout = 0 // stake is lost; nothing comes back
|
||||||
} else {
|
} else {
|
||||||
s.Payout = s.Bet + profit
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,14 +180,19 @@ func (s *State) payPot(pot Pot, live []ranked, evs *[]Event) {
|
|||||||
amount -= rake
|
amount -= rake
|
||||||
s.Rake += rake // every chip of it, so the table still balances
|
s.Rake += rake // every chip of it, so the table still balances
|
||||||
|
|
||||||
// But only the part that came out of *your* winnings is money the house
|
// But only the part that came out of a *human's* winnings is money the
|
||||||
// actually made, and it is the only part the felt should quote you. The
|
// house actually made, and it is the only part worth quoting. The bots'
|
||||||
// bots' chips are not real — the only real money at this table is yours —
|
// chips are not real — the only real money at the table is the players' —
|
||||||
// so raking a pot a bot won costs you nothing, and a counter that climbed
|
// so raking a pot a bot won costs nobody anything, and a counter that
|
||||||
// while you folded would be telling you it had.
|
// climbed while every human folded would be telling them it had.
|
||||||
for _, w := range winners {
|
for _, w := range winners {
|
||||||
if w.seat == You {
|
if !s.Seats[w.seat].Bot {
|
||||||
|
// The table total (for the audit's delta) and the winner's own
|
||||||
|
// running tally (for the ledger line the felt shows them). At a
|
||||||
|
// table with two humans these are different numbers: each player is
|
||||||
|
// only ever quoted the rake that came out of a pot they won.
|
||||||
s.Paid += rake / int64(len(winners))
|
s.Paid += rake / int64(len(winners))
|
||||||
|
s.Seats[w.seat].Paid += rake / int64(len(winners))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*evs = append(*evs, Event{Kind: "rake", Seat: -1, Amount: rake})
|
*evs = append(*evs, Event{Kind: "rake", Seat: -1, Amount: rake})
|
||||||
|
|||||||
@@ -49,11 +49,9 @@ var (
|
|||||||
ErrUnknownTier = errors.New("holdem: no such table")
|
ErrUnknownTier = errors.New("holdem: no such table")
|
||||||
ErrBadBuyIn = errors.New("holdem: that isn't a legal buy-in")
|
ErrBadBuyIn = errors.New("holdem: that isn't a legal buy-in")
|
||||||
ErrTableFull = errors.New("holdem: too many seats")
|
ErrTableFull = errors.New("holdem: too many seats")
|
||||||
|
ErrSeatTaken = errors.New("holdem: that seat is taken")
|
||||||
)
|
)
|
||||||
|
|
||||||
// You are always seat zero. The bots are the seats after you.
|
|
||||||
const You = 0
|
|
||||||
|
|
||||||
// rakeCapBB caps the rake on any one pot at three big blinds, which is what a
|
// rakeCapBB caps the rake on any one pot at three big blinds, which is what a
|
||||||
// cardroom does. Without a cap, five percent of a big pot is a lot of money to
|
// cardroom does. Without a cap, five percent of a big pot is a lot of money to
|
||||||
// take off a player for winning it.
|
// take off a player for winning it.
|
||||||
@@ -103,6 +101,7 @@ type Seat struct {
|
|||||||
Bet int64 `json:"bet"` // put in on this street
|
Bet int64 `json:"bet"` // put in on this street
|
||||||
Total int64 `json:"total"` // put in across this hand
|
Total int64 `json:"total"` // put in across this hand
|
||||||
Won int64 `json:"won"` // taken out of the pot this hand
|
Won int64 `json:"won"` // taken out of the pot this hand
|
||||||
|
Paid int64 `json:"paid"` // rake lifted from pots this seat has won, all session
|
||||||
State SeatState `json:"state"`
|
State SeatState `json:"state"`
|
||||||
Acted bool `json:"acted"` // has chosen to do something this street
|
Acted bool `json:"acted"` // has chosen to do something this street
|
||||||
}
|
}
|
||||||
@@ -264,46 +263,171 @@ type Move struct {
|
|||||||
// botNames are the regulars. Six of them so a full table never has two.
|
// botNames are the regulars. Six of them so a full table never has two.
|
||||||
var botNames = []string{"Dice", "Marjorie", "Ox", "Sunny", "Pinch", "The Reverend"}
|
var botNames = []string{"Dice", "Marjorie", "Ox", "Sunny", "Pinch", "The Reverend"}
|
||||||
|
|
||||||
// New sits you down. The buy-in is chips the caller has already taken off the
|
// SeatConfig is one chair the table opens with. A shared table is seated by the
|
||||||
// player's stack; this engine only ever gives them back through Leave.
|
// runtime: humans in the chairs people have taken, bots in the rest. Solo play is
|
||||||
//
|
// just the case where exactly one chair is human, which is why there is no longer
|
||||||
// No hand is dealt yet — the table opens on PhaseHandOver, which is the state a
|
// a separate solo constructor and no seat-zero-is-you convention — a table is a
|
||||||
// table between hands is in, and the first Deal move starts the first hand.
|
// list of seats, and who is human is a property of each seat, not of its index.
|
||||||
func New(t Tier, bots int, buyIn int64, rakePct float64, seed1, seed2 uint64) (State, []Event, error) {
|
type SeatConfig struct {
|
||||||
if bots < 1 || bots > MaxBots {
|
Name string
|
||||||
return State{}, nil, ErrTableFull
|
Bot bool
|
||||||
|
// Stack is the starting chips: a human's buy-in (already taken off their chip
|
||||||
|
// stack by the caller), or a bot's stake from the house.
|
||||||
|
Stack int64
|
||||||
}
|
}
|
||||||
if buyIn < t.MinBuy || buyIn > t.MaxBuy {
|
|
||||||
return State{}, nil, ErrBadBuyIn
|
// New opens a table and seats it. No hand is dealt yet — the table opens on
|
||||||
|
// PhaseHandOver, the state a table between hands is in, and the first Deal starts
|
||||||
|
// the first hand.
|
||||||
|
//
|
||||||
|
// The engine gives a human's chips back only by the runtime reading their stack
|
||||||
|
// when they leave; it never credits a chip stack itself. Bot stacks are house
|
||||||
|
// chips and not real money — the only real money at the table is the humans'.
|
||||||
|
func New(t Tier, seats []SeatConfig, rakePct float64, seed1, seed2 uint64) (State, []Event, error) {
|
||||||
|
if len(seats) < 2 || len(seats) > MaxBots+1 {
|
||||||
|
return State{}, nil, ErrTableFull
|
||||||
}
|
}
|
||||||
t.RakePct = rakePct
|
t.RakePct = rakePct
|
||||||
|
|
||||||
s := State{
|
s := State{
|
||||||
Tier: t,
|
Tier: t,
|
||||||
Button: 0,
|
|
||||||
Phase: PhaseHandOver,
|
Phase: PhaseHandOver,
|
||||||
BoughtIn: buyIn,
|
|
||||||
Seed1: seed1,
|
Seed1: seed1,
|
||||||
Seed2: seed2,
|
Seed2: seed2,
|
||||||
}
|
}
|
||||||
s.Seats = append(s.Seats, Seat{Name: "You", Stack: buyIn})
|
var evs []Event
|
||||||
for i := 0; i < bots; i++ {
|
for _, sc := range seats {
|
||||||
s.Seats = append(s.Seats, Seat{Name: botNames[i], Bot: true, Stack: t.MaxBuy})
|
if !sc.Bot && (sc.Stack < t.MinBuy || sc.Stack > t.MaxBuy) {
|
||||||
|
return State{}, nil, ErrBadBuyIn
|
||||||
|
}
|
||||||
|
i := len(s.Seats)
|
||||||
|
s.Seats = append(s.Seats, Seat{Name: sc.Name, Bot: sc.Bot, Stack: sc.Stack})
|
||||||
|
if !sc.Bot {
|
||||||
|
// BoughtIn is the sum of what the humans brought — the audit's idea of the
|
||||||
|
// stake. Per-seat border accounting lives in storage (game_seats.staked),
|
||||||
|
// not here; the engine only ever moves chips within the table.
|
||||||
|
s.BoughtIn += sc.Stack
|
||||||
|
evs = append(evs, Event{Kind: "sit", Seat: i, Amount: sc.Stack, Text: t.Name})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The button starts to your right, so the first hand deals you the small blind
|
// The button starts at the last seat, so deal() moves it to the first: a full
|
||||||
// heads-up and the button on a full table — either way you are in the action
|
// table puts the earliest seat on the button, and heads-up puts it on the small
|
||||||
// from the first card rather than folding your way in.
|
// blind, in the action from the first card either way.
|
||||||
s.Button = len(s.Seats) - 1
|
s.Button = len(s.Seats) - 1
|
||||||
|
|
||||||
evs := []Event{{Kind: "sit", Seat: You, Amount: buyIn, Text: t.Name}}
|
|
||||||
return s, evs, nil
|
return s, evs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyMove is the whole engine. It plays your move, then every bot behind you,
|
// soloSeats builds the seat list for a table of one human and n bots — the shape
|
||||||
// deals whatever streets that finishes, and stops either when it is your turn
|
// every table had before multiplayer, and the one the solo handler still opens.
|
||||||
// again or when the hand is over.
|
// The human is seat zero and takes buyIn; the bots take the table maximum.
|
||||||
func ApplyMove(s State, m Move) (State, []Event, error) {
|
// SoloSeats is exported for the handler that still opens a solo table. See New.
|
||||||
|
func SoloSeats(t Tier, bots int, buyIn int64) []SeatConfig {
|
||||||
|
return TableSeats(t, "You", bots, buyIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableSeats builds a table of one named human and n bots. It is what a player
|
||||||
|
// opening their own table gets: a chair with their name on it, and the rest of
|
||||||
|
// the felt filled with the house's regulars so the table is never empty. The
|
||||||
|
// human takes seat zero and their buy-in; each bot takes the table maximum in
|
||||||
|
// house chips, which are not real money and get rebought when a bot runs dry.
|
||||||
|
func TableSeats(t Tier, human string, bots int, buyIn int64) []SeatConfig {
|
||||||
|
seats := []SeatConfig{{Name: human, Stack: buyIn}}
|
||||||
|
for i := 0; i < bots && i < len(botNames); i++ {
|
||||||
|
seats = append(seats, SeatConfig{Name: botNames[i], Bot: true, Stack: t.MaxBuy})
|
||||||
|
}
|
||||||
|
return seats
|
||||||
|
}
|
||||||
|
|
||||||
|
// freeBotName picks a regular not already sitting at the table, so vacating a
|
||||||
|
// seat never puts two Marjories on the felt. It falls back to a generic name if
|
||||||
|
// every regular is somehow taken, which a six-max table cannot actually manage.
|
||||||
|
func (s *State) freeBotName() string {
|
||||||
|
used := make(map[string]bool, len(s.Seats))
|
||||||
|
for i := range s.Seats {
|
||||||
|
used[s.Seats[i].Name] = true
|
||||||
|
}
|
||||||
|
for _, n := range botNames {
|
||||||
|
if !used[n] {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "The House"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vacate turns a human's chair back into the house's and returns the stack that
|
||||||
|
// goes home with them. It is how a shared table survives a player getting up: the
|
||||||
|
// seat keeps its place and its chips — which become house money, rebought like
|
||||||
|
// any bot's when they run low — so the others play on without a hole in the ring.
|
||||||
|
//
|
||||||
|
// The chips left in the seat are not a leak. The only real money at the table is
|
||||||
|
// in the human seats; the moment a seat is a bot's, its stack is house chips that
|
||||||
|
// nobody's balance is counting. What the player actually takes home is the
|
||||||
|
// returned stack, credited by the runtime in the same transaction that saves this
|
||||||
|
// state — see storage.LeaveTable.
|
||||||
|
//
|
||||||
|
// It refuses while a hand is live, because a seat with chips in the pot cannot be
|
||||||
|
// emptied without stranding them. PhaseHandOver is the only phase Leave was ever
|
||||||
|
// legal from.
|
||||||
|
func (s *State) Vacate(seat int) (int64, error) {
|
||||||
|
if seat < 0 || seat >= len(s.Seats) {
|
||||||
|
return 0, ErrUnknownMove
|
||||||
|
}
|
||||||
|
if s.Phase == PhaseBetting {
|
||||||
|
return 0, ErrHandLive
|
||||||
|
}
|
||||||
|
p := &s.Seats[seat]
|
||||||
|
if p.Bot {
|
||||||
|
return 0, ErrUnknownMove
|
||||||
|
}
|
||||||
|
home := p.Stack
|
||||||
|
p.Bot = true
|
||||||
|
p.Name = s.freeBotName()
|
||||||
|
return home, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Occupy seats a human in a chair a bot was keeping warm, with the buy-in they
|
||||||
|
// brought. It is the join half of Vacate: a player sitting down at somebody
|
||||||
|
// else's table takes an open seat rather than opening a felt of their own.
|
||||||
|
//
|
||||||
|
// Like Vacate it is a between-hands move — you cannot sit into a live hand — and
|
||||||
|
// the buy-in has to be legal for the table, because the chips are already off the
|
||||||
|
// player's stack by the time this runs and an illegal seat would strand them.
|
||||||
|
func (s *State) Occupy(seat int, name string, buyIn int64) error {
|
||||||
|
if seat < 0 || seat >= len(s.Seats) {
|
||||||
|
return ErrUnknownMove
|
||||||
|
}
|
||||||
|
if s.Phase == PhaseBetting {
|
||||||
|
return ErrHandLive
|
||||||
|
}
|
||||||
|
if !s.Seats[seat].Bot {
|
||||||
|
return ErrSeatTaken
|
||||||
|
}
|
||||||
|
if buyIn < s.Tier.MinBuy || buyIn > s.Tier.MaxBuy {
|
||||||
|
return ErrBadBuyIn
|
||||||
|
}
|
||||||
|
p := &s.Seats[seat]
|
||||||
|
p.Bot = false
|
||||||
|
p.Name = name
|
||||||
|
p.Stack = buyIn
|
||||||
|
p.State = Out // between hands; the next deal brings them in
|
||||||
|
s.BoughtIn += buyIn
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyMove is the whole engine. It plays the acting seat's move, then every bot
|
||||||
|
// behind them, deals whatever streets that finishes, and stops when the action
|
||||||
|
// reaches a human — the same one again, or another at the table — or when the
|
||||||
|
// hand is over.
|
||||||
|
//
|
||||||
|
// seat is who is acting. A betting move is legal only from the seat whose turn it
|
||||||
|
// is; the session moves (Deal, TopUp, Leave) belong to the seat that sent them.
|
||||||
|
// This is the one place seat identity enters the engine — everything below works
|
||||||
|
// on seat indices already.
|
||||||
|
func ApplyMove(s State, seat int, m Move) (State, []Event, error) {
|
||||||
|
if seat < 0 || seat >= len(s.Seats) {
|
||||||
|
return s, nil, ErrUnknownMove
|
||||||
|
}
|
||||||
if s.Phase == PhaseDone {
|
if s.Phase == PhaseDone {
|
||||||
return s, nil, ErrOver
|
return s, nil, ErrOver
|
||||||
}
|
}
|
||||||
@@ -322,32 +446,36 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
|
|||||||
if s.Phase != PhaseHandOver {
|
if s.Phase != PhaseHandOver {
|
||||||
return s, nil, ErrHandLive
|
return s, nil, ErrHandLive
|
||||||
}
|
}
|
||||||
if m.Amount <= 0 || s.Seats[You].Stack+m.Amount > s.Tier.MaxBuy {
|
if m.Amount <= 0 || s.Seats[seat].Stack+m.Amount > s.Tier.MaxBuy {
|
||||||
return s, nil, ErrBadBuyIn
|
return s, nil, ErrBadBuyIn
|
||||||
}
|
}
|
||||||
s.Seats[You].Stack += m.Amount
|
s.Seats[seat].Stack += m.Amount
|
||||||
s.BoughtIn += m.Amount
|
s.BoughtIn += m.Amount
|
||||||
evs = append(evs, Event{Kind: "topup", Seat: You, Amount: m.Amount})
|
evs = append(evs, Event{Kind: "topup", Seat: seat, Amount: m.Amount})
|
||||||
|
|
||||||
case Leave:
|
case Leave:
|
||||||
if s.Phase != PhaseHandOver {
|
if s.Phase != PhaseHandOver {
|
||||||
return s, nil, ErrHandLive
|
return s, nil, ErrHandLive
|
||||||
}
|
}
|
||||||
|
// Getting up at a solo table ends the session and pays the stack out; the
|
||||||
|
// runtime reads Payout and crosses the border. At a shared table leaving is a
|
||||||
|
// storage operation (LeaveTable swaps the seat for a bot in the settle tx),
|
||||||
|
// and this branch is not the path taken — see the handler.
|
||||||
s.Phase = PhaseDone
|
s.Phase = PhaseDone
|
||||||
s.Payout = s.Seats[You].Stack
|
s.Payout = s.Seats[seat].Stack
|
||||||
evs = append(evs, Event{Kind: "leave", Seat: You, Amount: s.Payout})
|
evs = append(evs, Event{Kind: "leave", Seat: seat, Amount: s.Payout})
|
||||||
|
|
||||||
case Fold, Check, Call, Raise, Shove:
|
case Fold, Check, Call, Raise, Shove:
|
||||||
if s.Phase != PhaseBetting {
|
if s.Phase != PhaseBetting {
|
||||||
return s, nil, ErrNoHand
|
return s, nil, ErrNoHand
|
||||||
}
|
}
|
||||||
if s.ToAct != You {
|
if s.ToAct != seat {
|
||||||
return s, nil, ErrNotYourTurn
|
return s, nil, ErrNotYourTurn
|
||||||
}
|
}
|
||||||
if err := s.act(You, m, &evs); err != nil {
|
if err := s.act(seat, m, &evs); err != nil {
|
||||||
return s, nil, err // nothing happened; the caller keeps the old state
|
return s, nil, err // nothing happened; the caller keeps the old state
|
||||||
}
|
}
|
||||||
s.ToAct = s.nextCanAct(You)
|
s.ToAct = s.nextCanAct(seat)
|
||||||
s.advance(&evs, rng, true)
|
s.advance(&evs, rng, true)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -485,8 +613,8 @@ func (s *State) advance(evs *[]Event, rng *rand.Rand, bots bool) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.ToAct == You || !bots {
|
if !s.Seats[s.ToAct].Bot || !bots {
|
||||||
return // a decision that isn't ours to make
|
return // a decision a human has to make, or the trainer wants to
|
||||||
}
|
}
|
||||||
|
|
||||||
s.botActs(s.ToAct, evs, rng)
|
s.botActs(s.ToAct, evs, rng)
|
||||||
@@ -546,10 +674,19 @@ func (s *State) deal(evs *[]Event, rng *rand.Rand) {
|
|||||||
p.Hole[round] = c
|
p.Hole[round] = c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Only your cards go into the script. The bots' are in the state, on this side
|
// A hole event per dealt seat, each carrying that seat's cards. The engine no
|
||||||
// of the wire, and the only thing that ever turns them over is a showdown.
|
// longer decides who may see what — it cannot, now that a table has more than
|
||||||
*evs = append(*evs, Event{Kind: "hole", Seat: You,
|
// one human — so it emits every hand and the view layer redacts per viewer,
|
||||||
Cards: []cards.Card{s.Seats[You].Hole[0], s.Seats[You].Hole[1]}})
|
// nulling the cards of every seat but the one watching. That per-seat redaction
|
||||||
|
// is the whole security boundary once these events fan out over SSE, and it has
|
||||||
|
// a test that renders each seat's view and greps for anybody else's cards.
|
||||||
|
for i := range s.Seats {
|
||||||
|
if s.Seats[i].State == Out {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
*evs = append(*evs, Event{Kind: "hole", Seat: i,
|
||||||
|
Cards: []cards.Card{s.Seats[i].Hole[0], s.Seats[i].Hole[1]}})
|
||||||
|
}
|
||||||
|
|
||||||
s.ToAct = s.firstPreFlop(bb)
|
s.ToAct = s.firstPreFlop(bb)
|
||||||
}
|
}
|
||||||
@@ -627,18 +764,34 @@ func (s *State) endHand(evs *[]Event) {
|
|||||||
s.Pot = 0
|
s.Pot = 0
|
||||||
s.Side = nil
|
s.Side = nil
|
||||||
s.Phase = PhaseHandOver
|
s.Phase = PhaseHandOver
|
||||||
*evs = append(*evs, Event{Kind: "end", Seat: -1, Amount: s.Seats[You].Stack})
|
*evs = append(*evs, Event{Kind: "end", Seat: -1})
|
||||||
|
|
||||||
// Busting is the end of the session, not the end of a hand. There is nothing
|
// Busting is the end of a session, not of a hand. At a solo table there is
|
||||||
// to deal you and nothing to give back, so the table closes and you sit down
|
// nothing to deal the one human and nothing to give back, so the table closes
|
||||||
// again — which is a buy-in, and a buy-in is a decision worth making on purpose.
|
// and they sit down again — a buy-in, and a buy-in is a decision worth making
|
||||||
if s.Seats[You].Stack <= 0 {
|
// on purpose. At a shared table one human busting is not everyone's business:
|
||||||
|
// their seat simply goes Out and the runtime offers the rebuy while the table
|
||||||
|
// plays on. So the whole-session bust only fires when there is a single human.
|
||||||
|
humans := s.humanSeats()
|
||||||
|
if len(humans) == 1 && s.Seats[humans[0]].Stack <= 0 {
|
||||||
s.Phase = PhaseDone
|
s.Phase = PhaseDone
|
||||||
s.Payout = 0
|
s.Payout = 0
|
||||||
*evs = append(*evs, Event{Kind: "bust", Seat: You})
|
*evs = append(*evs, Event{Kind: "bust", Seat: humans[0]})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// humanSeats lists the seats a person is sitting in. Bots are the house; the only
|
||||||
|
// real money at the table is in these.
|
||||||
|
func (s State) humanSeats() []int {
|
||||||
|
var out []int
|
||||||
|
for i := range s.Seats {
|
||||||
|
if !s.Seats[i].Bot {
|
||||||
|
out = append(out, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// ---- the small stuff -------------------------------------------------------
|
// ---- the small stuff -------------------------------------------------------
|
||||||
|
|
||||||
// next derives this step's generator and advances the step count.
|
// next derives this step's generator and advances the step count.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func TestChipsAreConserved(t *testing.T) {
|
|||||||
bots := 1 + game%MaxBots
|
bots := 1 + game%MaxBots
|
||||||
tier := Tiers[game%len(Tiers)]
|
tier := Tiers[game%len(Tiers)]
|
||||||
|
|
||||||
s, _, err := New(tier, bots, tier.MaxBuy, tier.RakePct, uint64(game), 7)
|
s, _, err := New(tier, SoloSeats(tier, bots, tier.MaxBuy), tier.RakePct, uint64(game), 7)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new table: %v", err)
|
t.Fatalf("new table: %v", err)
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ func TestChipsAreConserved(t *testing.T) {
|
|||||||
|
|
||||||
for hand := 0; hand < 8 && s.Phase != PhaseDone; hand++ {
|
for hand := 0; hand < 8 && s.Phase != PhaseDone; hand++ {
|
||||||
var evs []Event
|
var evs []Event
|
||||||
s, evs, err = ApplyMove(s, Move{Kind: Deal})
|
s, evs, err = apply(s, Move{Kind: Deal})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("game %d hand %d: deal: %v", game, hand, err)
|
t.Fatalf("game %d hand %d: deal: %v", game, hand, err)
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ func TestChipsAreConserved(t *testing.T) {
|
|||||||
if step > 200 {
|
if step > 200 {
|
||||||
t.Fatalf("game %d hand %d: the hand will not end", game, hand)
|
t.Fatalf("game %d hand %d: the hand will not end", game, hand)
|
||||||
}
|
}
|
||||||
s, _, err = ApplyMove(s, randomMove(s, rng))
|
s, _, err = apply(s, randomMove(s, rng))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("game %d hand %d: %v", game, hand, err)
|
t.Fatalf("game %d hand %d: %v", game, hand, err)
|
||||||
}
|
}
|
||||||
@@ -98,6 +98,21 @@ func stacks(s State) []int64 {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// You is seat zero — the shape every test in this file is written against. The
|
||||||
|
// engine no longer has this constant: a table is a list of seats and which are
|
||||||
|
// human is a per-seat property, not a fixed index. But these tests all seat one
|
||||||
|
// human at zero (the pre-multiplayer solo shape), so a test-local alias keeps
|
||||||
|
// them readable as the regression guard they are. The multiway behaviour has its
|
||||||
|
// own tests, which do not assume it.
|
||||||
|
const You = 0
|
||||||
|
|
||||||
|
// apply plays a move as the seat whose turn it is at a solo table — always the
|
||||||
|
// human at seat zero. It wraps the seat-parameterized ApplyMove so the solo tests
|
||||||
|
// read as they did before the reshape.
|
||||||
|
func apply(s State, m Move) (State, []Event, error) {
|
||||||
|
return ApplyMove(s, You, m)
|
||||||
|
}
|
||||||
|
|
||||||
// randomMove picks something legal for the player, without any thought at all.
|
// randomMove picks something legal for the player, without any thought at all.
|
||||||
// A bad player is exactly what this test wants: it gets into all-ins, folds,
|
// A bad player is exactly what this test wants: it gets into all-ins, folds,
|
||||||
// short stacks and split pots far faster than a good one would.
|
// short stacks and split pots far faster than a good one would.
|
||||||
@@ -122,7 +137,7 @@ func randomMove(s State, rng *rand.Rand) Move {
|
|||||||
|
|
||||||
func TestHeadsUpButtonIsTheSmallBlindAndActsFirst(t *testing.T) {
|
func TestHeadsUpButtonIsTheSmallBlindAndActsFirst(t *testing.T) {
|
||||||
s := table(t, Tiers[0], 1, 200)
|
s := table(t, Tiers[0], 1, 200)
|
||||||
s, evs, err := ApplyMove(s, Move{Kind: Deal})
|
s, evs, err := apply(s, Move{Kind: Deal})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -154,13 +169,13 @@ func TestTheBigBlindGetsTheirOption(t *testing.T) {
|
|||||||
// A table where everyone just calls: the big blind has the bet matched without
|
// A table where everyone just calls: the big blind has the bet matched without
|
||||||
// ever having chosen anything, and the street must not end until they speak.
|
// ever having chosen anything, and the street must not end until they speak.
|
||||||
s := table(t, Tiers[0], 1, 200)
|
s := table(t, Tiers[0], 1, 200)
|
||||||
s, _, _ = ApplyMove(s, Move{Kind: Deal})
|
s, _, _ = apply(s, Move{Kind: Deal})
|
||||||
|
|
||||||
// Find a hand where the player is the big blind. The button alternates, so at
|
// Find a hand where the player is the big blind. The button alternates, so at
|
||||||
// most a couple of deals.
|
// most a couple of deals.
|
||||||
for i := 0; i < 6 && s.Position(You) != "BB"; i++ {
|
for i := 0; i < 6 && s.Position(You) != "BB"; i++ {
|
||||||
s = playOut(t, s)
|
s = playOut(t, s)
|
||||||
s, _, _ = ApplyMove(s, Move{Kind: Deal})
|
s, _, _ = apply(s, Move{Kind: Deal})
|
||||||
}
|
}
|
||||||
if s.Position(You) != "BB" {
|
if s.Position(You) != "BB" {
|
||||||
t.Skip("never dealt the big blind")
|
t.Skip("never dealt the big blind")
|
||||||
@@ -170,7 +185,7 @@ func TestTheBigBlindGetsTheirOption(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if s.ToAct == You && s.Owed(You) == 0 {
|
if s.ToAct == You && s.Owed(You) == 0 {
|
||||||
// This is the option: nothing to call, but the hand is still ours to act on.
|
// This is the option: nothing to call, but the hand is still ours to act on.
|
||||||
if _, _, err := ApplyMove(s, Move{Kind: Check}); err != nil {
|
if _, _, err := apply(s, Move{Kind: Check}); err != nil {
|
||||||
t.Errorf("the big blind cannot check their option: %v", err)
|
t.Errorf("the big blind cannot check their option: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,7 +403,7 @@ func TestTheRakeIsFivePercentUnderTheCap(t *testing.T) {
|
|||||||
// house quietly took nothing from every pot for an afternoon.
|
// house quietly took nothing from every pot for an afternoon.
|
||||||
func TestTheRakeSurvivesTheConstructor(t *testing.T) {
|
func TestTheRakeSurvivesTheConstructor(t *testing.T) {
|
||||||
tier := Tiers[1] // 5/10, so the cap is 30
|
tier := Tiers[1] // 5/10, so the cap is 30
|
||||||
s, _, err := New(tier, 1, tier.MaxBuy, 0.05, 1, 2)
|
s, _, err := New(tier, SoloSeats(tier, 1, tier.MaxBuy), 0.05, 1, 2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -443,6 +458,40 @@ func TestYouOnlyPayRakeOnPotsYouWin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// At a table with two humans the session-rake line each is shown is their own,
|
||||||
|
// not the table's: the house took it out of a pot one of them won, and quoting it
|
||||||
|
// to the other says the house has taken money off them that it has not. So the
|
||||||
|
// per-seat tally only ever moves on the seat that won the pot.
|
||||||
|
func TestRakeIsChargedToTheSeatThatWonIt(t *testing.T) {
|
||||||
|
s := State{Tier: Tiers[1], Flopped: true,
|
||||||
|
Seats: []Seat{{Name: "Reala"}, {Name: "Bob"}}}
|
||||||
|
var evs []Event
|
||||||
|
|
||||||
|
// Reala wins a 400 pot. The rake on it (5%, 20) is Reala's to have paid.
|
||||||
|
s.payPot(Pot{Amount: 400, Eligible: []int{0}}, []ranked{{seat: 0}}, &evs)
|
||||||
|
if s.Seats[0].Paid != 20 {
|
||||||
|
t.Errorf("the seat that won the pot paid %d in rake, want 20", s.Seats[0].Paid)
|
||||||
|
}
|
||||||
|
if s.Seats[1].Paid != 0 {
|
||||||
|
t.Errorf("the other player was charged %d in rake for a pot they were not in", s.Seats[1].Paid)
|
||||||
|
}
|
||||||
|
if s.Paid != 20 {
|
||||||
|
t.Errorf("the table total is %d, want 20 — it is still the sum for the audit", s.Paid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bob wins the next one. His tally moves; Reala's stays where it was.
|
||||||
|
s.payPot(Pot{Amount: 200, Eligible: []int{1}}, []ranked{{seat: 1}}, &evs)
|
||||||
|
if s.Seats[0].Paid != 20 {
|
||||||
|
t.Errorf("Reala's tally moved on a pot Bob won: %d, want 20", s.Seats[0].Paid)
|
||||||
|
}
|
||||||
|
if s.Seats[1].Paid != 10 {
|
||||||
|
t.Errorf("Bob paid %d in rake on a 200 pot he won, want 10", s.Seats[1].Paid)
|
||||||
|
}
|
||||||
|
if s.Paid != 30 {
|
||||||
|
t.Errorf("the table total is %d, want 30", s.Paid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestASplitPotSplits(t *testing.T) {
|
func TestASplitPotSplits(t *testing.T) {
|
||||||
s := State{Tier: Tiers[1], Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
|
s := State{Tier: Tiers[1], Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
|
||||||
var evs []Event
|
var evs []Event
|
||||||
@@ -462,25 +511,25 @@ func TestASplitPotSplits(t *testing.T) {
|
|||||||
|
|
||||||
func TestYouCannotWalkOutOfALiveHand(t *testing.T) {
|
func TestYouCannotWalkOutOfALiveHand(t *testing.T) {
|
||||||
s := table(t, Tiers[0], 2, 200)
|
s := table(t, Tiers[0], 2, 200)
|
||||||
s, _, _ = ApplyMove(s, Move{Kind: Deal})
|
s, _, _ = apply(s, Move{Kind: Deal})
|
||||||
if s.Phase != PhaseBetting {
|
if s.Phase != PhaseBetting {
|
||||||
t.Skip("the hand ended before the player could act")
|
t.Skip("the hand ended before the player could act")
|
||||||
}
|
}
|
||||||
if _, _, err := ApplyMove(s, Move{Kind: Leave}); err != ErrHandLive {
|
if _, _, err := apply(s, Move{Kind: Leave}); err != ErrHandLive {
|
||||||
t.Errorf("leaving mid-hand gave %v, want ErrHandLive", err)
|
t.Errorf("leaving mid-hand gave %v, want ErrHandLive", err)
|
||||||
}
|
}
|
||||||
if _, _, err := ApplyMove(s, Move{Kind: TopUp, Amount: 10}); err != ErrHandLive {
|
if _, _, err := apply(s, Move{Kind: TopUp, Amount: 10}); err != ErrHandLive {
|
||||||
t.Errorf("topping up mid-hand gave %v, want ErrHandLive", err)
|
t.Errorf("topping up mid-hand gave %v, want ErrHandLive", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLeavingTakesTheStackHome(t *testing.T) {
|
func TestLeavingTakesTheStackHome(t *testing.T) {
|
||||||
s := table(t, Tiers[0], 1, 200)
|
s := table(t, Tiers[0], 1, 200)
|
||||||
s, _, _ = ApplyMove(s, Move{Kind: Deal})
|
s, _, _ = apply(s, Move{Kind: Deal})
|
||||||
s = playOut(t, s)
|
s = playOut(t, s)
|
||||||
|
|
||||||
stack := s.Seats[You].Stack
|
stack := s.Seats[You].Stack
|
||||||
s, _, err := ApplyMove(s, Move{Kind: Leave})
|
s, _, err := apply(s, Move{Kind: Leave})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -490,7 +539,7 @@ func TestLeavingTakesTheStackHome(t *testing.T) {
|
|||||||
if s.Payout != stack {
|
if s.Payout != stack {
|
||||||
t.Errorf("payout %d, want the %d that was in front of us", s.Payout, stack)
|
t.Errorf("payout %d, want the %d that was in front of us", s.Payout, stack)
|
||||||
}
|
}
|
||||||
if _, _, err := ApplyMove(s, Move{Kind: Deal}); err != ErrOver {
|
if _, _, err := apply(s, Move{Kind: Deal}); err != ErrOver {
|
||||||
t.Errorf("dealt a hand at a table we got up from: %v", err)
|
t.Errorf("dealt a hand at a table we got up from: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -514,12 +563,12 @@ func TestBustingEndsTheSession(t *testing.T) {
|
|||||||
|
|
||||||
func TestATopUpCannotGoOverTheTableMax(t *testing.T) {
|
func TestATopUpCannotGoOverTheTableMax(t *testing.T) {
|
||||||
s := table(t, Tiers[0], 1, 200) // max buy is 200, and we're at it
|
s := table(t, Tiers[0], 1, 200) // max buy is 200, and we're at it
|
||||||
if _, _, err := ApplyMove(s, Move{Kind: TopUp, Amount: 1}); err != ErrBadBuyIn {
|
if _, _, err := apply(s, Move{Kind: TopUp, Amount: 1}); err != ErrBadBuyIn {
|
||||||
t.Errorf("topped up over the table maximum: %v", err)
|
t.Errorf("topped up over the table maximum: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s = table(t, Tiers[0], 1, 100)
|
s = table(t, Tiers[0], 1, 100)
|
||||||
s, _, err := ApplyMove(s, Move{Kind: TopUp, Amount: 50})
|
s, _, err := apply(s, Move{Kind: TopUp, Amount: 50})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -534,47 +583,45 @@ func TestATopUpCannotGoOverTheTableMax(t *testing.T) {
|
|||||||
func TestABuyInHasToBeInRange(t *testing.T) {
|
func TestABuyInHasToBeInRange(t *testing.T) {
|
||||||
tier := Tiers[0]
|
tier := Tiers[0]
|
||||||
for _, amount := range []int64{0, tier.MinBuy - 1, tier.MaxBuy + 1} {
|
for _, amount := range []int64{0, tier.MinBuy - 1, tier.MaxBuy + 1} {
|
||||||
if _, _, err := New(tier, 1, amount, 5, 1, 2); err != ErrBadBuyIn {
|
if _, _, err := New(tier, SoloSeats(tier, 1, amount), 5, 1, 2); err != ErrBadBuyIn {
|
||||||
t.Errorf("buy-in of %d at a %d–%d table: %v", amount, tier.MinBuy, tier.MaxBuy, err)
|
t.Errorf("buy-in of %d at a %d–%d table: %v", amount, tier.MinBuy, tier.MaxBuy, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- what the player is allowed to know ------------------------------------
|
// ---- what the raw script carries ------------------------------------------
|
||||||
|
|
||||||
func TestTheScriptNeverCarriesABotsCards(t *testing.T) {
|
// The engine no longer redacts the event stream, and this pins why: a shared
|
||||||
rng := rand.New(rand.NewPCG(4, 4))
|
// table has more than one human, so the engine cannot know who a stream is for.
|
||||||
s := table(t, Tiers[0], 3, 200)
|
// It emits every seat's hole cards, and the *view* layer builds each viewer's
|
||||||
|
// redacted copy — that per-seat redaction is the security boundary now, and it
|
||||||
for hand := 0; hand < 20 && s.Phase != PhaseDone; hand++ {
|
// has its own test in the web package (TestHoldemViewNeverLeaksAnotherSeatsCards).
|
||||||
s, evs, err := ApplyMove(s, Move{Kind: Deal})
|
//
|
||||||
|
// So the engine-level contract flipped: the deal must carry a hole event for
|
||||||
|
// every dealt seat, or a viewer would have no cards of their own to be shown.
|
||||||
|
func TestTheDealScriptCarriesEverySeatsHole(t *testing.T) {
|
||||||
|
s := table(t, Tiers[0], 3, 200) // four seats: one human, three bots
|
||||||
|
s, evs, err := apply(s, Move{Kind: Deal})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
noBotCards(t, s, evs)
|
|
||||||
|
|
||||||
for s.Phase == PhaseBetting {
|
holes := map[int][]cards.Card{}
|
||||||
var next []Event
|
|
||||||
s, next, err = ApplyMove(s, randomMove(s, rng))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
noBotCards(t, s, next)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A bot's cards may appear in exactly one kind of event: the showdown that turns
|
|
||||||
// them face up, which is the moment they stop being secret.
|
|
||||||
func noBotCards(t *testing.T, s State, evs []Event) {
|
|
||||||
t.Helper()
|
|
||||||
for _, e := range evs {
|
for _, e := range evs {
|
||||||
if len(e.Cards) == 0 || e.Seat < 0 || e.Kind == "show" {
|
if e.Kind == "hole" {
|
||||||
|
holes[e.Seat] = e.Cards
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range s.Seats {
|
||||||
|
if s.Seats[i].State == Out {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if e.Seat != You && s.Seats[e.Seat].Bot {
|
got := holes[i]
|
||||||
t.Fatalf("a %q event carries seat %d's cards (%v) — that's a bot's hand",
|
if len(got) != 2 {
|
||||||
e.Kind, e.Seat, e.Cards)
|
t.Fatalf("seat %d got no hole event; the view has nothing to redact from", i)
|
||||||
|
}
|
||||||
|
if got[0] != s.Seats[i].Hole[0] || got[1] != s.Seats[i].Hole[1] {
|
||||||
|
t.Errorf("seat %d hole event %v disagrees with state %v", i, got, s.Seats[i].Hole)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -655,17 +702,17 @@ func TestTheBotsAreActuallyTrained(t *testing.T) {
|
|||||||
rng := rand.New(rand.NewPCG(11, 12))
|
rng := rand.New(rand.NewPCG(11, 12))
|
||||||
for game := 0; game < 40; game++ {
|
for game := 0; game < 40; game++ {
|
||||||
tier := Tiers[1]
|
tier := Tiers[1]
|
||||||
s, _, err := New(tier, 1, tier.MaxBuy, tier.RakePct, uint64(game), 5)
|
s, _, err := New(tier, SoloSeats(tier, 1, tier.MaxBuy), tier.RakePct, uint64(game), 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for hand := 0; hand < 6 && s.Phase != PhaseDone; hand++ {
|
for hand := 0; hand < 6 && s.Phase != PhaseDone; hand++ {
|
||||||
s, _, err = ApplyMove(s, Move{Kind: Deal})
|
s, _, err = apply(s, Move{Kind: Deal})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for s.Phase == PhaseBetting {
|
for s.Phase == PhaseBetting {
|
||||||
s, _, err = ApplyMove(s, randomMove(s, rng))
|
s, _, err = apply(s, randomMove(s, rng))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -691,7 +738,7 @@ func TestTheBotsAreActuallyTrained(t *testing.T) {
|
|||||||
|
|
||||||
func table(t *testing.T, tier Tier, bots int, buyIn int64) State {
|
func table(t *testing.T, tier Tier, bots int, buyIn int64) State {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, _, err := New(tier, bots, buyIn, tier.RakePct, 1, 2)
|
s, _, err := New(tier, SoloSeats(tier, bots, buyIn), tier.RakePct, 1, 2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new table: %v", err)
|
t.Fatalf("new table: %v", err)
|
||||||
}
|
}
|
||||||
@@ -710,7 +757,7 @@ func playOut(t *testing.T, s State) State {
|
|||||||
move = Move{Kind: Check}
|
move = Move{Kind: Check}
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
s, _, err = ApplyMove(s, move)
|
s, _, err = apply(s, move)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("playing out: %v", err)
|
t.Fatalf("playing out: %v", err)
|
||||||
}
|
}
|
||||||
@@ -734,7 +781,7 @@ func has(evs []Event, kind string) bool {
|
|||||||
// the only thing that reads this, which is exactly why nothing caught it.
|
// the only thing that reads this, which is exactly why nothing caught it.
|
||||||
func TestPositionsDoNotMoveWhenSeatsFold(t *testing.T) {
|
func TestPositionsDoNotMoveWhenSeatsFold(t *testing.T) {
|
||||||
s := table(t, Tiers[0], 5, 200) // six-handed
|
s := table(t, Tiers[0], 5, 200) // six-handed
|
||||||
s, _, _ = ApplyMove(s, Move{Kind: Deal})
|
s, _, _ = apply(s, Move{Kind: Deal})
|
||||||
|
|
||||||
before := make([]string, len(s.Seats))
|
before := make([]string, len(s.Seats))
|
||||||
for i := range s.Seats {
|
for i := range s.Seats {
|
||||||
|
|||||||
112
internal/games/holdem/multiway_test.go
Normal file
112
internal/games/holdem/multiway_test.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package holdem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The reshape's own guard: a table with more than one human actually plays, and
|
||||||
|
// the chips still conserve when the person to act is not always seat zero.
|
||||||
|
//
|
||||||
|
// The solo suite proves the engine still behaves as it did; this proves the thing
|
||||||
|
// that changed. Two humans and two bots sit down, and the driver plays whichever
|
||||||
|
// human the action stops on — which is the whole point of the multiway advance:
|
||||||
|
// it runs the bots itself and hands control back at every *human* seat, not just
|
||||||
|
// at seat zero.
|
||||||
|
|
||||||
|
// randomMoveFor picks a legal move for a specific seat, the multiway sibling of
|
||||||
|
// randomMove. It never folds when it can check, so hands actually develop.
|
||||||
|
func randomMoveFor(s State, seat int, rng *rand.Rand) Move {
|
||||||
|
owed := s.Owed(seat)
|
||||||
|
var legal []Move
|
||||||
|
if owed > 0 {
|
||||||
|
legal = append(legal, Move{Kind: Fold}, Move{Kind: Call})
|
||||||
|
} else {
|
||||||
|
legal = append(legal, Move{Kind: Check})
|
||||||
|
}
|
||||||
|
if s.Seats[seat].Stack > owed && s.canBet() {
|
||||||
|
if to := s.MinRaiseTo(seat); to < s.MaxRaiseTo(seat) {
|
||||||
|
legal = append(legal, Move{Kind: Raise, To: to})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return legal[rng.IntN(len(legal))]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiwayChipsAreConserved(t *testing.T) {
|
||||||
|
for game := 0; game < 100; game++ {
|
||||||
|
rng := rand.New(rand.NewPCG(uint64(game), 71))
|
||||||
|
tier := Tiers[game%len(Tiers)]
|
||||||
|
|
||||||
|
// Two humans, two bots. The humans sit at 0 and 2 so the action genuinely
|
||||||
|
// lands on a non-zero human seat, which is the case the old engine could not
|
||||||
|
// have reached.
|
||||||
|
seats := []SeatConfig{
|
||||||
|
{Name: "Ana", Stack: tier.MaxBuy},
|
||||||
|
{Name: "Bot A", Bot: true, Stack: tier.MaxBuy},
|
||||||
|
{Name: "Bo", Stack: tier.MaxBuy},
|
||||||
|
{Name: "Bot B", Bot: true, Stack: tier.MaxBuy},
|
||||||
|
}
|
||||||
|
s, _, err := New(tier, seats, tier.RakePct, uint64(game), 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new table: %v", err)
|
||||||
|
}
|
||||||
|
want := chipsAt(s)
|
||||||
|
|
||||||
|
for hand := 0; hand < 8 && s.Phase == PhaseHandOver; hand++ {
|
||||||
|
var evs []Event
|
||||||
|
s, evs, err = ApplyMove(s, 0, Move{Kind: Deal})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("game %d hand %d: deal: %v", game, hand, err)
|
||||||
|
}
|
||||||
|
want += reloaded(evs)
|
||||||
|
check(t, s, want, game, hand, "deal")
|
||||||
|
|
||||||
|
for step := 0; s.Phase == PhaseBetting; step++ {
|
||||||
|
if step > 400 {
|
||||||
|
t.Fatalf("game %d hand %d: the hand will not end", game, hand)
|
||||||
|
}
|
||||||
|
seat := s.ToAct
|
||||||
|
if s.Seats[seat].Bot {
|
||||||
|
t.Fatalf("game %d: advance stopped on bot seat %d — it should run bots itself", game, seat)
|
||||||
|
}
|
||||||
|
s, _, err = ApplyMove(s, seat, randomMoveFor(s, seat, rng))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("game %d hand %d seat %d: %v", game, hand, seat, err)
|
||||||
|
}
|
||||||
|
check(t, s, want, game, hand, "move")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMultiwayRejectsOutOfTurnMoves pins that a human cannot act when it is
|
||||||
|
// another human's turn — the betting move is legal only from the seat to act.
|
||||||
|
func TestMultiwayRejectsOutOfTurnMoves(t *testing.T) {
|
||||||
|
tier := Tiers[0]
|
||||||
|
seats := []SeatConfig{
|
||||||
|
{Name: "Ana", Stack: tier.MaxBuy},
|
||||||
|
{Name: "Bo", Stack: tier.MaxBuy},
|
||||||
|
}
|
||||||
|
s, _, err := New(tier, seats, tier.RakePct, 3, 9)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, _, err = ApplyMove(s, 0, Move{Kind: Deal})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Phase != PhaseBetting {
|
||||||
|
t.Fatalf("want a live hand, got phase %s", s.Phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whoever is not to act tries to move. It must be refused, and nothing must
|
||||||
|
// change.
|
||||||
|
other := 1 - s.ToAct
|
||||||
|
before := chipsAt(s)
|
||||||
|
if _, _, err := ApplyMove(s, other, Move{Kind: Call}); err != ErrNotYourTurn {
|
||||||
|
t.Fatalf("want ErrNotYourTurn from the seat not to act, got %v", err)
|
||||||
|
}
|
||||||
|
if got := chipsAt(s); got != before {
|
||||||
|
t.Errorf("a refused move moved chips: %d -> %d", before, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,17 +173,9 @@ func botColor(hand []Card, rng *rand.Rand) Color {
|
|||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
// botNames deals the bots their names. Flavour, and load-bearing flavour: "Kiwi
|
// botPool are the regulars a bot seat is named from. Flavour, and load-bearing
|
||||||
// played a +4" is a table, "Bot 2 played a +4" is a test fixture.
|
// flavour: "Kiwi played a +4" is a table, "Bot 2 played a +4" is a test fixture.
|
||||||
func botNames(n int, rng *rand.Rand) []string {
|
// More names than a full table, so no two chairs ever share one.
|
||||||
pool := append([]string(nil), botPool...)
|
|
||||||
rng.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
|
|
||||||
if n > len(pool) {
|
|
||||||
n = len(pool)
|
|
||||||
}
|
|
||||||
return pool[:n]
|
|
||||||
}
|
|
||||||
|
|
||||||
var botPool = []string{
|
var botPool = []string{
|
||||||
"Kiwi", "Mochi", "Bramble", "Pixel", "Gus", "Nori", "Waffle", "Marzipan",
|
"Kiwi", "Mochi", "Bramble", "Pixel", "Gus", "Nori", "Waffle", "Marzipan",
|
||||||
"Tuck", "Bebop", "Olive", "Rascal", "Peaches", "Dot", "Sable", "Clementine",
|
"Tuck", "Bebop", "Olive", "Rascal", "Peaches", "Dot", "Sable", "Clementine",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func botForgets(rng *rand.Rand) bool { return rng.Float64() < botForget }
|
|||||||
// — announces it. A seat on any other number of cards owes nothing and this does
|
// — announces it. A seat on any other number of cards owes nothing and this does
|
||||||
// nothing, which is what makes it safe to call after every play.
|
// nothing, which is what makes it safe to call after every play.
|
||||||
func (s *State) declare(seat int, called bool, evs *[]Event) {
|
func (s *State) declare(seat int, called bool, evs *[]Event) {
|
||||||
if s.Phase == PhaseDone || len(s.Hands[seat]) != 1 {
|
if !s.playing() || len(s.Hands[seat]) != 1 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.ensureCalled()
|
s.ensureCalled()
|
||||||
@@ -59,43 +59,45 @@ func (s *State) declare(seat int, called bool, evs *[]Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// botsCatch is the window. You have just played, you are holding one card, and
|
// botsCatch is the window. The seat that just played is holding one card and
|
||||||
// you didn't say the word — so every bot still in the game gets one look, in seat
|
// didn't say the word — so every bot still in the hand gets one look, in seat
|
||||||
// order, and the first to see it takes you for two.
|
// order, and the first to see it takes them for two.
|
||||||
//
|
//
|
||||||
// It runs before runBots on purpose. The catch has to land while the table is
|
// It runs before runBots on purpose. The catch has to land while the table is
|
||||||
// still looking at the card you played, not three turns later.
|
// still looking at the card that was played, not three turns later. Only the bots
|
||||||
func (s *State) botsCatch(evs *[]Event, rng *rand.Rand) {
|
// catch on a roll here; a human polices the table with the catch button, which is
|
||||||
if s.Phase == PhaseDone || len(s.Hands[You]) != 1 || s.called(You) {
|
// the asymmetry the whole rule turns on — attention is the player's edge.
|
||||||
|
func (s *State) botsCatch(actor int, evs *[]Event, rng *rand.Rand) {
|
||||||
|
if !s.playing() || len(s.Hands[actor]) != 1 || s.called(actor) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, seat := range s.alive() {
|
for _, seat := range s.alive() {
|
||||||
if seat == You {
|
if seat == actor || !s.Seats[seat].Bot {
|
||||||
continue
|
continue // a human catches with the button, not on a roll
|
||||||
}
|
}
|
||||||
if rng.Float64() >= botAlert {
|
if rng.Float64() >= botAlert {
|
||||||
continue // this one wasn't looking
|
continue // this one wasn't looking
|
||||||
}
|
}
|
||||||
s.penalise(You, seat, EvCaught, evs, rng)
|
s.penalise(actor, seat, EvCaught, evs, rng)
|
||||||
return // caught once is caught. They don't queue up to take turns at you
|
return // caught once is caught. They don't queue up to take turns at you
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// playerCatches calls out a seat you think is holding one card in silence.
|
// seatCatches calls out a seat the caller thinks is holding one card in silence.
|
||||||
//
|
//
|
||||||
// It is not a turn: right or wrong, the turn stays where it was, which is with
|
// It is not a turn: right or wrong, the turn stays where it was. What it costs is
|
||||||
// you. What it costs is the risk — a seat that did call, or isn't on one card at
|
// the risk — a seat that did call, or isn't on one card at all, is a seat the
|
||||||
// all, is a seat you have just accused of nothing, and that is two cards to you.
|
// caller has accused of nothing, and that is two cards to them.
|
||||||
func (s *State) playerCatches(m Move, rng *rand.Rand) ([]Event, error) {
|
func (s *State) seatCatches(caller int, m Move, rng *rand.Rand) ([]Event, error) {
|
||||||
seat := m.Seat
|
seat := m.Seat
|
||||||
if seat == You || seat < 0 || seat >= len(s.Hands) || !s.live(seat) {
|
if seat == caller || seat < 0 || seat >= len(s.Hands) || !s.live(seat) {
|
||||||
return nil, ErrNoCatch
|
return nil, ErrNoCatch
|
||||||
}
|
}
|
||||||
var evs []Event
|
var evs []Event
|
||||||
if len(s.Hands[seat]) == 1 && !s.called(seat) {
|
if len(s.Hands[seat]) == 1 && !s.called(seat) {
|
||||||
s.penalise(seat, You, EvCaught, &evs, rng) // got them
|
s.penalise(seat, caller, EvCaught, &evs, rng) // got them
|
||||||
} else {
|
} else {
|
||||||
s.penalise(You, seat, EvMiscall, &evs, rng) // they were clean, and you weren't looking
|
s.penalise(caller, seat, EvMiscall, &evs, rng) // they were clean, and you weren't looking
|
||||||
}
|
}
|
||||||
return evs, nil
|
return evs, nil
|
||||||
}
|
}
|
||||||
@@ -127,11 +129,11 @@ func (s *State) penalise(victim, by int, kind string, evs *[]Event, rng *rand.Ra
|
|||||||
//
|
//
|
||||||
// It answers for every card, legal or not. The table only ever asks about a card
|
// It answers for every card, legal or not. The table only ever asks about a card
|
||||||
// you were allowed to play anyway.
|
// you were allowed to play anyway.
|
||||||
func (s State) UnoAt() []int {
|
func (s State) UnoAt(seat int) []int {
|
||||||
if s.Phase == PhaseDone || s.Turn != You {
|
if !s.playing() || s.Turn != seat {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
hand := s.Hands[You]
|
hand := s.Hands[seat]
|
||||||
var out []int
|
var out []int
|
||||||
for i, c := range hand {
|
for i, c := range hand {
|
||||||
left := len(hand) - 1
|
left := len(hand) - 1
|
||||||
@@ -156,13 +158,13 @@ func (s State) UnoAt() []int {
|
|||||||
// already isn't — this is the same two facts, subtracted, and doing that
|
// already isn't — this is the same two facts, subtracted, and doing that
|
||||||
// subtraction on the server is only so that the rule for what counts as catchable
|
// subtraction on the server is only so that the rule for what counts as catchable
|
||||||
// lives in one place instead of two.
|
// lives in one place instead of two.
|
||||||
func (s State) Catchable() []int {
|
func (s State) Catchable(viewer int) []int {
|
||||||
if s.Phase == PhaseDone || s.Turn != You {
|
if !s.playing() || s.Turn != viewer {
|
||||||
return nil // you can only catch a seat on your own turn
|
return nil // you can only catch a seat on your own turn
|
||||||
}
|
}
|
||||||
var out []int
|
var out []int
|
||||||
for _, seat := range s.alive() {
|
for _, seat := range s.alive() {
|
||||||
if seat != You && len(s.Hands[seat]) == 1 && !s.called(seat) {
|
if seat != viewer && len(s.Hands[seat]) == 1 && !s.called(seat) {
|
||||||
out = append(out, seat)
|
out = append(out, seat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import "testing"
|
|||||||
// a +2.
|
// a +2.
|
||||||
func oneCardAway(t *testing.T, seed uint64) State {
|
func oneCardAway(t *testing.T, seed uint64) State {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s := deal(t, duel(), 100, seed)
|
s := deal(t, duel(), seed)
|
||||||
s.Color = Red
|
s.Color = Red
|
||||||
s.Discard = []Card{{Red, Five}}
|
s.Discard = []Card{{Red, Five}}
|
||||||
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
|
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
|
||||||
@@ -33,7 +33,7 @@ func oneCardAway(t *testing.T, seed uint64) State {
|
|||||||
func TestCallingUnoKeepsYouSafe(t *testing.T) {
|
func TestCallingUnoKeepsYouSafe(t *testing.T) {
|
||||||
for seed := uint64(0); seed < 200; seed++ {
|
for seed := uint64(0); seed < 200; seed++ {
|
||||||
s := oneCardAway(t, seed)
|
s := oneCardAway(t, seed)
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Uno: true})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Uno: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seed %d: play: %v", seed, err)
|
t.Fatalf("seed %d: play: %v", seed, err)
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ func TestForgettingUnoGetsYouCaught(t *testing.T) {
|
|||||||
caught, games := 0, 400
|
caught, games := 0, 400
|
||||||
for seed := uint64(0); seed < uint64(games); seed++ {
|
for seed := uint64(0); seed < uint64(games); seed++ {
|
||||||
s := oneCardAway(t, seed)
|
s := oneCardAway(t, seed)
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seed %d: play: %v", seed, err)
|
t.Fatalf("seed %d: play: %v", seed, err)
|
||||||
}
|
}
|
||||||
@@ -83,13 +83,13 @@ func TestForgettingUnoGetsYouCaught(t *testing.T) {
|
|||||||
// forgetting at a full table is very nearly always punished.
|
// forgetting at a full table is very nearly always punished.
|
||||||
func TestMoreBotsMeansLessGettingAwayWithIt(t *testing.T) {
|
func TestMoreBotsMeansLessGettingAwayWithIt(t *testing.T) {
|
||||||
away := func(tier Tier, seed uint64) bool {
|
away := func(tier Tier, seed uint64) bool {
|
||||||
s := deal(t, tier, 100, seed)
|
s := deal(t, tier, seed)
|
||||||
s.Color = Red
|
s.Color = Red
|
||||||
s.Discard = []Card{{Red, Five}}
|
s.Discard = []Card{{Red, Five}}
|
||||||
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
|
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
|
||||||
s.Turn = You
|
s.Turn = You
|
||||||
s.Phase = PhasePlay
|
s.Phase = PhasePlay
|
||||||
_, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
_, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("play: %v", err)
|
t.Fatalf("play: %v", err)
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ func TestMoreBotsMeansLessGettingAwayWithIt(t *testing.T) {
|
|||||||
// quietBot puts a bot on one card it never called, with the turn back on you.
|
// quietBot puts a bot on one card it never called, with the turn back on you.
|
||||||
func quietBot(t *testing.T, called bool) State {
|
func quietBot(t *testing.T, called bool) State {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s := deal(t, duel(), 100, 21)
|
s := deal(t, duel(), 21)
|
||||||
s.Color = Red
|
s.Color = Red
|
||||||
s.Discard = []Card{{Red, Five}}
|
s.Discard = []Card{{Red, Five}}
|
||||||
s.Hands[You] = []Card{{Red, One}, {Blue, Two}, {Green, Three}}
|
s.Hands[You] = []Card{{Red, One}, {Blue, Two}, {Green, Three}}
|
||||||
@@ -131,7 +131,7 @@ func TestCatchingAQuietBot(t *testing.T) {
|
|||||||
s := quietBot(t, false)
|
s := quietBot(t, false)
|
||||||
before := total(census(s))
|
before := total(census(s))
|
||||||
|
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MoveCatch, Seat: 1})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MoveCatch, Seat: 1})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("catch: %v", err)
|
t.Fatalf("catch: %v", err)
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,7 @@ func TestCatchingACleanBotCostsYou(t *testing.T) {
|
|||||||
}()},
|
}()},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
next, evs, err := ApplyMove(tc.state, Move{Kind: MoveCatch, Seat: 1})
|
next, evs, err := ApplyMove(tc.state, You, Move{Kind: MoveCatch, Seat: 1})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("catch: %v", err)
|
t.Fatalf("catch: %v", err)
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ func TestCatchingACleanBotCostsYou(t *testing.T) {
|
|||||||
func TestYouCannotCatchYourself(t *testing.T) {
|
func TestYouCannotCatchYourself(t *testing.T) {
|
||||||
s := quietBot(t, false)
|
s := quietBot(t, false)
|
||||||
for _, seat := range []int{You, -1, 9} {
|
for _, seat := range []int{You, -1, 9} {
|
||||||
if _, _, err := ApplyMove(s, Move{Kind: MoveCatch, Seat: seat}); err != ErrNoCatch {
|
if _, _, err := ApplyMove(s, You, Move{Kind: MoveCatch, Seat: seat}); err != ErrNoCatch {
|
||||||
t.Errorf("catching seat %d: got %v, want ErrNoCatch", seat, err)
|
t.Errorf("catching seat %d: got %v, want ErrNoCatch", seat, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,7 +198,7 @@ func TestYouCannotCatchYourself(t *testing.T) {
|
|||||||
// your way back down to one: that is a new call you owe, not the old one still
|
// your way back down to one: that is a new call you owe, not the old one still
|
||||||
// standing. Without this a seat could be caught out once and never again.
|
// standing. Without this a seat could be caught out once and never again.
|
||||||
func TestACallIsSpentWhenTheHandGrows(t *testing.T) {
|
func TestACallIsSpentWhenTheHandGrows(t *testing.T) {
|
||||||
s := deal(t, duel(), 100, 5)
|
s := deal(t, duel(), 5)
|
||||||
s.Color = Red
|
s.Color = Red
|
||||||
s.Discard = []Card{{Red, Five}}
|
s.Discard = []Card{{Red, Five}}
|
||||||
s.Hands[You] = []Card{{Red, One}}
|
s.Hands[You] = []Card{{Red, One}}
|
||||||
@@ -208,7 +208,7 @@ func TestACallIsSpentWhenTheHandGrows(t *testing.T) {
|
|||||||
|
|
||||||
// Draw, and the hand is two: the word you said was about a card you no longer
|
// Draw, and the hand is two: the word you said was about a card you no longer
|
||||||
// hold on its own.
|
// hold on its own.
|
||||||
next, _, err := ApplyMove(s, Move{Kind: MoveDraw})
|
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("draw: %v", err)
|
t.Fatalf("draw: %v", err)
|
||||||
}
|
}
|
||||||
@@ -220,17 +220,17 @@ func TestACallIsSpentWhenTheHandGrows(t *testing.T) {
|
|||||||
// TestCatchableIsWhatTheTableCanSee — a quiet bot on one card, and nobody else.
|
// TestCatchableIsWhatTheTableCanSee — a quiet bot on one card, and nobody else.
|
||||||
func TestCatchable(t *testing.T) {
|
func TestCatchable(t *testing.T) {
|
||||||
s := quietBot(t, false)
|
s := quietBot(t, false)
|
||||||
if got := s.Catchable(); len(got) != 1 || got[0] != 1 {
|
if got := s.Catchable(You); len(got) != 1 || got[0] != 1 {
|
||||||
t.Errorf("Catchable() = %v, want [1]", got)
|
t.Errorf("Catchable() = %v, want [1]", got)
|
||||||
}
|
}
|
||||||
clean := quietBot(t, true)
|
clean := quietBot(t, true)
|
||||||
if got := clean.Catchable(); len(got) != 0 {
|
if got := clean.Catchable(You); len(got) != 0 {
|
||||||
t.Errorf("Catchable() = %v on a bot that called, want none", got)
|
t.Errorf("Catchable() = %v on a bot that called, want none", got)
|
||||||
}
|
}
|
||||||
// And not on somebody else's turn: you can only call it out when it's on you.
|
// And not on somebody else's turn: you can only call it out when it's on you.
|
||||||
off := quietBot(t, false)
|
off := quietBot(t, false)
|
||||||
off.Turn = 1
|
off.Turn = 1
|
||||||
if got := off.Catchable(); len(got) != 0 {
|
if got := off.Catchable(You); len(got) != 0 {
|
||||||
t.Errorf("Catchable() = %v off-turn, want none", got)
|
t.Errorf("Catchable() = %v off-turn, want none", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,7 +240,7 @@ func TestCatchable(t *testing.T) {
|
|||||||
// all" takes every card of its colour with it, so a six-card hand can land on
|
// all" takes every card of its colour with it, so a six-card hand can land on
|
||||||
// one, and a browser subtracting one from six gets a player caught.
|
// one, and a browser subtracting one from six gets a player caught.
|
||||||
func TestUnoAtSeesThroughDiscardAll(t *testing.T) {
|
func TestUnoAtSeesThroughDiscardAll(t *testing.T) {
|
||||||
s := deal(t, nmDuel(), 100, 3)
|
s := deal(t, nmDuel(), 3)
|
||||||
s.Color = Red
|
s.Color = Red
|
||||||
s.Discard = []Card{{Red, Five}}
|
s.Discard = []Card{{Red, Five}}
|
||||||
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Red, Seven}, {Blue, Two}}
|
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Red, Seven}, {Blue, Two}}
|
||||||
@@ -249,7 +249,7 @@ func TestUnoAtSeesThroughDiscardAll(t *testing.T) {
|
|||||||
|
|
||||||
// Index 0 dumps itself and the three other reds: five cards become one.
|
// Index 0 dumps itself and the three other reds: five cards become one.
|
||||||
// Index 4 is an ordinary play: five become four.
|
// Index 4 is an ordinary play: five become four.
|
||||||
got := s.UnoAt()
|
got := s.UnoAt(You)
|
||||||
if len(got) != 1 || got[0] != 0 {
|
if len(got) != 1 || got[0] != 0 {
|
||||||
t.Errorf("UnoAt() = %v, want [0]: only the discard-all lands you on one card", got)
|
t.Errorf("UnoAt() = %v, want [0]: only the discard-all lands you on one card", got)
|
||||||
}
|
}
|
||||||
@@ -258,7 +258,7 @@ func TestUnoAtSeesThroughDiscardAll(t *testing.T) {
|
|||||||
// TestUnoAtIsTheOrdinaryCaseToo — two cards in hand, and either of them is a call.
|
// TestUnoAtIsTheOrdinaryCaseToo — two cards in hand, and either of them is a call.
|
||||||
func TestUnoAtIsTheOrdinaryCaseToo(t *testing.T) {
|
func TestUnoAtIsTheOrdinaryCaseToo(t *testing.T) {
|
||||||
s := oneCardAway(t, 1)
|
s := oneCardAway(t, 1)
|
||||||
got := s.UnoAt()
|
got := s.UnoAt(You)
|
||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Errorf("UnoAt() = %v, want both cards: either one leaves you holding one", got)
|
t.Errorf("UnoAt() = %v, want both cards: either one leaves you holding one", got)
|
||||||
}
|
}
|
||||||
@@ -267,19 +267,19 @@ func TestUnoAtIsTheOrdinaryCaseToo(t *testing.T) {
|
|||||||
// TestGoingOutNeedsNoCall — your last card is not one card, it's none. Nobody
|
// TestGoingOutNeedsNoCall — your last card is not one card, it's none. Nobody
|
||||||
// owes the table a word for winning.
|
// owes the table a word for winning.
|
||||||
func TestGoingOutNeedsNoCall(t *testing.T) {
|
func TestGoingOutNeedsNoCall(t *testing.T) {
|
||||||
s := deal(t, duel(), 100, 9)
|
s := deal(t, duel(), 9)
|
||||||
s.Color = Red
|
s.Color = Red
|
||||||
s.Discard = []Card{{Red, Five}}
|
s.Discard = []Card{{Red, Five}}
|
||||||
s.Hands[You] = []Card{{Red, One}}
|
s.Hands[You] = []Card{{Red, One}}
|
||||||
s.Turn = You
|
s.Turn = You
|
||||||
s.Phase = PhasePlay
|
s.Phase = PhasePlay
|
||||||
|
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("play the last card: %v", err)
|
t.Fatalf("play the last card: %v", err)
|
||||||
}
|
}
|
||||||
if !next.Outcome.Won() {
|
if next.Winner != You {
|
||||||
t.Fatalf("outcome %q, want a win", next.Outcome)
|
t.Fatalf("winner %d outcome %q, want a win for you", next.Winner, next.Outcome)
|
||||||
}
|
}
|
||||||
if hasKind(evs, EvCaught) {
|
if hasKind(evs, EvCaught) {
|
||||||
t.Error("caught for not calling UNO on the card that won the game")
|
t.Error("caught for not calling UNO on the card that won the game")
|
||||||
@@ -292,7 +292,7 @@ func TestGoingOutNeedsNoCall(t *testing.T) {
|
|||||||
func TestABotThatForgetsSaysNothing(t *testing.T) {
|
func TestABotThatForgetsSaysNothing(t *testing.T) {
|
||||||
quiet := 0
|
quiet := 0
|
||||||
for seed := uint64(0); seed < 300 && quiet < 1; seed++ {
|
for seed := uint64(0); seed < 300 && quiet < 1; seed++ {
|
||||||
s := deal(t, duel(), 100, seed)
|
s := deal(t, duel(), seed)
|
||||||
s.Color = Red
|
s.Color = Red
|
||||||
s.Discard = []Card{{Red, Five}}
|
s.Discard = []Card{{Red, Five}}
|
||||||
s.Hands[You] = []Card{{Blue, Two}, {Blue, Three}, {Blue, Four}}
|
s.Hands[You] = []Card{{Blue, Two}, {Blue, Three}, {Blue, Four}}
|
||||||
@@ -302,7 +302,7 @@ func TestABotThatForgetsSaysNothing(t *testing.T) {
|
|||||||
s.Turn = You // the bot plays on the back of your move
|
s.Turn = You // the bot plays on the back of your move
|
||||||
|
|
||||||
// Draw, handing the turn over: the bot plays a red and lands on one card.
|
// Draw, handing the turn over: the bot plays a red and lands on one card.
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("draw: %v", err)
|
t.Fatalf("draw: %v", err)
|
||||||
}
|
}
|
||||||
@@ -313,7 +313,7 @@ func TestABotThatForgetsSaysNothing(t *testing.T) {
|
|||||||
if hasKind(evs, EvUno) {
|
if hasKind(evs, EvUno) {
|
||||||
t.Error("a bot that forgot to call still announced it")
|
t.Error("a bot that forgot to call still announced it")
|
||||||
}
|
}
|
||||||
if len(next.Catchable()) != 1 {
|
if len(next.Catchable(You)) != 1 {
|
||||||
t.Error("a quiet bot on one card isn't catchable")
|
t.Error("a quiet bot on one card isn't catchable")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,10 +109,10 @@ func (s *State) absorb(seat int, evs *[]Event, rng *rand.Rand) {
|
|||||||
s.Pending = 0
|
s.Pending = 0
|
||||||
s.deal(seat, n, true, evs, rng)
|
s.deal(seat, n, true, evs, rng)
|
||||||
|
|
||||||
// The seat can die paying the bill, and a mercy kill can end the whole game —
|
// The seat can die paying the bill, and a mercy kill can end the hand — the last
|
||||||
// the player dying, or the last bot dying and leaving you alone at the table.
|
// seat but one dying leaves somebody alone to take the pot. So the phase is only
|
||||||
// So the phase is only reset if there is still a game to have a phase.
|
// reset if there is still a hand to have a phase.
|
||||||
if s.mercy(seat, evs, rng) && s.Phase == PhaseDone {
|
if s.mercy(seat, evs, rng) && !s.playing() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.live(seat) {
|
if !s.live(seat) {
|
||||||
@@ -188,13 +188,13 @@ func (s *State) discardAll(seat int, color Color, evs *[]Event) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mercy checks a seat against the limit and, if it has crossed it, takes it out
|
// mercy checks a seat against the limit and, if it has crossed it, takes it out
|
||||||
// of the game: its cards go back into the deck and it never plays again. It
|
// of the hand: its cards go back into the deck and it plays no more this hand. It
|
||||||
// reports whether the seat died.
|
// reports whether the seat died.
|
||||||
//
|
//
|
||||||
// What that *means* depends on who it was. You dying is the game over — the
|
// A mercy kill is no longer game over for anyone — it is one seat out of the
|
||||||
// stake is gone whatever the bots do next. A bot dying leaves a table with one
|
// current hand, human or bot alike, and the hand plays on among the living. When
|
||||||
// fewer seat, and if it leaves you alone at it, you have won: everybody who could
|
// it leaves exactly one seat alive, that seat has outlived the table and takes the
|
||||||
// have beaten you to the last card is dead.
|
// pot; whoever it is, they win it the same way going out first would.
|
||||||
func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool {
|
func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool {
|
||||||
if !s.Tier.NoMercy || !s.live(seat) || len(s.Hands[seat]) < MercyLimit {
|
if !s.Tier.NoMercy || !s.live(seat) || len(s.Hands[seat]) < MercyLimit {
|
||||||
return false
|
return false
|
||||||
@@ -207,12 +207,8 @@ func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool {
|
|||||||
s.Pending = 0 // a dead seat pays no bill, and leaves none behind
|
s.Pending = 0 // a dead seat pays no bill, and leaves none behind
|
||||||
*evs = append(*evs, s.mine(Event{Kind: EvMercy, Seat: seat, N: n, Left: 0}))
|
*evs = append(*evs, s.mine(Event{Kind: EvMercy, Seat: seat, N: n, Left: 0}))
|
||||||
|
|
||||||
if seat == You {
|
|
||||||
s.lose(evs)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if alive := s.alive(); len(alive) == 1 {
|
if alive := s.alive(); len(alive) == 1 {
|
||||||
s.settle(alive[0], evs) // you outlived the table
|
s.settle(alive[0], OutcomeWon, evs) // the last one standing takes the pot
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -233,17 +229,9 @@ func (s State) alive() []int {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// live reports whether a seat is still playing. Out is empty in a normal game and
|
// live reports whether a seat is still in the current hand. Out is empty between
|
||||||
// in any game saved before No Mercy existed, so a missing entry is a living seat.
|
// hands and in any game saved before No Mercy existed, so a missing entry is a
|
||||||
|
// living seat.
|
||||||
func (s State) live(seat int) bool {
|
func (s State) live(seat int) bool {
|
||||||
return seat >= len(s.Out) || !s.Out[seat]
|
return seat >= len(s.Out) || !s.Out[seat]
|
||||||
}
|
}
|
||||||
|
|
||||||
// lose ends the game against the player without anybody having gone out — which
|
|
||||||
// is what a mercy kill on seat zero is.
|
|
||||||
func (s *State) lose(evs *[]Event) {
|
|
||||||
s.Phase = PhaseDone
|
|
||||||
s.Outcome = OutcomeLost
|
|
||||||
s.Payout = 0
|
|
||||||
*evs = append(*evs, Event{Kind: EvSettle, Seat: You, Text: string(OutcomeLost)})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,32 +33,23 @@ func TestNoMercyDeckIsADeck(t *testing.T) {
|
|||||||
t.Errorf("%v %v: got %d, want %d", c.Color, c.Value, m[c], n)
|
t.Errorf("%v %v: got %d, want %d", c.Color, c.Value, m[c], n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The normal deck's wilds are not in this one, and its coloured +4 is not in
|
|
||||||
// the normal one. They are different cards that print the same thing.
|
|
||||||
if m[Card{Wild, WildCard}] != 0 || m[Card{Wild, WildDrawFour}] != 0 {
|
if m[Card{Wild, WildCard}] != 0 || m[Card{Wild, WildDrawFour}] != 0 {
|
||||||
t.Error("the No Mercy deck should print none of the normal wilds")
|
t.Error("the No Mercy deck should print none of the normal wilds")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNoMercyCensus is the load-bearing one, and the same one the normal game
|
// TestNoMercyCensus is the load-bearing one: 168 cards, each in exactly one place,
|
||||||
// has: 168 cards, each in exactly one place, checked after every move of a
|
// checked after every move of a hundred hands played to the end.
|
||||||
// hundred games played to the end.
|
|
||||||
//
|
|
||||||
// It is what would catch the two new ways this deck can lose a card. Discard All
|
|
||||||
// buries a whole colour under the pile, and a mercy kill shovels a
|
|
||||||
// twenty-five-card hand back into the deck — either of those dropping a card on
|
|
||||||
// the floor is a deck that quietly shrinks until the table can't be dealt.
|
|
||||||
func TestNoMercyCensus(t *testing.T) {
|
func TestNoMercyCensus(t *testing.T) {
|
||||||
for _, tier := range []Tier{nmDuel(), nmTable(), nmFull()} {
|
for _, tier := range []Tier{nmDuel(), nmTable(), nmFull()} {
|
||||||
for seed := uint64(0); seed < 100; seed++ {
|
for seed := uint64(0); seed < 100; seed++ {
|
||||||
s := deal(t, tier, 100, seed)
|
s := deal(t, tier, seed)
|
||||||
start := census(s)
|
if got := total(census(s)); got != 168 {
|
||||||
if got := total(start); got != 168 {
|
|
||||||
t.Fatalf("%s seed %d: dealt %d cards, want 168", tier.Slug, seed, got)
|
t.Fatalf("%s seed %d: dealt %d cards, want 168", tier.Slug, seed, got)
|
||||||
}
|
}
|
||||||
rng := rand.New(rand.NewPCG(seed, 99))
|
rng := rand.New(rand.NewPCG(seed, 99))
|
||||||
for moves := 0; s.Phase != PhaseDone && moves < 800; moves++ {
|
for moves := 0; s.playing() && moves < 800; moves++ {
|
||||||
next, _, err := ApplyMove(s, naive(s, rng))
|
next, _, err := ApplyMove(s, You, naive(s, rng))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("%s seed %d: %v (phase %s)", tier.Slug, seed, err, s.Phase)
|
t.Fatalf("%s seed %d: %v (phase %s)", tier.Slug, seed, err, s.Phase)
|
||||||
}
|
}
|
||||||
@@ -68,38 +59,31 @@ func TestNoMercyCensus(t *testing.T) {
|
|||||||
tier.Slug, seed, total(got))
|
tier.Slug, seed, total(got))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.Phase != PhaseDone {
|
if s.playing() {
|
||||||
t.Fatalf("%s seed %d: game never ended", tier.Slug, seed)
|
t.Fatalf("%s seed %d: hand never ended", tier.Slug, seed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// naive is the strategy the multiples are priced against: play the first legal
|
// naive is a bad-but-real strategy: play the first legal card, take a stack you
|
||||||
// card you hold, take a stack you can't answer, and draw when you have nothing.
|
// can't answer, and draw when you have nothing. Always played from the human seat.
|
||||||
// It is a real way to play and a bad one, which is exactly what a house edge is
|
|
||||||
// measured against.
|
|
||||||
func naive(s State, rng *rand.Rand) Move {
|
func naive(s State, rng *rand.Rand) Move {
|
||||||
if s.Phase == PhaseStack {
|
if s.Phase == PhaseStack {
|
||||||
if p := s.Playable(); len(p) > 0 {
|
if p := s.Playable(You); len(p) > 0 {
|
||||||
return playMove(s, p[0], rng)
|
return playMove(s, p[0], rng)
|
||||||
}
|
}
|
||||||
return Move{Kind: MoveTake}
|
return Move{Kind: MoveTake}
|
||||||
}
|
}
|
||||||
if p := s.Playable(); len(p) > 0 {
|
if p := s.Playable(You); len(p) > 0 {
|
||||||
return playMove(s, p[0], rng)
|
return playMove(s, p[0], rng)
|
||||||
}
|
}
|
||||||
return Move{Kind: MoveDraw}
|
return Move{Kind: MoveDraw}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stack loads a seat's hand up to n cards by taking them off the deck, so the
|
// stack loads a seat's hand up to n cards by taking them off the deck, so the
|
||||||
// table still holds 168 of them. Every card it moves is one that can't be played
|
// table still holds 168 of them.
|
||||||
// on the pile, which is what a hand on its way to the mercy limit looks like.
|
|
||||||
func stack(s *State, seat, n int) {
|
func stack(s *State, seat, n int) {
|
||||||
// Every card the seat was holding goes back in the deck first, so the table is
|
|
||||||
// whole before we take n out of it again. The pile keeps whatever the deal
|
|
||||||
// turned over — replacing it with a card of our choosing would quietly destroy
|
|
||||||
// one, and the census below would blame the engine for it.
|
|
||||||
s.Deck = append(s.Deck, s.Hands[seat]...)
|
s.Deck = append(s.Deck, s.Hands[seat]...)
|
||||||
s.Hands[seat] = nil
|
s.Hands[seat] = nil
|
||||||
s.Color = s.top().Color
|
s.Color = s.top().Color
|
||||||
@@ -120,15 +104,7 @@ func playMove(s State, idx int, rng *rand.Rand) Move {
|
|||||||
if s.Hands[You][idx].IsWild() {
|
if s.Hands[You][idx].IsWild() {
|
||||||
m.Color = Red + Color(rng.IntN(4))
|
m.Color = Red + Color(rng.IntN(4))
|
||||||
}
|
}
|
||||||
// It calls UNO. That is not a strategy, it is a button — the table puts it in
|
for _, at := range s.UnoAt(You) {
|
||||||
// front of you and waits — and what these multiples price is *bad card play*,
|
|
||||||
// not a player who ignores the felt shouting at them.
|
|
||||||
//
|
|
||||||
// A player who genuinely never calls loses far more than this one does, and
|
|
||||||
// nothing here is priced for them. That is the rule having teeth, which is the
|
|
||||||
// point of it. Naive is the floor for someone playing the game badly, not for
|
|
||||||
// someone not playing it.
|
|
||||||
for _, at := range s.UnoAt() {
|
|
||||||
if at == idx {
|
if at == idx {
|
||||||
m.Uno = true
|
m.Uno = true
|
||||||
}
|
}
|
||||||
@@ -136,13 +112,10 @@ func playMove(s State, idx int, rng *rand.Rand) Move {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAStackIsPassedOnAndPaidOnce walks the one rule the whole mode turns on: a
|
// TestAStackIsPassedOnAndPaid walks the rule the whole mode turns on: a draw card
|
||||||
// draw card doesn't land on you, it *opens a bill*, and the seat that can't
|
// opens a bill, and the seat that can't answer pays the whole thing.
|
||||||
// answer pays the whole thing.
|
|
||||||
func TestAStackIsPassedOnAndPaid(t *testing.T) {
|
func TestAStackIsPassedOnAndPaid(t *testing.T) {
|
||||||
s := deal(t, nmDuel(), 100, 7)
|
s := deal(t, nmDuel(), 7)
|
||||||
// Rig it: you hold a +2 on a red pile, the bot holds one card that can answer
|
|
||||||
// and one that can't.
|
|
||||||
s.Color = Red
|
s.Color = Red
|
||||||
s.Discard = []Card{{Red, Five}}
|
s.Discard = []Card{{Red, Five}}
|
||||||
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
|
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
|
||||||
@@ -150,9 +123,7 @@ func TestAStackIsPassedOnAndPaid(t *testing.T) {
|
|||||||
s.Turn = You
|
s.Turn = You
|
||||||
s.Phase = PhasePlay
|
s.Phase = PhasePlay
|
||||||
|
|
||||||
// You play the +2. The bot answers with its own, so the bill comes back to you
|
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||||
// at four — and you have nothing to answer with, so you pay it.
|
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("play +2: %v", err)
|
t.Fatalf("play +2: %v", err)
|
||||||
}
|
}
|
||||||
@@ -168,22 +139,15 @@ func TestAStackIsPassedOnAndPaid(t *testing.T) {
|
|||||||
if !hasKind(evs, EvStack) {
|
if !hasKind(evs, EvStack) {
|
||||||
t.Error("no stack event: the felt has nothing to show the player")
|
t.Error("no stack event: the felt has nothing to show the player")
|
||||||
}
|
}
|
||||||
// You cannot draw your way out of it, and you cannot play a card that isn't a
|
if _, _, err := ApplyMove(next, You, Move{Kind: MoveDraw}); err != ErrMustStack {
|
||||||
// draw card.
|
|
||||||
if _, _, err := ApplyMove(next, Move{Kind: MoveDraw}); err != ErrMustStack {
|
|
||||||
t.Errorf("drawing out of a stack: %v, want ErrMustStack", err)
|
t.Errorf("drawing out of a stack: %v, want ErrMustStack", err)
|
||||||
}
|
}
|
||||||
if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustStack {
|
if _, _, err := ApplyMove(next, You, Move{Kind: MovePlay, Index: 0}); err != ErrMustStack {
|
||||||
t.Errorf("playing a plain card under a stack: %v, want ErrMustStack", err)
|
t.Errorf("playing a plain card under a stack: %v, want ErrMustStack", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pay it. The bot is left holding one card it cannot play, and — because No
|
|
||||||
// Mercy makes it draw until it can — it will draw into a fresh hand and may
|
|
||||||
// well open a *new* stack on the way. That's the game working, not a leak, so
|
|
||||||
// what's asserted here is the bill this seat paid, not the state of the table
|
|
||||||
// afterwards: four cards into the hand, and the bill discharged.
|
|
||||||
before := len(next.Hands[You])
|
before := len(next.Hands[You])
|
||||||
paid, evs, err := ApplyMove(next, Move{Kind: MoveTake})
|
paid, evs, err := ApplyMove(next, You, Move{Kind: MoveTake})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("take: %v", err)
|
t.Fatalf("take: %v", err)
|
||||||
}
|
}
|
||||||
@@ -199,54 +163,49 @@ func TestAStackIsPassedOnAndPaid(t *testing.T) {
|
|||||||
if len(paid.Hands[You]) < before+4 {
|
if len(paid.Hands[You]) < before+4 {
|
||||||
t.Errorf("hand went %d → %d, want at least four more", before, len(paid.Hands[You]))
|
t.Errorf("hand went %d → %d, want at least four more", before, len(paid.Hands[You]))
|
||||||
}
|
}
|
||||||
// The bill you paid is gone. Anything pending now is a new stack the bot
|
|
||||||
// opened after yours was settled, and it is never the one you just paid.
|
|
||||||
if paid.Pending == 4 && paid.Phase == PhaseStack {
|
if paid.Pending == 4 && paid.Phase == PhaseStack {
|
||||||
t.Error("the bill you just paid is still standing")
|
t.Error("the bill you just paid is still standing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTwentyFiveCardsKillsYou is the mercy rule, from the player's side: the
|
// TestMercyKillsThePlayerButNotTheSession: the mercy limit takes the human out of
|
||||||
// stake is gone the moment the hand hits the limit, whoever else is still playing.
|
// the *hand* — the pot goes to whoever is left, and the table plays on. It is no
|
||||||
func TestTwentyFiveCardsKillsYou(t *testing.T) {
|
// longer game over.
|
||||||
s := deal(t, nmFull(), 100, 3)
|
func TestMercyKillsThePlayerButNotTheSession(t *testing.T) {
|
||||||
// Twenty-four cards in your hand, and a stack of ten pointed at you.
|
s := deal(t, nmDuel(), 3)
|
||||||
//
|
|
||||||
// The cards are *moved* from the deck, not invented: a fixture that conjures
|
|
||||||
// a hand out of nothing breaks the census before the engine gets a chance to,
|
|
||||||
// and then the census assertion below is testing the fixture instead of the
|
|
||||||
// mercy rule.
|
|
||||||
stack(&s, You, 24)
|
stack(&s, You, 24)
|
||||||
s.Turn = You
|
s.Turn = You
|
||||||
s.Phase = PhaseStack
|
s.Phase = PhaseStack
|
||||||
s.Pending = 10
|
s.Pending = 10
|
||||||
|
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MoveTake})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MoveTake})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("take: %v", err)
|
t.Fatalf("take: %v", err)
|
||||||
}
|
}
|
||||||
if !hasKind(evs, EvMercy) {
|
if !hasKind(evs, EvMercy) {
|
||||||
t.Fatal("no mercy event: twenty-five cards should have killed the seat")
|
t.Fatal("no mercy event: twenty-five cards should have killed the seat")
|
||||||
}
|
}
|
||||||
if next.Phase != PhaseDone || next.Outcome != OutcomeLost {
|
if next.playing() {
|
||||||
t.Fatalf("phase %s outcome %q, want done/lost", next.Phase, next.Outcome)
|
t.Fatalf("the hand should be over once one seat is left: phase %s", next.Phase)
|
||||||
}
|
}
|
||||||
if next.Payout != 0 {
|
if next.live(You) || len(next.Hands[You]) != 0 {
|
||||||
t.Errorf("a mercy kill paid out %d, want nothing", next.Payout)
|
t.Error("a dead seat should hold no cards and be out of the hand")
|
||||||
}
|
}
|
||||||
if len(next.Hands[You]) != 0 || next.live(You) {
|
// Heads-up, killing the human leaves the bot alone: it takes the pot.
|
||||||
t.Error("a dead seat should hold no cards and be out of the game")
|
if next.Winner != 1 {
|
||||||
|
t.Errorf("winner is seat %d, want the surviving bot", next.Winner)
|
||||||
}
|
}
|
||||||
if got := total(census(next)); got != 168 {
|
if got := total(census(next)); got != 168 {
|
||||||
t.Errorf("%d cards after a mercy kill, want 168 — the hand goes back in the deck", got)
|
t.Errorf("%d cards after a mercy kill, want 168 — the hand goes back in the deck", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOutlivingTheTableWins is the other side of the mercy rule, and the one
|
// TestOutlivingTheTableWins: the deck buries bots too, and a table with every bot
|
||||||
// that makes No Mercy pay less than it looks like it should: the deck buries bots
|
// dead is a pot you have taken.
|
||||||
// too, and a table with every bot dead is a table you have won.
|
|
||||||
func TestOutlivingTheTableWins(t *testing.T) {
|
func TestOutlivingTheTableWins(t *testing.T) {
|
||||||
s := deal(t, nmDuel(), 100, 11)
|
s := deal(t, nmDuel(), 11)
|
||||||
|
pot := s.Pot
|
||||||
|
before := s.Seats[You].Stack
|
||||||
s.Color = Red
|
s.Color = Red
|
||||||
s.Discard = []Card{{Red, Five}}
|
s.Discard = []Card{{Red, Five}}
|
||||||
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
|
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
|
||||||
@@ -257,63 +216,56 @@ func TestOutlivingTheTableWins(t *testing.T) {
|
|||||||
s.Turn = You
|
s.Turn = You
|
||||||
s.Phase = PhasePlay
|
s.Phase = PhasePlay
|
||||||
|
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("play +2: %v", err)
|
t.Fatalf("play +2: %v", err)
|
||||||
}
|
}
|
||||||
if !hasKind(evs, EvMercy) {
|
if !hasKind(evs, EvMercy) {
|
||||||
t.Fatal("the bot should have died taking the stack")
|
t.Fatal("the bot should have died taking the stack")
|
||||||
}
|
}
|
||||||
if next.Phase != PhaseDone || next.Outcome != OutcomeWon {
|
if next.playing() || next.Winner != You {
|
||||||
t.Fatalf("phase %s outcome %q, want done/won: the last seat standing wins",
|
t.Fatalf("phase %s winner %d, want a finished hand won by you", next.Phase, next.Winner)
|
||||||
next.Phase, next.Outcome)
|
|
||||||
}
|
}
|
||||||
if next.Payout != next.Pays() {
|
profit := pot - s.Tier.Ante
|
||||||
t.Errorf("paid %d, quoted %d — settle and the felt must agree", next.Payout, next.Pays())
|
wantWon := pot - int64(float64(profit)*rake)
|
||||||
|
if next.Seats[You].Stack != before+wantWon {
|
||||||
|
t.Errorf("your stack is %d, want %d", next.Seats[You].Stack, before+wantWon)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestYouDrawUntilYouCanPlay: no drawing one card and shrugging. The turn only
|
|
||||||
// moves on when the deck itself has nothing left.
|
|
||||||
func TestYouDrawUntilYouCanPlay(t *testing.T) {
|
func TestYouDrawUntilYouCanPlay(t *testing.T) {
|
||||||
s := deal(t, nmDuel(), 100, 5)
|
s := deal(t, nmDuel(), 5)
|
||||||
s.Color = Red
|
s.Color = Red
|
||||||
s.Discard = []Card{{Red, Five}}
|
s.Discard = []Card{{Red, Five}}
|
||||||
s.Hands[You] = []Card{{Blue, One}} // nothing playable
|
s.Hands[You] = []Card{{Blue, One}} // nothing playable
|
||||||
// A deck whose first two cards are dead and whose third plays.
|
|
||||||
s.Deck = []Card{{Green, Two}, {Yellow, Three}, {Red, Nine}, {Blue, Four}}
|
s.Deck = []Card{{Green, Two}, {Yellow, Three}, {Red, Nine}, {Blue, Four}}
|
||||||
s.Turn = You
|
s.Turn = You
|
||||||
s.Phase = PhasePlay
|
s.Phase = PhasePlay
|
||||||
|
|
||||||
next, _, err := ApplyMove(s, Move{Kind: MoveDraw})
|
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("draw: %v", err)
|
t.Fatalf("draw: %v", err)
|
||||||
}
|
}
|
||||||
if len(next.Hands[You]) != 4 {
|
if len(next.Hands[You]) != 4 {
|
||||||
t.Fatalf("hand is %d, want 4: you draw until something plays",
|
t.Fatalf("hand is %d, want 4: you draw until something plays", len(next.Hands[You]))
|
||||||
len(next.Hands[You]))
|
|
||||||
}
|
}
|
||||||
if next.Phase != PhaseDrawn {
|
if next.Phase != PhaseDrawn {
|
||||||
t.Fatalf("phase %s, want drawn: the card you stopped on is one you must play",
|
t.Fatalf("phase %s, want drawn: the card you stopped on is one you must play", next.Phase)
|
||||||
next.Phase)
|
|
||||||
}
|
}
|
||||||
// And you may not pass on it: you drew for it, you play it.
|
if _, _, err := ApplyMove(next, You, Move{Kind: MovePass}); err != ErrMustPlayNow {
|
||||||
if _, _, err := ApplyMove(next, Move{Kind: MovePass}); err != ErrMustPlayNow {
|
|
||||||
t.Errorf("passing in No Mercy: %v, want ErrMustPlayNow", err)
|
t.Errorf("passing in No Mercy: %v, want ErrMustPlayNow", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSkipAllComesBackToYou — everyone else loses their turn, so the turn never
|
|
||||||
// actually leaves the seat that played it.
|
|
||||||
func TestSkipAllComesBackToYou(t *testing.T) {
|
func TestSkipAllComesBackToYou(t *testing.T) {
|
||||||
s := deal(t, nmFull(), 100, 13)
|
s := deal(t, nmFull(), 13)
|
||||||
s.Color = Red
|
s.Color = Red
|
||||||
s.Discard = []Card{{Red, Five}}
|
s.Discard = []Card{{Red, Five}}
|
||||||
s.Hands[You] = []Card{{Red, SkipAll}, {Blue, One}}
|
s.Hands[You] = []Card{{Red, SkipAll}, {Blue, One}}
|
||||||
s.Turn = You
|
s.Turn = You
|
||||||
s.Phase = PhasePlay
|
s.Phase = PhasePlay
|
||||||
|
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("play skip-all: %v", err)
|
t.Fatalf("play skip-all: %v", err)
|
||||||
}
|
}
|
||||||
@@ -325,10 +277,8 @@ func TestSkipAllComesBackToYou(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDiscardAllTakesTheColourWithIt, and the cards it takes are still in the
|
|
||||||
// game — buried under the pile, not deleted.
|
|
||||||
func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
|
func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
|
||||||
s := deal(t, nmDuel(), 100, 17)
|
s := deal(t, nmDuel(), 17)
|
||||||
s.Color = Red
|
s.Color = Red
|
||||||
s.Discard = []Card{{Red, Five}}
|
s.Discard = []Card{{Red, Five}}
|
||||||
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Blue, Two}}
|
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Blue, Two}}
|
||||||
@@ -336,22 +286,26 @@ func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
|
|||||||
s.Phase = PhasePlay
|
s.Phase = PhasePlay
|
||||||
before := total(census(s))
|
before := total(census(s))
|
||||||
|
|
||||||
// With the call, because this play takes three reds out of the hand at once and
|
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Uno: true})
|
||||||
// lands on exactly one card — which is precisely the case a browser counting
|
|
||||||
// "hand length minus one" would miss, and the reason UnoAt is the engine's job.
|
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Uno: true})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("play discard-all: %v", err)
|
t.Fatalf("play discard-all: %v", err)
|
||||||
}
|
}
|
||||||
if len(next.Hands[You]) != 1 {
|
if len(next.Hands[You]) != 1 {
|
||||||
t.Fatalf("hand is %d, want 1: every red should have gone with it",
|
t.Fatalf("hand is %d, want 1: every red should have gone with it", len(next.Hands[You]))
|
||||||
len(next.Hands[You]))
|
|
||||||
}
|
}
|
||||||
if next.Hands[You][0] != (Card{Blue, Two}) {
|
if next.Hands[You][0] != (Card{Blue, Two}) {
|
||||||
t.Errorf("kept %v, want the blue two", next.Hands[You][0])
|
t.Errorf("kept %v, want the blue two", next.Hands[You][0])
|
||||||
}
|
}
|
||||||
if top := next.Top(); top.Value != DiscardAll {
|
// The discard-all was played, so it is on the pile — the bot may have played over
|
||||||
t.Errorf("the card in play is %v, want the discard-all that was played", top.Value)
|
// it since, which is why this looks for it rather than at the very top.
|
||||||
|
found := false
|
||||||
|
for _, c := range next.Discard {
|
||||||
|
if c.Value == DiscardAll {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("the discard-all isn't on the pile")
|
||||||
}
|
}
|
||||||
if !hasKind(evs, EvDiscardAll) {
|
if !hasKind(evs, EvDiscardAll) {
|
||||||
t.Error("no discard event")
|
t.Error("no discard event")
|
||||||
@@ -361,9 +315,8 @@ func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRouletteFlipsUntilTheColour — and the victim keeps every card it turned.
|
|
||||||
func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
||||||
s := deal(t, nmDuel(), 100, 19)
|
s := deal(t, nmDuel(), 19)
|
||||||
s.Color = Blue
|
s.Color = Blue
|
||||||
s.Discard = []Card{{Blue, Five}}
|
s.Discard = []Card{{Blue, Five}}
|
||||||
s.Hands[You] = []Card{{Wild, WildRoulette}, {Blue, One}}
|
s.Hands[You] = []Card{{Wild, WildRoulette}, {Blue, One}}
|
||||||
@@ -372,8 +325,7 @@ func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
|||||||
s.Turn = You
|
s.Turn = You
|
||||||
s.Phase = PhasePlay
|
s.Phase = PhasePlay
|
||||||
|
|
||||||
// Name red: the bot flips blue, green, yellow, red — four cards — and keeps them.
|
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Red})
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Red})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("play roulette: %v", err)
|
t.Fatalf("play roulette: %v", err)
|
||||||
}
|
}
|
||||||
@@ -386,8 +338,6 @@ func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
|||||||
if got != 4 {
|
if got != 4 {
|
||||||
t.Errorf("flipped %d, want 4 — up to and including the first red", got)
|
t.Errorf("flipped %d, want 4 — up to and including the first red", got)
|
||||||
}
|
}
|
||||||
// One card it started with, plus the four it turned. (The bot is then skipped,
|
|
||||||
// so the turn is back with you and it never played any of them.)
|
|
||||||
if n := len(next.Hands[1]); n != 5 {
|
if n := len(next.Hands[1]); n != 5 {
|
||||||
t.Errorf("the bot holds %d, want 5", n)
|
t.Errorf("the bot holds %d, want 5", n)
|
||||||
}
|
}
|
||||||
@@ -395,43 +345,3 @@ func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
|||||||
t.Error("the roulette lost a card")
|
t.Error("the roulette lost a card")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTheMultiplesAreStillPriced measures the naive strategy against the bots and
|
|
||||||
// checks each tier still charges roughly the house's edge for it.
|
|
||||||
//
|
|
||||||
// This is the test that fails when somebody changes the bots, the deck, or a
|
|
||||||
// rule, and it is *supposed* to: the tier and the game it prices are a pair. If
|
|
||||||
// this goes red, re-measure and move the number, don't loosen the bound.
|
|
||||||
func TestTheMultiplesAreStillPriced(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("slow: plays thousands of games")
|
|
||||||
}
|
|
||||||
for _, tier := range AllTiers() {
|
|
||||||
wins, games := 0, 3000
|
|
||||||
for seed := 0; seed < games; seed++ {
|
|
||||||
s := deal(t, tier, 100, uint64(seed)+7777)
|
|
||||||
rng := rand.New(rand.NewPCG(uint64(seed), 4242))
|
|
||||||
for moves := 0; s.Phase != PhaseDone && moves < 800; moves++ {
|
|
||||||
next, _, err := ApplyMove(s, naive(s, rng))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s: %v", tier.Slug, err)
|
|
||||||
}
|
|
||||||
s = next
|
|
||||||
}
|
|
||||||
if s.Outcome.Won() {
|
|
||||||
wins++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p := float64(wins) / float64(games)
|
|
||||||
// What a staked chip comes back as, playing badly: you win p of the time and
|
|
||||||
// keep the multiple less the rake on the profit, and lose the stake the rest.
|
|
||||||
ev := p*(1+(tier.Base-1)*(1-rake)) - 1
|
|
||||||
t.Logf("%-8s bots=%d base=%.2f naive win rate %.1f%% house edge %.1f%%",
|
|
||||||
tier.Slug, tier.Bots, tier.Base, p*100, -ev*100)
|
|
||||||
if ev < -0.14 || ev > -0.02 {
|
|
||||||
t.Errorf("%s: the house edge on naive play is %.1f%%, which is outside the 2–14%% "+
|
|
||||||
"band the tiers are priced to. Re-measure Base: %.2f would put it near 8%%.",
|
|
||||||
tier.Slug, -ev*100, (0.92/p-1)/(1-rake)+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,19 +8,38 @@ import (
|
|||||||
|
|
||||||
const rake = 0.05
|
const rake = 0.05
|
||||||
|
|
||||||
|
// You is the human's seat in the solo tests, a test-local alias for what the
|
||||||
|
// engine no longer hardcodes: a table is a list of seats, and seat zero being the
|
||||||
|
// human is a convention only these fixtures keep.
|
||||||
|
const You = 0
|
||||||
|
|
||||||
func duel() Tier { t, _ := TierBySlug("duel"); return t }
|
func duel() Tier { t, _ := TierBySlug("duel"); return t }
|
||||||
func full() Tier { t, _ := TierBySlug("full"); return t }
|
func full() Tier { t, _ := TierBySlug("full"); return t }
|
||||||
func table() Tier { t, _ := TierBySlug("table"); return t }
|
func table() Tier { t, _ := TierBySlug("table"); return t }
|
||||||
|
|
||||||
// deal starts a game, failing the test if it can't.
|
// openSolo opens a solo table (one human, the tier's bots) without dealing.
|
||||||
func deal(t *testing.T, tier Tier, bet int64, seed uint64) State {
|
func openSolo(t *testing.T, tier Tier, seed uint64) State {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, evs, err := New(bet, tier, rake, seed, 0xC0FFEE)
|
s, _, err := New(tier, SoloSeats(tier, tier.Bots, 1000), rake, seed, 0xC0FFEE)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New: %v", err)
|
t.Fatalf("New: %v", err)
|
||||||
}
|
}
|
||||||
if len(evs) != 1 || evs[0].Kind != EvDeal {
|
return s
|
||||||
t.Fatalf("New should deal exactly one event, got %+v", evs)
|
}
|
||||||
|
|
||||||
|
// deal opens a solo table and deals the first hand, so the human is to act.
|
||||||
|
func deal(t *testing.T, tier Tier, seed uint64) State {
|
||||||
|
t.Helper()
|
||||||
|
s := openSolo(t, tier, seed)
|
||||||
|
s, evs, err := ApplyMove(s, You, Move{Kind: MoveDeal})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deal: %v", err)
|
||||||
|
}
|
||||||
|
if !hasKind(evs, EvDeal) {
|
||||||
|
t.Fatalf("the deal emitted no deal event: %+v", evs)
|
||||||
|
}
|
||||||
|
if s.Turn != You {
|
||||||
|
t.Fatalf("the human should act first at a solo table, turn is %d", s.Turn)
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -38,11 +57,6 @@ func census(s State) map[Card]int {
|
|||||||
m[c]++
|
m[c]++
|
||||||
}
|
}
|
||||||
for _, c := range s.Discard {
|
for _, c := range s.Discard {
|
||||||
// A wild is stamped with the colour it was played as while it sits on the
|
|
||||||
// pile, so it counts as the wild it really is. This asks the face, rather
|
|
||||||
// than listing the wilds: No Mercy prints four more of them, and a census
|
|
||||||
// that didn't know about them would count a played roulette as a red card
|
|
||||||
// and report a deck that balances while the cards don't.
|
|
||||||
if c.Value.Wild() {
|
if c.Value.Wild() {
|
||||||
c.Color = Wild
|
c.Color = Wild
|
||||||
}
|
}
|
||||||
@@ -76,7 +90,7 @@ func TestNewDeckIsADeck(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewDeals(t *testing.T) {
|
func TestNewDeals(t *testing.T) {
|
||||||
s := deal(t, full(), 100, 7)
|
s := deal(t, full(), 7)
|
||||||
if len(s.Hands) != 4 {
|
if len(s.Hands) != 4 {
|
||||||
t.Fatalf("full house is four seats, got %d", len(s.Hands))
|
t.Fatalf("full house is four seats, got %d", len(s.Hands))
|
||||||
}
|
}
|
||||||
@@ -85,21 +99,31 @@ func TestNewDeals(t *testing.T) {
|
|||||||
t.Errorf("seat %d holds %d cards, want %d", i, len(h), HandSize)
|
t.Errorf("seat %d holds %d cards, want %d", i, len(h), HandSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(s.Bots) != 3 {
|
bots := 0
|
||||||
t.Fatalf("want three bot names, got %v", s.Bots)
|
for _, seat := range s.Seats {
|
||||||
|
if seat.Bot {
|
||||||
|
bots++
|
||||||
|
if seat.Name == "" {
|
||||||
|
t.Error("a bot seat has no name")
|
||||||
}
|
}
|
||||||
if s.Turn != You {
|
}
|
||||||
t.Errorf("you play first, turn is %d", s.Turn)
|
}
|
||||||
|
if bots != 3 {
|
||||||
|
t.Fatalf("want three bots, got %d", bots)
|
||||||
}
|
}
|
||||||
if got := total(census(s)); got != 108 {
|
if got := total(census(s)); got != 108 {
|
||||||
t.Fatalf("the deal lost cards: %d of 108", got)
|
t.Fatalf("the deal lost cards: %d of 108", got)
|
||||||
}
|
}
|
||||||
|
// Every seat anted, so the pot is one ante per seat.
|
||||||
|
if want := s.Tier.Ante * int64(len(s.Seats)); s.Pot != want {
|
||||||
|
t.Errorf("pot is %d, want %d (one ante each)", s.Pot, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The card turned over to start is never an action card — see New.
|
// The card turned over to start is never an action card — see dealHand.
|
||||||
func TestOpeningCardIsANumber(t *testing.T) {
|
func TestOpeningCardIsANumber(t *testing.T) {
|
||||||
for seed := uint64(0); seed < 300; seed++ {
|
for seed := uint64(0); seed < 300; seed++ {
|
||||||
s := deal(t, table(), 50, seed)
|
s := deal(t, table(), seed)
|
||||||
if s.Top().Value.Action() {
|
if s.Top().Value.Action() {
|
||||||
t.Fatalf("seed %d opened on %v", seed, s.Top())
|
t.Fatalf("seed %d opened on %v", seed, s.Top())
|
||||||
}
|
}
|
||||||
@@ -111,12 +135,9 @@ func TestOpeningCardIsANumber(t *testing.T) {
|
|||||||
|
|
||||||
// ---- the rules ------------------------------------------------------------
|
// ---- the rules ------------------------------------------------------------
|
||||||
|
|
||||||
// rig builds a state by hand, so a rule can be tested without hunting a seed
|
// rig builds a live hand by hand, so a rule can be tested without hunting a seed
|
||||||
// that happens to deal it.
|
// that happens to deal it. Every seat has anted, so the pot is set and a win
|
||||||
//
|
// settles for real.
|
||||||
// The deck is the rest of the deck: every card not in a hand and not the one in
|
|
||||||
// play. So a rigged game still holds 108 cards, and the census invariant means
|
|
||||||
// something in these tests too.
|
|
||||||
func rig(hands [][]Card, top Card, color Color) State {
|
func rig(hands [][]Card, top Card, color Color) State {
|
||||||
left := map[Card]int{}
|
left := map[Card]int{}
|
||||||
for _, c := range NewDeck() {
|
for _, c := range NewDeck() {
|
||||||
@@ -137,30 +158,38 @@ func rig(hands [][]Card, top Card, color Color) State {
|
|||||||
|
|
||||||
var deck []Card
|
var deck []Card
|
||||||
for _, c := range NewDeck() {
|
for _, c := range NewDeck() {
|
||||||
key := c
|
if left[c] > 0 {
|
||||||
if left[key] > 0 {
|
left[c]--
|
||||||
left[key]--
|
|
||||||
deck = append(deck, c)
|
deck = append(deck, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ante := full().Ante
|
||||||
|
seats := make([]Seat, len(hands))
|
||||||
|
for i := range seats {
|
||||||
|
// The stack is what is left after this seat anted: a real deal moves the ante
|
||||||
|
// out of the stack and into the pot, so a refund or a win returns it here.
|
||||||
|
seats[i] = Seat{Name: botPool[i], Bot: i != You, Stack: 1000 - ante, Ante: ante}
|
||||||
|
}
|
||||||
|
seats[You].Name = "You"
|
||||||
return State{
|
return State{
|
||||||
Tier: full(), Hands: hands, Discard: []Card{top}, Color: color,
|
Tier: full(), Seats: seats, Hands: hands, Discard: []Card{top}, Color: color,
|
||||||
Deck: deck, Dir: 1, Turn: You, Phase: PhasePlay,
|
Deck: deck, Dir: 1, Turn: You, Dealer: len(hands) - 1, Phase: PhasePlay,
|
||||||
Bet: 100, RakePct: rake, Seed1: 1, Seed2: 2,
|
Pot: ante * int64(len(hands)), Winner: -1,
|
||||||
|
Out: make([]bool, len(hands)), Called: make([]bool, len(hands)),
|
||||||
|
RakePct: rake, Seed1: 1, Seed2: 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlayMustMatch(t *testing.T) {
|
func TestPlayMustMatch(t *testing.T) {
|
||||||
s := rig([][]Card{{{Blue, Three}}, {{Red, Five}}}, Card{Red, Nine}, Red)
|
s := rig([][]Card{{{Blue, Three}}, {{Red, Five}}}, Card{Red, Nine}, Red)
|
||||||
if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}); err != ErrCantPlay {
|
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}); err != ErrCantPlay {
|
||||||
t.Fatalf("a blue 3 on a red 9 should be refused, got %v", err)
|
t.Fatalf("a blue 3 on a red 9 should be refused, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlayMatchesFaceOrColor(t *testing.T) {
|
func TestPlayMatchesFaceOrColor(t *testing.T) {
|
||||||
// Same face, different colour: legal.
|
|
||||||
s := rig([][]Card{{{Blue, Nine}, {Red, Two}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
s := rig([][]Card{{{Blue, Nine}, {Red, Two}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("a blue 9 on a red 9 is legal: %v", err)
|
t.Fatalf("a blue 9 on a red 9 is legal: %v", err)
|
||||||
}
|
}
|
||||||
@@ -174,22 +203,20 @@ func TestPlayMatchesFaceOrColor(t *testing.T) {
|
|||||||
|
|
||||||
func TestWildNeedsAColor(t *testing.T) {
|
func TestWildNeedsAColor(t *testing.T) {
|
||||||
s := rig([][]Card{{{Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
s := rig([][]Card{{{Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||||
if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor {
|
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor {
|
||||||
t.Fatalf("a wild with no colour should be refused, got %v", err)
|
t.Fatalf("a wild with no colour should be refused, got %v", err)
|
||||||
}
|
}
|
||||||
if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Wild}); err != ErrNeedColor {
|
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Wild}); err != ErrNeedColor {
|
||||||
t.Fatalf("naming 'wild' is not naming a colour, got %v", err)
|
t.Fatalf("naming 'wild' is not naming a colour, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWildNamesTheColor(t *testing.T) {
|
func TestWildNamesTheColor(t *testing.T) {
|
||||||
s := rig([][]Card{{{Wild, WildCard}, {Green, One}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
s := rig([][]Card{{{Wild, WildCard}, {Green, One}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||||
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Green})
|
next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Green})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("play wild: %v", err)
|
t.Fatalf("play wild: %v", err)
|
||||||
}
|
}
|
||||||
// The bot moved after us, so the colour in play is whatever it left behind —
|
|
||||||
// what we can check is that the wild itself went down as green.
|
|
||||||
top := next.Discard
|
top := next.Discard
|
||||||
if len(top) < 2 {
|
if len(top) < 2 {
|
||||||
t.Fatalf("expected the wild and the bot's card on the pile: %v", top)
|
t.Fatalf("expected the wild and the bot's card on the pile: %v", top)
|
||||||
@@ -200,10 +227,9 @@ func TestWildNamesTheColor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDrawTwoHitsTheNextSeat(t *testing.T) {
|
func TestDrawTwoHitsTheNextSeat(t *testing.T) {
|
||||||
// Two seats, so the +2 lands on the bot and the turn comes straight back.
|
|
||||||
s := rig([][]Card{{{Red, DrawTwo}, {Red, One}}, {{Blue, Five}, {Blue, Six}}}, Card{Red, Nine}, Red)
|
s := rig([][]Card{{{Red, DrawTwo}, {Red, One}}, {{Blue, Five}, {Blue, Six}}}, Card{Red, Nine}, Red)
|
||||||
s.Tier = duel()
|
s.Tier = duel()
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("play +2: %v", err)
|
t.Fatalf("play +2: %v", err)
|
||||||
}
|
}
|
||||||
@@ -224,7 +250,7 @@ func TestDrawTwoHitsTheNextSeat(t *testing.T) {
|
|||||||
func TestReverseIsASkipHeadsUp(t *testing.T) {
|
func TestReverseIsASkipHeadsUp(t *testing.T) {
|
||||||
s := rig([][]Card{{{Red, Reverse}, {Red, One}}, {{Blue, Five}}}, Card{Red, Nine}, Red)
|
s := rig([][]Card{{{Red, Reverse}, {Red, One}}, {{Blue, Five}}}, Card{Red, Nine}, Red)
|
||||||
s.Tier = duel()
|
s.Tier = duel()
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("play reverse: %v", err)
|
t.Fatalf("play reverse: %v", err)
|
||||||
}
|
}
|
||||||
@@ -243,15 +269,13 @@ func TestReverseIsASkipHeadsUp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReverseTurnsTheTableAround(t *testing.T) {
|
func TestReverseTurnsTheTableAround(t *testing.T) {
|
||||||
// Every bot holds a red card, so each of them can play the moment the turn
|
|
||||||
// reaches it — which is what makes the *order* they play in observable.
|
|
||||||
s := rig([][]Card{
|
s := rig([][]Card{
|
||||||
{{Red, Reverse}, {Red, One}},
|
{{Red, Reverse}, {Red, One}},
|
||||||
{{Red, Five}, {Blue, Six}},
|
{{Red, Five}, {Blue, Six}},
|
||||||
{{Red, Six}, {Green, Six}},
|
{{Red, Six}, {Green, Six}},
|
||||||
{{Red, Seven}, {Yellow, Six}},
|
{{Red, Seven}, {Yellow, Six}},
|
||||||
}, Card{Red, Nine}, Red)
|
}, Card{Red, Nine}, Red)
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("play reverse: %v", err)
|
t.Fatalf("play reverse: %v", err)
|
||||||
}
|
}
|
||||||
@@ -264,7 +288,6 @@ func TestReverseTurnsTheTableAround(t *testing.T) {
|
|||||||
if next.Turn != You {
|
if next.Turn != You {
|
||||||
t.Errorf("the bots should have played round to you, turn is %d", next.Turn)
|
t.Errorf("the bots should have played round to you, turn is %d", next.Turn)
|
||||||
}
|
}
|
||||||
// The table now runs anticlockwise: seat 3 plays, then 2, then 1.
|
|
||||||
var order []int
|
var order []int
|
||||||
for _, e := range evs {
|
for _, e := range evs {
|
||||||
if e.Kind == EvPlay && e.Seat != You {
|
if e.Kind == EvPlay && e.Seat != You {
|
||||||
@@ -277,15 +300,13 @@ func TestReverseTurnsTheTableAround(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSkipSkips(t *testing.T) {
|
func TestSkipSkips(t *testing.T) {
|
||||||
// Both bots hold a playable red, so the only reason either of them doesn't
|
|
||||||
// play is that it wasn't asked to.
|
|
||||||
s := rig([][]Card{
|
s := rig([][]Card{
|
||||||
{{Red, Skip}, {Red, One}},
|
{{Red, Skip}, {Red, One}},
|
||||||
{{Red, Five}, {Blue, Six}},
|
{{Red, Five}, {Blue, Six}},
|
||||||
{{Red, Six}, {Green, Six}},
|
{{Red, Six}, {Green, Six}},
|
||||||
}, Card{Red, Nine}, Red)
|
}, Card{Red, Nine}, Red)
|
||||||
s.Tier = table()
|
s.Tier = table()
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("play skip: %v", err)
|
t.Fatalf("play skip: %v", err)
|
||||||
}
|
}
|
||||||
@@ -310,7 +331,7 @@ func TestSkipSkips(t *testing.T) {
|
|||||||
func TestDrawnPlayableWaitsForYou(t *testing.T) {
|
func TestDrawnPlayableWaitsForYou(t *testing.T) {
|
||||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||||
s.Deck = []Card{{Red, Four}} // exactly what you'll draw, and it plays
|
s.Deck = []Card{{Red, Four}} // exactly what you'll draw, and it plays
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("draw: %v", err)
|
t.Fatalf("draw: %v", err)
|
||||||
}
|
}
|
||||||
@@ -323,18 +344,16 @@ func TestDrawnPlayableWaitsForYou(t *testing.T) {
|
|||||||
if evs[0].Kind != EvDraw || evs[0].Card == nil || *evs[0].Card != (Card{Red, Four}) {
|
if evs[0].Kind != EvDraw || evs[0].Card == nil || *evs[0].Card != (Card{Red, Four}) {
|
||||||
t.Fatalf("your own drawn card comes face up: %+v", evs[0])
|
t.Fatalf("your own drawn card comes face up: %+v", evs[0])
|
||||||
}
|
}
|
||||||
if got := next.Playable(); len(got) != 1 || got[0] != 1 {
|
if got := next.Playable(You); len(got) != 1 || got[0] != 1 {
|
||||||
t.Errorf("the drawn card, and only it, is playable: %v", got)
|
t.Errorf("the drawn card, and only it, is playable: %v", got)
|
||||||
}
|
}
|
||||||
// You may not play the *other* card instead — drawing would otherwise be a
|
if _, _, err := ApplyMove(next, You, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow {
|
||||||
// free look with no cost.
|
|
||||||
if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow {
|
|
||||||
t.Fatalf("only the drawn card may be played, got %v", err)
|
t.Fatalf("only the drawn card may be played, got %v", err)
|
||||||
}
|
}
|
||||||
if _, _, err := ApplyMove(next, Move{Kind: MoveDraw}); err != ErrMustPlayNow {
|
if _, _, err := ApplyMove(next, You, Move{Kind: MoveDraw}); err != ErrMustPlayNow {
|
||||||
t.Fatalf("you can't draw twice, got %v", err)
|
t.Fatalf("you can't draw twice, got %v", err)
|
||||||
}
|
}
|
||||||
after, _, err := ApplyMove(next, Move{Kind: MovePass})
|
after, _, err := ApplyMove(next, You, Move{Kind: MovePass})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("pass: %v", err)
|
t.Fatalf("pass: %v", err)
|
||||||
}
|
}
|
||||||
@@ -349,7 +368,7 @@ func TestDrawnPlayableWaitsForYou(t *testing.T) {
|
|||||||
func TestUnplayableDrawPassesTheTurn(t *testing.T) {
|
func TestUnplayableDrawPassesTheTurn(t *testing.T) {
|
||||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||||
s.Deck = []Card{{Blue, Four}, {Red, Two}} // draw a blue 4: it doesn't go on a red 9
|
s.Deck = []Card{{Blue, Four}, {Red, Two}} // draw a blue 4: it doesn't go on a red 9
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("draw: %v", err)
|
t.Fatalf("draw: %v", err)
|
||||||
}
|
}
|
||||||
@@ -363,69 +382,78 @@ func TestUnplayableDrawPassesTheTurn(t *testing.T) {
|
|||||||
|
|
||||||
func TestPassOnlyAfterADraw(t *testing.T) {
|
func TestPassOnlyAfterADraw(t *testing.T) {
|
||||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||||
if _, _, err := ApplyMove(s, Move{Kind: MovePass}); err != ErrCantPass {
|
if _, _, err := ApplyMove(s, You, Move{Kind: MovePass}); err != ErrCantPass {
|
||||||
t.Fatalf("you can't pass a turn you haven't drawn on, got %v", err)
|
t.Fatalf("you can't pass a turn you haven't drawn on, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// dead is a table nobody can move at: the deck is spent, the discard is one card
|
// dead is a table nobody can move at: the deck is spent, the discard is one card
|
||||||
// deep so there is nothing to reshuffle out of, and not a seat holds a card that
|
// deep, and not a seat holds a card that goes on the pile.
|
||||||
// goes on the pile. Every seat can only pass, forever.
|
|
||||||
func dead(hands [][]Card) State {
|
func dead(hands [][]Card) State {
|
||||||
s := rig(hands, Card{Red, Nine}, Red)
|
s := rig(hands, Card{Red, Nine}, Red)
|
||||||
s.Deck = nil
|
s.Deck = nil
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// The game has to end here. It used to not: the stuck guard counted how many
|
// A dead table ends the hand rather than passing the turn round forever. It no
|
||||||
// bots had passed in a row and asked for more of them than there are seats, so
|
// longer ends the *session* — a shared table plays another hand — so it lands on
|
||||||
// it never fired once, and a dead table just handed the turn round and round.
|
// PhaseHandOver, not PhaseDone.
|
||||||
// That is a game the player cannot finish — and a game they cannot finish is
|
|
||||||
// chips they cannot cash out, because the cage won't let them leave a live hand.
|
|
||||||
func TestDeadTableEnds(t *testing.T) {
|
func TestDeadTableEnds(t *testing.T) {
|
||||||
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}})
|
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}}) // level: one card each
|
||||||
|
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("draw: %v", err)
|
t.Fatalf("draw: %v", err)
|
||||||
}
|
}
|
||||||
if next.Phase != PhaseDone {
|
if next.playing() {
|
||||||
t.Fatalf("nobody can move and there is nothing to draw: the game is over, not %q", next.Phase)
|
t.Fatalf("nobody can move and there is nothing to draw: the hand is over, not %q", next.Phase)
|
||||||
}
|
}
|
||||||
if next.Outcome != OutcomeStuck {
|
if next.Phase != PhaseHandOver {
|
||||||
t.Errorf("a tie on the shortest hand is nobody going out: %q", next.Outcome)
|
t.Fatalf("a dead hand at a shared table returns to handover, not %q", next.Phase)
|
||||||
|
}
|
||||||
|
if next.Outcome != OutcomeTie {
|
||||||
|
t.Errorf("level on the shortest hand is a tie, got %q", next.Outcome)
|
||||||
|
}
|
||||||
|
// A tie hands the antes back: every seat is whole again.
|
||||||
|
for i := range next.Seats {
|
||||||
|
if next.Seats[i].Stack != 1000 {
|
||||||
|
t.Errorf("seat %d wasn't refunded: stack %d, want 1000", i, next.Seats[i].Stack)
|
||||||
}
|
}
|
||||||
if next.Payout != 0 {
|
|
||||||
t.Errorf("a stuck game pays nothing, not %d", next.Payout)
|
|
||||||
}
|
}
|
||||||
if !hasKind(evs, EvSettle) {
|
if !hasKind(evs, EvSettle) {
|
||||||
t.Errorf("the table has to be told it's over: %+v", evs)
|
t.Errorf("the table has to be told the hand is over: %+v", evs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// And the shortest hand takes it, which is the one way a stuck table still pays.
|
// And the shortest hand takes the pot, which is the one way a stuck table pays.
|
||||||
func TestDeadTablePaysTheShortestHand(t *testing.T) {
|
func TestDeadTablePaysTheShortestHand(t *testing.T) {
|
||||||
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}, {Green, Six}}})
|
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}, {Green, Six}}})
|
||||||
|
pot := s.Pot
|
||||||
|
before := s.Seats[You].Stack
|
||||||
|
|
||||||
next, _, err := ApplyMove(s, Move{Kind: MoveDraw})
|
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("draw: %v", err)
|
t.Fatalf("draw: %v", err)
|
||||||
}
|
}
|
||||||
if next.Outcome != OutcomeWon {
|
if next.Outcome != OutcomeStuck || next.Winner != You {
|
||||||
t.Fatalf("one card against two is a win: %q", next.Outcome)
|
t.Fatalf("one card against two is a win for you: outcome %q winner %d", next.Outcome, next.Winner)
|
||||||
}
|
}
|
||||||
if next.Payout != s.Pays() {
|
profit := pot - s.Tier.Ante
|
||||||
t.Errorf("payout %d, quoted %d — the felt has to honour what it advertised", next.Payout, s.Pays())
|
wantRake := int64(float64(profit) * rake)
|
||||||
|
wantWon := pot - wantRake
|
||||||
|
if next.Seats[You].Won != wantWon {
|
||||||
|
t.Errorf("you took %d, want %d (pot %d less rake %d)", next.Seats[You].Won, wantWon, pot, wantRake)
|
||||||
|
}
|
||||||
|
if next.Seats[You].Stack != before+wantWon {
|
||||||
|
t.Errorf("your stack is %d, want %d", next.Seats[You].Stack, before+wantWon)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReshuffleRebuildsTheDeck(t *testing.T) {
|
func TestReshuffleRebuildsTheDeck(t *testing.T) {
|
||||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||||
// An empty deck, and a discard with something under the top card to become one.
|
|
||||||
// The buried wild went down as green; it has to come back as a wild.
|
|
||||||
s.Deck = nil
|
s.Deck = nil
|
||||||
s.Discard = []Card{{Green, WildCard}, {Red, Two}, {Red, Nine}}
|
s.Discard = []Card{{Green, WildCard}, {Red, Two}, {Red, Nine}}
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
|
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("draw on an empty deck: %v", err)
|
t.Fatalf("draw on an empty deck: %v", err)
|
||||||
}
|
}
|
||||||
@@ -449,89 +477,64 @@ func TestReshuffleRebuildsTheDeck(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- the money ------------------------------------------------------------
|
// ---- the pot --------------------------------------------------------------
|
||||||
|
|
||||||
// The rule every game in this casino has had to be taught: the number the felt
|
// The winner takes the pot, and the house's rake comes out of the winnings, never
|
||||||
// quotes and the number the settle lands on are one function, not two.
|
// out of a seat's own ante.
|
||||||
func TestQuoteIsThePayout(t *testing.T) {
|
func TestWinnerTakesThePotLessRake(t *testing.T) {
|
||||||
for _, tier := range Tiers {
|
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}, {{Blue, One}, {Blue, Two}}}, Card{Red, Nine}, Red)
|
||||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
pot := s.Pot
|
||||||
s.Tier = tier
|
before := s.Seats[You].Stack
|
||||||
s.Hands = make([][]Card, tier.Bots+1)
|
|
||||||
s.Hands[You] = []Card{{Red, Three}}
|
|
||||||
for i := 1; i <= tier.Bots; i++ {
|
|
||||||
s.Hands[i] = []Card{{Green, Five}, {Green, Six}}
|
|
||||||
}
|
|
||||||
quoted := s.Pays()
|
|
||||||
|
|
||||||
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) // your last card
|
next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) // your last card
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s: go out: %v", tier.Slug, err)
|
|
||||||
}
|
|
||||||
if next.Outcome != OutcomeWon {
|
|
||||||
t.Fatalf("%s: playing your last card wins, got %q", tier.Slug, next.Outcome)
|
|
||||||
}
|
|
||||||
if next.Payout != quoted {
|
|
||||||
t.Errorf("%s: the felt quoted %d, the house paid %d", tier.Slug, quoted, next.Payout)
|
|
||||||
}
|
|
||||||
if next.Net() != quoted-s.Bet {
|
|
||||||
t.Errorf("%s: net is %d, want %d", tier.Slug, next.Net(), quoted-s.Bet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The rake comes out of the winnings, never the stake.
|
|
||||||
//
|
|
||||||
// The arithmetic is derived from the tier rather than written down. It used to be
|
|
||||||
// written down — payout 214, rake 6 — and those numbers were the 2.2× duel. When
|
|
||||||
// the tiers were re-measured and repriced, this test failed on a rake that was
|
|
||||||
// perfectly correct, which is a test asserting a *price* while claiming to assert
|
|
||||||
// a *rule*. The rule is: the house takes its cut of the profit and never touches
|
|
||||||
// the stake. That holds at any multiple.
|
|
||||||
func TestRakeIsOnWinningsOnly(t *testing.T) {
|
|
||||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
|
||||||
s.Tier = duel()
|
|
||||||
s.Bet = 100
|
|
||||||
|
|
||||||
gross := int64(float64(s.Bet) * s.Tier.Base) // what the tier pays back, before the house
|
|
||||||
profit := gross - s.Bet
|
|
||||||
wantRake := int64(float64(profit) * rake)
|
|
||||||
wantPayout := s.Bet + profit - wantRake
|
|
||||||
|
|
||||||
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("go out: %v", err)
|
t.Fatalf("go out: %v", err)
|
||||||
}
|
}
|
||||||
if next.Payout != wantPayout {
|
if next.Outcome != OutcomeWon || next.Winner != You {
|
||||||
t.Errorf("payout %d, want %d (%d stake + %d winnings - %d rake)",
|
t.Fatalf("playing your last card wins: outcome %q winner %d", next.Outcome, next.Winner)
|
||||||
next.Payout, wantPayout, s.Bet, profit, wantRake)
|
|
||||||
}
|
}
|
||||||
|
profit := pot - s.Tier.Ante
|
||||||
|
wantRake := int64(float64(profit) * rake)
|
||||||
|
wantWon := pot - wantRake
|
||||||
if next.Rake != wantRake {
|
if next.Rake != wantRake {
|
||||||
t.Errorf("rake %d, want %d — and never a penny of the %d stake",
|
t.Errorf("rake %d, want %d — and never a penny of an ante", next.Rake, wantRake)
|
||||||
next.Rake, wantRake, s.Bet)
|
|
||||||
}
|
}
|
||||||
if next.Net() != wantPayout-s.Bet {
|
if next.LastPot != pot {
|
||||||
t.Errorf("net %d, want %d", next.Net(), wantPayout-s.Bet)
|
t.Errorf("last pot %d, want %d", next.LastPot, pot)
|
||||||
|
}
|
||||||
|
if next.Seats[You].Stack != before+wantWon {
|
||||||
|
t.Errorf("your stack is %d, want %d (+%d)", next.Seats[You].Stack, before+wantWon, wantWon)
|
||||||
|
}
|
||||||
|
if next.Paid != wantRake {
|
||||||
|
t.Errorf("the session rake tally is %d, want %d", next.Paid, wantRake)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLosingPaysNothingAndIsNotCharged(t *testing.T) {
|
// A bot winning rakes nothing: the house already keeps the whole pot when its own
|
||||||
// The bot holds one card that plays on the pile, so it goes out the moment the
|
// seat takes it, so there is nothing to charge.
|
||||||
// turn reaches it.
|
func TestABotWinningRakesNothing(t *testing.T) {
|
||||||
|
// The bot at seat 1 holds one card that plays; it goes out the moment the turn
|
||||||
|
// reaches it.
|
||||||
s := rig([][]Card{{{Red, Three}, {Red, Four}}, {{Red, Five}}}, Card{Red, Nine}, Red)
|
s := rig([][]Card{{{Red, Three}, {Red, Four}}, {{Red, Five}}}, Card{Red, Nine}, Red)
|
||||||
s.Tier = duel()
|
s.Tier = duel()
|
||||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
pot := s.Pot
|
||||||
|
|
||||||
|
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("play: %v", err)
|
t.Fatalf("play: %v", err)
|
||||||
}
|
}
|
||||||
if next.Outcome != OutcomeLost {
|
if next.Outcome != OutcomeWon || next.Winner != 1 {
|
||||||
t.Fatalf("the bot went out, so you lost: %q", next.Outcome)
|
t.Fatalf("the bot went out: outcome %q winner %d", next.Outcome, next.Winner)
|
||||||
}
|
}
|
||||||
if next.Payout != 0 || next.Rake != 0 {
|
if next.Rake != 0 {
|
||||||
t.Errorf("a loss pays nothing and is charged nothing: payout %d rake %d", next.Payout, next.Rake)
|
t.Errorf("a bot winning rakes nothing, got %d", next.Rake)
|
||||||
}
|
}
|
||||||
if next.Net() != -s.Bet {
|
if next.Seats[1].Won != pot {
|
||||||
t.Errorf("a loss costs the stake and no more: %d", next.Net())
|
t.Errorf("the bot took %d, want the whole pot %d", next.Seats[1].Won, pot)
|
||||||
|
}
|
||||||
|
// You anted and lost it: your stack is down exactly one ante.
|
||||||
|
if next.Seats[You].Stack != 1000-s.Tier.Ante {
|
||||||
|
t.Errorf("your stack is %d, want %d (one ante gone)", next.Seats[You].Stack, 1000-s.Tier.Ante)
|
||||||
}
|
}
|
||||||
last := evs[len(evs)-1]
|
last := evs[len(evs)-1]
|
||||||
if last.Kind != EvSettle || last.Seat != 1 {
|
if last.Kind != EvSettle || last.Seat != 1 {
|
||||||
@@ -539,51 +542,81 @@ func TestLosingPaysNothingAndIsNotCharged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNoMoveAfterItIsOver(t *testing.T) {
|
// A hand ending returns the table to handover, ready to deal again — it does not
|
||||||
|
// take a hand move.
|
||||||
|
func TestNoHandMoveBetweenHands(t *testing.T) {
|
||||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
||||||
s.Tier = duel()
|
s.Tier = duel()
|
||||||
done, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
over, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("go out: %v", err)
|
t.Fatalf("go out: %v", err)
|
||||||
}
|
}
|
||||||
if _, _, err := ApplyMove(done, Move{Kind: MoveDraw}); err != ErrGameOver {
|
if over.Phase != PhaseHandOver {
|
||||||
t.Fatalf("a finished game takes no more moves, got %v", err)
|
t.Fatalf("a finished hand returns to handover, got %q", over.Phase)
|
||||||
|
}
|
||||||
|
if _, _, err := ApplyMove(over, You, Move{Kind: MoveDraw}); err != ErrNoHand {
|
||||||
|
t.Fatalf("no hand is in progress between hands, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBadBet(t *testing.T) {
|
// You can deal the next hand, ante again, and play on — the session shape.
|
||||||
if _, _, err := New(0, duel(), rake, 1, 2); err != ErrBadBet {
|
func TestDealTheNextHand(t *testing.T) {
|
||||||
t.Fatalf("want ErrBadBet, got %v", err)
|
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
||||||
|
s.Tier = duel()
|
||||||
|
over, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("go out: %v", err)
|
||||||
|
}
|
||||||
|
again, evs, err := ApplyMove(over, You, Move{Kind: MoveDeal})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deal the next hand: %v", err)
|
||||||
|
}
|
||||||
|
if again.HandNo != over.HandNo+1 {
|
||||||
|
t.Errorf("hand number didn't advance: %d then %d", over.HandNo, again.HandNo)
|
||||||
|
}
|
||||||
|
if !again.playing() {
|
||||||
|
t.Fatalf("the next hand should be live, phase %q", again.Phase)
|
||||||
|
}
|
||||||
|
if !hasKind(evs, EvAnte) || !hasKind(evs, EvDeal) {
|
||||||
|
t.Errorf("the deal antes and turns a card: %+v", evs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadBuyIn(t *testing.T) {
|
||||||
|
if _, _, err := New(duel(), SoloSeats(duel(), 1, 10), rake, 1, 2); err != ErrBadBuyIn {
|
||||||
|
t.Fatalf("a buy-in under the minimum should be refused, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- the whole game -------------------------------------------------------
|
// ---- the whole game -------------------------------------------------------
|
||||||
|
|
||||||
// playOut plays a game to its end with a simple strategy: play the first legal
|
// playOut plays one hand to its end with a simple strategy: play the first legal
|
||||||
// card, otherwise draw, otherwise pass. It asserts the invariants at every step.
|
// card, take a stack you can't answer, otherwise draw, otherwise pass.
|
||||||
func playOut(t *testing.T, s State, maxTurns int) State {
|
func playOut(t *testing.T, s State, maxTurns int) State {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for turn := 0; s.Phase != PhaseDone; turn++ {
|
for turn := 0; s.playing(); turn++ {
|
||||||
if turn > maxTurns {
|
if turn > maxTurns {
|
||||||
t.Fatalf("the game never ended in %d turns", maxTurns)
|
t.Fatalf("the hand never ended in %d turns", maxTurns)
|
||||||
}
|
}
|
||||||
if s.Turn != You {
|
if s.Turn != You {
|
||||||
t.Fatalf("ApplyMove left the turn with seat %d — the bots should always run out", s.Turn)
|
t.Fatalf("ApplyMove left the turn with seat %d — the bots should always run out", s.Turn)
|
||||||
}
|
}
|
||||||
|
|
||||||
var m Move
|
var m Move
|
||||||
if p := s.Playable(); len(p) > 0 {
|
if p := s.Playable(You); len(p) > 0 {
|
||||||
m = Move{Kind: MovePlay, Index: p[0]}
|
m = Move{Kind: MovePlay, Index: p[0]}
|
||||||
if s.Hands[You][p[0]].IsWild() {
|
if s.Hands[You][p[0]].IsWild() {
|
||||||
m.Color = Green
|
m.Color = Green
|
||||||
}
|
}
|
||||||
|
} else if s.Phase == PhaseStack {
|
||||||
|
m = Move{Kind: MoveTake}
|
||||||
} else if s.Phase == PhaseDrawn {
|
} else if s.Phase == PhaseDrawn {
|
||||||
m = Move{Kind: MovePass}
|
m = Move{Kind: MovePass}
|
||||||
} else {
|
} else {
|
||||||
m = Move{Kind: MoveDraw}
|
m = Move{Kind: MoveDraw}
|
||||||
}
|
}
|
||||||
|
|
||||||
next, evs, err := ApplyMove(s, m)
|
next, evs, err := ApplyMove(s, You, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("turn %d: %v (move %+v, phase %v)", turn, err, m, s.Phase)
|
t.Fatalf("turn %d: %v (move %+v, phase %v)", turn, err, m, s.Phase)
|
||||||
}
|
}
|
||||||
@@ -598,13 +631,6 @@ func playOut(t *testing.T, s State, maxTurns int) State {
|
|||||||
t.Fatalf("turn %d: %d of %v, want %d — a card was duplicated", turn, n, c, want)
|
t.Fatalf("turn %d: %d of %v, want %d — a card was duplicated", turn, n, c, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No event ever names a bot's card. That is the hole card of this game, and
|
|
||||||
// it is most of the deck.
|
|
||||||
for _, e := range evs {
|
|
||||||
if (e.Kind == EvDraw || e.Kind == EvForced) && e.Seat != You && e.Card != nil {
|
|
||||||
t.Fatalf("turn %d: a bot's drawn card crossed the wire: %+v", turn, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s = next
|
s = next
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
@@ -622,72 +648,54 @@ func deckCount(c Card) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A hundred games, played out, with the invariants checked at every step. This
|
// A hundred hands, played out, with the invariants checked at every step.
|
||||||
// is the test that would have caught a deck that leaks cards through the
|
|
||||||
// reshuffle, a turn the bots don't hand back, or a game that can't end.
|
|
||||||
func TestGamesPlayOut(t *testing.T) {
|
func TestGamesPlayOut(t *testing.T) {
|
||||||
wins, losses, stuck := 0, 0, 0
|
yous, others, ties := 0, 0, 0
|
||||||
for seed := uint64(0); seed < 100; seed++ {
|
for seed := uint64(0); seed < 100; seed++ {
|
||||||
tier := Tiers[seed%3]
|
tier := Tiers[seed%3]
|
||||||
end := playOut(t, deal(t, tier, 100, seed), 500)
|
end := playOut(t, deal(t, tier, seed), 500)
|
||||||
switch end.Outcome {
|
if end.Phase != PhaseHandOver {
|
||||||
case OutcomeWon:
|
t.Fatalf("seed %d ended in phase %q", seed, end.Phase)
|
||||||
wins++
|
|
||||||
if end.Payout != end.Pays() {
|
|
||||||
t.Fatalf("seed %d: paid %d, quoted %d", seed, end.Payout, end.Pays())
|
|
||||||
}
|
}
|
||||||
case OutcomeLost:
|
switch {
|
||||||
losses++
|
case end.Winner == You:
|
||||||
case OutcomeStuck:
|
yous++
|
||||||
stuck++
|
case end.Winner >= 0:
|
||||||
|
others++
|
||||||
|
case end.Outcome == OutcomeTie:
|
||||||
|
ties++
|
||||||
default:
|
default:
|
||||||
t.Fatalf("seed %d ended as %q", seed, end.Outcome)
|
t.Fatalf("seed %d ended with winner %d outcome %q", seed, end.Winner, end.Outcome)
|
||||||
}
|
}
|
||||||
if len(end.Hands[end.winnerSeat()]) != 0 && end.Outcome != OutcomeStuck {
|
if end.Winner >= 0 && end.Outcome == OutcomeWon && len(end.Hands[end.Winner]) != 0 {
|
||||||
t.Fatalf("seed %d: the winner is still holding cards", seed)
|
t.Fatalf("seed %d: the winner is still holding cards", seed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Playing the first legal card is a poor strategy against bots that hold their
|
if yous == 0 || others == 0 {
|
||||||
// +4s back, so this is not a fairness assertion — it's a check that both
|
t.Fatalf("100 hands gave %d to you, %d to others, %d tied — one side never happens", yous, others, ties)
|
||||||
// outcomes actually happen. A table that never pays is a bug in the bots.
|
|
||||||
if wins == 0 || losses == 0 {
|
|
||||||
t.Fatalf("100 games gave %d wins, %d losses, %d stuck — one side never happens", wins, losses, stuck)
|
|
||||||
}
|
}
|
||||||
t.Logf("100 games: %d won, %d lost, %d stuck", wins, losses, stuck)
|
t.Logf("100 hands: %d to you, %d to others, %d tied", yous, others, ties)
|
||||||
}
|
}
|
||||||
|
|
||||||
// winnerSeat is the seat the settle event named — only used by the tests.
|
// The same seed deals the same hand and the bots make the same choices.
|
||||||
func (s State) winnerSeat() int {
|
|
||||||
best := 0
|
|
||||||
for i := range s.Hands {
|
|
||||||
if len(s.Hands[i]) < len(s.Hands[best]) {
|
|
||||||
best = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
|
|
||||||
// The same seed deals the same game and the bots make the same choices — which
|
|
||||||
// is what lets a disputed game be replayed exactly as it fell.
|
|
||||||
func TestReplaysFromTheSeed(t *testing.T) {
|
func TestReplaysFromTheSeed(t *testing.T) {
|
||||||
a := playOut(t, deal(t, full(), 250, 42), 500)
|
a := playOut(t, deal(t, full(), 42), 500)
|
||||||
b := playOut(t, deal(t, full(), 250, 42), 500)
|
b := playOut(t, deal(t, full(), 42), 500)
|
||||||
|
|
||||||
ja, _ := json.Marshal(a)
|
ja, _ := json.Marshal(a)
|
||||||
jb, _ := json.Marshal(b)
|
jb, _ := json.Marshal(b)
|
||||||
if string(ja) != string(jb) {
|
if string(ja) != string(jb) {
|
||||||
t.Fatal("the same seed played the same way gave two different games")
|
t.Fatal("the same seed played the same way gave two different games")
|
||||||
}
|
}
|
||||||
if a.Outcome == "" {
|
if a.Winner < 0 && a.Outcome != OutcomeTie {
|
||||||
t.Fatal("the replay didn't finish")
|
t.Fatal("the replay didn't finish")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A game in progress survives a redeploy: it is a plain value, so it round-trips
|
// A game in progress survives a redeploy: it round-trips through its JSON.
|
||||||
// through the JSON it is stored as.
|
|
||||||
func TestStateSurvivesStorage(t *testing.T) {
|
func TestStateSurvivesStorage(t *testing.T) {
|
||||||
s := deal(t, table(), 100, 9)
|
s := deal(t, table(), 9)
|
||||||
s, _, err := ApplyMove(s, Move{Kind: MoveDraw})
|
s, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("draw: %v", err)
|
t.Fatalf("draw: %v", err)
|
||||||
}
|
}
|
||||||
@@ -704,12 +712,10 @@ func TestStateSurvivesStorage(t *testing.T) {
|
|||||||
if string(again) != string(blob) {
|
if string(again) != string(blob) {
|
||||||
t.Fatal("a stored game came back different")
|
t.Fatal("a stored game came back different")
|
||||||
}
|
}
|
||||||
// And it plays on from there.
|
|
||||||
playOut(t, back, 500)
|
playOut(t, back, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A move the engine refuses leaves the caller's state exactly as it was — no
|
// A move the engine refuses leaves the caller's state exactly as it was.
|
||||||
// card half-played, no turn half-passed.
|
|
||||||
func TestARefusedMoveChangesNothing(t *testing.T) {
|
func TestARefusedMoveChangesNothing(t *testing.T) {
|
||||||
s := rig([][]Card{{{Blue, Three}, {Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
s := rig([][]Card{{{Blue, Three}, {Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||||
before, _ := json.Marshal(s)
|
before, _ := json.Marshal(s)
|
||||||
@@ -721,7 +727,7 @@ func TestARefusedMoveChangesNothing(t *testing.T) {
|
|||||||
{Kind: MovePass}, // nothing drawn
|
{Kind: MovePass}, // nothing drawn
|
||||||
{Kind: "shuffle-the-deck-in-my-favour"}, // no
|
{Kind: "shuffle-the-deck-in-my-favour"}, // no
|
||||||
} {
|
} {
|
||||||
if _, _, err := ApplyMove(s, m); err == nil {
|
if _, _, err := ApplyMove(s, You, m); err == nil {
|
||||||
t.Fatalf("%+v should have been refused", m)
|
t.Fatalf("%+v should have been refused", m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -731,13 +737,12 @@ func TestARefusedMoveChangesNothing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The bots choose. Two different seeds should not play the same bot game, or the
|
// The bots choose. Two different seeds should not play the same game.
|
||||||
// bot is a lookup table you can memorise.
|
|
||||||
func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) {
|
func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) {
|
||||||
same := 0
|
same := 0
|
||||||
for seed := uint64(0); seed < 20; seed++ {
|
for seed := uint64(0); seed < 20; seed++ {
|
||||||
a := playOut(t, deal(t, duel(), 100, seed), 500)
|
a := playOut(t, deal(t, duel(), seed), 500)
|
||||||
b := playOut(t, deal(t, duel(), 100, seed+1000), 500)
|
b := playOut(t, deal(t, duel(), seed+1000), 500)
|
||||||
if len(a.Discard) == len(b.Discard) {
|
if len(a.Discard) == len(b.Discard) {
|
||||||
same++
|
same++
|
||||||
}
|
}
|
||||||
@@ -747,8 +752,6 @@ func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// botPick holds its +4 back while it's comfortable, and reaches for it when
|
|
||||||
// somebody is about to go out.
|
|
||||||
func TestBotSavesTheDrawFour(t *testing.T) {
|
func TestBotSavesTheDrawFour(t *testing.T) {
|
||||||
hand := []Card{{Wild, WildDrawFour}, {Red, Five}}
|
hand := []Card{{Wild, WildDrawFour}, {Red, Five}}
|
||||||
top, color := Card{Red, Nine}, Red
|
top, color := Card{Red, Nine}, Red
|
||||||
@@ -781,7 +784,6 @@ func TestBotPicksItsBestColor(t *testing.T) {
|
|||||||
if got := botColor(hand, rng); got != Blue {
|
if got := botColor(hand, rng); got != Blue {
|
||||||
t.Errorf("the bot holds two blues: it should call blue, got %v", got)
|
t.Errorf("the bot holds two blues: it should call blue, got %v", got)
|
||||||
}
|
}
|
||||||
// A hand of nothing but wilds still has to name something.
|
|
||||||
for i := 0; i < 20; i++ {
|
for i := 0; i < 20; i++ {
|
||||||
if got := botColor([]Card{{Wild, WildCard}}, rng); !got.Playable() {
|
if got := botColor([]Card{{Wild, WildCard}}, rng); !got.Playable() {
|
||||||
t.Fatalf("botColor named %v, which is not a colour", got)
|
t.Fatalf("botColor named %v, which is not a colour", got)
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ func runMigrations(d *sql.DB) error {
|
|||||||
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
|
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
|
||||||
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
|
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
|
||||||
addColumnIfMissing(d, "round_robin_state", "last_channel", "TEXT")
|
addColumnIfMissing(d, "round_robin_state", "last_channel", "TEXT")
|
||||||
|
// Occupancy of a shared table. Rows written before the casino went multiplayer
|
||||||
|
// are solo games and read as NULL, which is exactly what they are.
|
||||||
|
addColumnIfMissing(d, "game_live_hands", "table_id", "TEXT")
|
||||||
|
// The public detail sheet (stats + equipped gear) for an adventurer's
|
||||||
|
// click-through page. Rides the roster snapshot; NULL on rows pushed by a
|
||||||
|
// gogobee build that predates the detail page.
|
||||||
|
addColumnIfMissing(d, "adventure_roster", "detail_json", "TEXT")
|
||||||
|
|
||||||
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
|
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
|
||||||
// Check sqlite_master before creating.
|
// Check sqlite_master before creating.
|
||||||
|
|||||||
131
internal/storage/detail.go
Normal file
131
internal/storage/detail.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlayerDetail is one player's private, owner-only expansion — inventory, vault,
|
||||||
|
// house, pets — pushed by gogobee keyed by localpart. Pete stores it in its own
|
||||||
|
// keyspace (player_self_detail) and only ever serves it back to the one
|
||||||
|
// authenticated user it belongs to. Token rides along so the detail page can
|
||||||
|
// prove owner↔page without ever reversing the anonymous roster token.
|
||||||
|
type PlayerDetail struct {
|
||||||
|
Localpart string `json:"localpart"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Inventory []ItemView `json:"inventory,omitempty"`
|
||||||
|
Vault []ItemView `json:"vault,omitempty"`
|
||||||
|
House HouseView `json:"house"`
|
||||||
|
Pets []PetView `json:"pets,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemView is one backpack or vault item.
|
||||||
|
type ItemView struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
Temper int `json:"temper,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HouseView is the owner's housing summary.
|
||||||
|
type HouseView struct {
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
LoanBalance int `json:"loan_balance,omitempty"`
|
||||||
|
Autopay bool `json:"autopay,omitempty"`
|
||||||
|
Rate float64 `json:"rate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PetView is one pet slot.
|
||||||
|
type PetView struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
XP int `json:"xp,omitempty"`
|
||||||
|
ArmorTier int `json:"armor_tier,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplacePlayerDetail swaps the whole private-detail set in one transaction —
|
||||||
|
// replace, never merge, the same contract as the roster: a player who dropped
|
||||||
|
// out of gogobee's push must lose their stale self-view rather than have it
|
||||||
|
// linger. localpart is lowercased upstream to match how a session Username reads.
|
||||||
|
func ReplacePlayerDetail(players []PlayerDetail, snapshotAt int64) error {
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`DELETE FROM player_self_detail`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO player_self_detail (localpart, token, detail_json, snapshot_at)
|
||||||
|
VALUES (?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, p := range players {
|
||||||
|
if p.Localpart == "" || p.Token == "" {
|
||||||
|
continue // a self-view with no owner or no page to hang on is unusable
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := stmt.Exec(p.Localpart, p.Token, string(body), snapshotAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerDetailByOwner returns the private detail for localpart, but only when it
|
||||||
|
// owns the given page token. This is the ownership join the detail page needs:
|
||||||
|
// the signed-in user's localpart is trusted (it comes from their verified
|
||||||
|
// session), and a row exists only if gogobee pushed that same (localpart, token)
|
||||||
|
// pair — so a viewer can only ever unlock the self extras on their own page, and
|
||||||
|
// Pete never has to turn a token back into a handle to decide it.
|
||||||
|
func PlayerDetailByOwner(localpart, token string) (PlayerDetail, bool, error) {
|
||||||
|
if localpart == "" || token == "" {
|
||||||
|
return PlayerDetail{}, false, nil
|
||||||
|
}
|
||||||
|
var storedToken, detailJSON string
|
||||||
|
err := Get().QueryRow(
|
||||||
|
`SELECT token, detail_json FROM player_self_detail WHERE localpart = ?`, localpart).
|
||||||
|
Scan(&storedToken, &detailJSON)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return PlayerDetail{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return PlayerDetail{}, false, err
|
||||||
|
}
|
||||||
|
if storedToken != token {
|
||||||
|
return PlayerDetail{}, false, nil // signed in, but not the owner of this page
|
||||||
|
}
|
||||||
|
var pd PlayerDetail
|
||||||
|
if err := json.Unmarshal([]byte(detailJSON), &pd); err != nil {
|
||||||
|
return PlayerDetail{}, false, err
|
||||||
|
}
|
||||||
|
pd.Localpart = localpart
|
||||||
|
pd.Token = storedToken
|
||||||
|
return pd, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelfToken returns the roster token owned by localpart, if gogobee's last push
|
||||||
|
// carried one. Lets the board mark "your adventurer" without exposing the
|
||||||
|
// localpart↔token map anywhere public.
|
||||||
|
func SelfToken(localpart string) (string, bool) {
|
||||||
|
if localpart == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var token string
|
||||||
|
err := Get().QueryRow(
|
||||||
|
`SELECT token FROM player_self_detail WHERE localpart = ?`, localpart).Scan(&token)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return token, true
|
||||||
|
}
|
||||||
126
internal/storage/detail_test.go
Normal file
126
internal/storage/detail_test.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The private self-detail set is the one place Pete holds a localpart↔token
|
||||||
|
// association. These tests pin the security contract of that store: the
|
||||||
|
// ownership join only ever unlocks a page for the localpart that owns it, and a
|
||||||
|
// replace wipes a departed player's stale self-view rather than leaving it to
|
||||||
|
// linger.
|
||||||
|
|
||||||
|
func seedSelfDetail(t *testing.T, localpart, token string) PlayerDetail {
|
||||||
|
t.Helper()
|
||||||
|
return PlayerDetail{
|
||||||
|
Localpart: localpart,
|
||||||
|
Token: token,
|
||||||
|
Inventory: []ItemView{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}},
|
||||||
|
Vault: []ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
|
||||||
|
House: HouseView{Tier: 2, LoanBalance: 1500},
|
||||||
|
Pets: []PetView{{Type: "cat", Name: "Mittens", Level: 3}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlayerDetailByOwnerMatrix is the ownership matrix: the self-view unlocks
|
||||||
|
// only when the signed-in localpart owns the exact page token. A different
|
||||||
|
// localpart, or the owner viewing someone else's page, gets nothing.
|
||||||
|
func TestPlayerDetailByOwnerMatrix(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||||
|
seedSelfDetail(t, "josie", "tok-josie"),
|
||||||
|
seedSelfDetail(t, "quack", "tok-quack"),
|
||||||
|
}, 1000); err != nil {
|
||||||
|
t.Fatalf("ReplacePlayerDetail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner, on their own page: unlocked, and the private goods come through.
|
||||||
|
pd, ok, err := PlayerDetailByOwner("josie", "tok-josie")
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("owner on own page: ok=%v err=%v, want unlocked", ok, err)
|
||||||
|
}
|
||||||
|
if len(pd.Inventory) != 1 || pd.House.Tier != 2 || len(pd.Pets) != 1 {
|
||||||
|
t.Errorf("owner detail = %+v, want inventory+house+pet carried", pd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Josie signed in, looking at Quack's page: her localpart doesn't own that
|
||||||
|
// token, so the join must refuse — no peeking at another player's private set.
|
||||||
|
if _, ok, _ := PlayerDetailByOwner("josie", "tok-quack"); ok {
|
||||||
|
t.Error("owner unlocked ANOTHER player's page — the token guard failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A signed-in stranger with no self-detail row at all.
|
||||||
|
if _, ok, _ := PlayerDetailByOwner("nobody", "tok-josie"); ok {
|
||||||
|
t.Error("a stranger unlocked a page they have no row for")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty inputs never unlock.
|
||||||
|
if _, ok, _ := PlayerDetailByOwner("", "tok-josie"); ok {
|
||||||
|
t.Error("empty localpart unlocked a page")
|
||||||
|
}
|
||||||
|
if _, ok, _ := PlayerDetailByOwner("josie", ""); ok {
|
||||||
|
t.Error("empty token unlocked a page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReplacePlayerDetailReplaces: the set is swapped whole, never merged — a
|
||||||
|
// player gogobee stops pushing (deleted character, dropped out) must lose their
|
||||||
|
// stale self-view, the same complete-snapshot contract as the board.
|
||||||
|
func TestReplacePlayerDetailReplaces(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||||
|
seedSelfDetail(t, "josie", "tok-josie"),
|
||||||
|
seedSelfDetail(t, "quack", "tok-quack"),
|
||||||
|
}, 1000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Next push carries only Josie; Quack has left.
|
||||||
|
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||||
|
seedSelfDetail(t, "josie", "tok-josie"),
|
||||||
|
}, 1060); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok, _ := PlayerDetailByOwner("quack", "tok-quack"); ok {
|
||||||
|
t.Error("a dropped player's self-view survived the replace")
|
||||||
|
}
|
||||||
|
if _, ok, _ := PlayerDetailByOwner("josie", "tok-josie"); !ok {
|
||||||
|
t.Error("the surviving player lost their self-view")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReplacePlayerDetailTokenFollows: when a player's board token rotates
|
||||||
|
// (re-derived each push), the ownership check must follow it. The stale token no
|
||||||
|
// longer unlocks; the current one does.
|
||||||
|
func TestReplacePlayerDetailTokenFollows(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
if err := ReplacePlayerDetail([]PlayerDetail{seedSelfDetail(t, "josie", "tok-old")}, 1000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ReplacePlayerDetail([]PlayerDetail{seedSelfDetail(t, "josie", "tok-new")}, 1060); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok, _ := PlayerDetailByOwner("josie", "tok-old"); ok {
|
||||||
|
t.Error("the stale token still unlocked the page after a rotation")
|
||||||
|
}
|
||||||
|
if _, ok, _ := PlayerDetailByOwner("josie", "tok-new"); !ok {
|
||||||
|
t.Error("the current token failed to unlock the page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReplacePlayerDetailSkipsUnusable: a row with no owner or no page to hang
|
||||||
|
// on is dropped at write time — it could never be served and would only be dead
|
||||||
|
// weight.
|
||||||
|
func TestReplacePlayerDetailSkipsUnusable(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||||
|
{Localpart: "", Token: "tok-orphan"},
|
||||||
|
{Localpart: "ghost", Token: ""},
|
||||||
|
seedSelfDetail(t, "josie", "tok-josie"),
|
||||||
|
}, 1000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tok, ok := SelfToken("josie"); !ok || tok != "tok-josie" {
|
||||||
|
t.Errorf("SelfToken(josie) = %q,%v, want tok-josie", tok, ok)
|
||||||
|
}
|
||||||
|
if _, ok := SelfToken("ghost"); ok {
|
||||||
|
t.Error("a tokenless row was stored")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -398,12 +398,38 @@ func Stake(user string, amount int64) error {
|
|||||||
// Award returns chips to a player when a hand settles: stake plus winnings, net
|
// Award returns chips to a player when a hand settles: stake plus winnings, net
|
||||||
// of rake, exactly as the engine computed it. A losing hand awards nothing and
|
// of rake, exactly as the engine computed it. A losing hand awards nothing and
|
||||||
// should not call this.
|
// should not call this.
|
||||||
|
//
|
||||||
|
// This is the standalone form, for a caller with no transaction of its own. A
|
||||||
|
// settle must not use it — see award, and the warning on CommitHand.
|
||||||
func Award(user string, amount int64) error {
|
func Award(user string, amount int64) error {
|
||||||
if amount <= 0 {
|
if amount <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
now := nowUnix()
|
tx, err := Get().Begin()
|
||||||
if _, err := Get().Exec(
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: begin award: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||||
|
|
||||||
|
if err := award(tx, user, amount, nowUnix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("games: commit award: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// award credits a stack inside an open transaction.
|
||||||
|
//
|
||||||
|
// It differs from addChips in one deliberate way: it moves last_played, because
|
||||||
|
// being paid is something that happens at a table and the reaper should see it.
|
||||||
|
// A buy-in is not — that is why addChips leaves the idle clock alone.
|
||||||
|
func award(tx *sql.Tx, user string, amount int64, now int64) error {
|
||||||
|
if amount <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
|
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT(matrix_user) DO UPDATE SET
|
ON CONFLICT(matrix_user) DO UPDATE SET
|
||||||
@@ -431,11 +457,28 @@ type Hand struct {
|
|||||||
// with them, any hand in the log can be dealt again exactly as it fell, which is
|
// with them, any hand in the log can be dealt again exactly as it fell, which is
|
||||||
// how a dispute gets answered with a fact instead of an apology.
|
// how a dispute gets answered with a fact instead of an apology.
|
||||||
func RecordHand(h Hand) error {
|
func RecordHand(h Hand) error {
|
||||||
if _, err := Get().Exec(
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: begin record hand: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||||
|
|
||||||
|
if err := recordHand(tx, h, nowUnix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("games: commit record hand: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordHand writes the audit row inside an open transaction.
|
||||||
|
func recordHand(tx *sql.Tx, h Hand, now int64) error {
|
||||||
|
if _, err := tx.Exec(
|
||||||
`INSERT INTO game_hands (matrix_user, game, bet, payout, rake, outcome, seed1, seed2, played_at)
|
`INSERT INTO game_hands (matrix_user, game, bet, payout, rake, outcome, seed1, seed2, played_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
h.MatrixUser, h.Game, h.Bet, h.Payout, h.Rake, h.Outcome,
|
h.MatrixUser, h.Game, h.Bet, h.Payout, h.Rake, h.Outcome,
|
||||||
int64(h.Seed1), int64(h.Seed2), nowUnix(),
|
int64(h.Seed1), int64(h.Seed2), now,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("games: record hand: %w", err)
|
return fmt.Errorf("games: record hand: %w", err)
|
||||||
}
|
}
|
||||||
@@ -520,6 +563,12 @@ type LiveHand struct {
|
|||||||
State []byte
|
State []byte
|
||||||
Seed1 uint64
|
Seed1 uint64
|
||||||
Seed2 uint64
|
Seed2 uint64
|
||||||
|
// TableID is set when the player is sitting at a shared table instead of playing
|
||||||
|
// alone. The cards are then in game_tables and State here is empty: this row is
|
||||||
|
// the occupancy claim and nothing else. One row per player either way, which is
|
||||||
|
// the point — the primary key that stops a second solo hand is the same one that
|
||||||
|
// stops a second seat.
|
||||||
|
TableID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartLiveHand seats a *new* hand, and refuses if the player is already in one.
|
// StartLiveHand seats a *new* hand, and refuses if the player is already in one.
|
||||||
@@ -566,16 +615,17 @@ func LoadLiveHand(user string) (LiveHand, error) {
|
|||||||
var h LiveHand
|
var h LiveHand
|
||||||
var state string
|
var state string
|
||||||
var s1, s2 int64
|
var s1, s2 int64
|
||||||
|
var tableID sql.NullString
|
||||||
err := Get().QueryRow(
|
err := Get().QueryRow(
|
||||||
`SELECT game, state, seed1, seed2 FROM game_live_hands WHERE matrix_user = ?`, user,
|
`SELECT game, state, seed1, seed2, table_id FROM game_live_hands WHERE matrix_user = ?`, user,
|
||||||
).Scan(&h.Game, &state, &s1, &s2)
|
).Scan(&h.Game, &state, &s1, &s2, &tableID)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return LiveHand{}, ErrNoLiveHand
|
return LiveHand{}, ErrNoLiveHand
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return LiveHand{}, fmt.Errorf("games: load live hand: %w", err)
|
return LiveHand{}, fmt.Errorf("games: load live hand: %w", err)
|
||||||
}
|
}
|
||||||
h.State, h.Seed1, h.Seed2 = []byte(state), uint64(s1), uint64(s2)
|
h.State, h.Seed1, h.Seed2, h.TableID = []byte(state), uint64(s1), uint64(s2), tableID.String
|
||||||
return h, nil
|
return h, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,6 +638,127 @@ func ClearLiveHand(user string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- the settle ------------------------------------------------------------
|
||||||
|
|
||||||
|
// Commit is one write-back of a game: the state, and — if the game is over —
|
||||||
|
// everything settling it takes.
|
||||||
|
type Commit struct {
|
||||||
|
Live LiveHand
|
||||||
|
Fresh bool // a game just started, which is the one write that may be refused
|
||||||
|
|
||||||
|
// Stake is what the player put up to open this game. It is refunded, in this
|
||||||
|
// same transaction, if the seat turns out to be taken. Only meaningful with
|
||||||
|
// Fresh.
|
||||||
|
Stake int64
|
||||||
|
|
||||||
|
Done bool
|
||||||
|
Payout int64 // stake plus winnings, net of rake. Zero on a loss.
|
||||||
|
Audit Hand // the audit row. Ignored unless Done.
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitHand writes a game back and settles it if it is over — all of it in one
|
||||||
|
// transaction.
|
||||||
|
//
|
||||||
|
// It used to be four separate autocommit statements (save, award, record,
|
||||||
|
// clear), which was survivable while a game belonged to exactly one player: the
|
||||||
|
// ordering paid first and cleared second, so a crash in between left a settled
|
||||||
|
// game on the felt, which reads as done and can be cleared. It does not survive
|
||||||
|
// a game with a pot in it. Pay the winner, die before the state write, and the
|
||||||
|
// table still says the hand is live — so it settles a second time and the winner
|
||||||
|
// is paid twice. Chips minted from nothing, and gogobee will happily turn them
|
||||||
|
// into euros.
|
||||||
|
//
|
||||||
|
// So: one Begin, one Commit, and the money and the state move together or not at
|
||||||
|
// all.
|
||||||
|
//
|
||||||
|
// The rule this enforces, and the reason award/recordHand exist in tx-taking
|
||||||
|
// form at all: **nothing inside here may call Get().Exec**. The pool runs at
|
||||||
|
// MaxOpenConns(1), so a bare Exec inside an open transaction waits for the one
|
||||||
|
// connection that this transaction is holding — forever. It is not an error, it
|
||||||
|
// is a hung process, and since the news app shares the pool it takes that down
|
||||||
|
// too. The tx-taking helper is the pattern; addChips has done it this way since
|
||||||
|
// the escrow ledger was written.
|
||||||
|
func CommitHand(user string, c Commit) error {
|
||||||
|
now := nowUnix()
|
||||||
|
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: begin commit: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||||
|
|
||||||
|
// Seat the game first, even one that is already over — a blackjack natural
|
||||||
|
// settles the instant it is dealt. The INSERT is what enforces one game at a
|
||||||
|
// time, and it has to happen for *every* new one, or a natural dealt on top of
|
||||||
|
// a game already in progress would settle, clear the felt, and take the other
|
||||||
|
// game's stake with it.
|
||||||
|
if c.Fresh {
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(matrix_user) DO NOTHING`,
|
||||||
|
user, c.Live.Game, string(c.Live.State), int64(c.Live.Seed1), int64(c.Live.Seed2), now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: start live hand: %w", err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
// Somebody is already sitting here. This game was never seated, so the
|
||||||
|
// chips it staked go back — and they go back *in this transaction*, which
|
||||||
|
// is the point. As two statements, a crash between the refusal and the
|
||||||
|
// refund took the player's stake for a game that never existed anywhere.
|
||||||
|
if err := award(tx, user, c.Stake, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("games: commit refund: %w", err)
|
||||||
|
}
|
||||||
|
return ErrHandInProgress
|
||||||
|
}
|
||||||
|
} else if _, err := tx.Exec(
|
||||||
|
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(matrix_user) DO UPDATE SET
|
||||||
|
game = excluded.game, state = excluded.state,
|
||||||
|
seed1 = excluded.seed1, seed2 = excluded.seed2, updated_at = excluded.updated_at`,
|
||||||
|
user, c.Live.Game, string(c.Live.State), int64(c.Live.Seed1), int64(c.Live.Seed2), now,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("games: save live hand: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Done {
|
||||||
|
if err := award(tx, user, c.Payout, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The audit row is now inside the transaction with the payout, which means a
|
||||||
|
// failure to write it rolls the payout back rather than paying quietly and
|
||||||
|
// logging. That is a deliberate change: the two are the same fact, and a
|
||||||
|
// payout nobody can account for is worse than a payout that didn't happen —
|
||||||
|
// the game stays live and settles again on the next request.
|
||||||
|
if err := recordHand(tx, c.Audit, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil {
|
||||||
|
return fmt.Errorf("games: clear live hand: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch, folded in: a deliberate action at a table, so the reaper leaves them
|
||||||
|
// alone. A player with no chip row yet has nothing to touch, and this is a
|
||||||
|
// no-op for them.
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
|
||||||
|
now, now, user,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("games: touch session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("games: commit hand: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// HouseTake is the total rake collected since a given time — the number that
|
// HouseTake is the total rake collected since a given time — the number that
|
||||||
// answers "is this economy inflating".
|
// answers "is this economy inflating".
|
||||||
func HouseTake(since int64) (int64, error) {
|
func HouseTake(since int64) (int64, error) {
|
||||||
|
|||||||
335
internal/storage/mischief.go
Normal file
335
internal/storage/mischief.go
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The mischief storefront's half of the euro border.
|
||||||
|
//
|
||||||
|
// A buyer signs in, picks a mark off the anonymous roster board, and places a
|
||||||
|
// hit. Pete records only the *intent*: it never moves money and never runs a
|
||||||
|
// single game rule. gogobee's poll loop reads the pending orders, does the real
|
||||||
|
// work against its own ledger (debit, eligibility, open the contract), and hands
|
||||||
|
// back a verdict Pete files against the order. The guid is the idempotency key
|
||||||
|
// end to end — gogobee passes it to DebitIdem and stamps it on the contract — so
|
||||||
|
// a verdict whose ack is lost on the wire can be retried without the buyer paying
|
||||||
|
// twice or the mark catching two hits for one order.
|
||||||
|
|
||||||
|
// MischiefOrder is one storefront order and its current standing.
|
||||||
|
type MischiefOrder struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
BuyerSub string `json:"-"` // OIDC subject; keys "my orders", never sent to gogobee
|
||||||
|
BuyerUsername string `json:"buyer_username"` // localpart gogobee turns into @username:server
|
||||||
|
TargetToken string `json:"target_token"`
|
||||||
|
TargetName string `json:"target_name"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
Signed bool `json:"signed"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order states. These strings cross the wire to gogobee (it POSTs the verdict),
|
||||||
|
// so they are part of the contract — see the schema and gogobee_mischief_plan.md.
|
||||||
|
const (
|
||||||
|
MischiefPending = "pending" // placed; gogobee hasn't acted yet
|
||||||
|
MischiefPlaced = "placed" // gogobee debited the buyer and opened a contract
|
||||||
|
MischiefBouncedFunds = "bounced_funds" // buyer couldn't afford it after all
|
||||||
|
MischiefBouncedIneligible = "bounced_ineligible" // target no longer a valid mark
|
||||||
|
)
|
||||||
|
|
||||||
|
// validMischiefVerdict is the set of terminal states gogobee is allowed to hand
|
||||||
|
// back. An unknown verdict is a contract mismatch, not something to file blindly.
|
||||||
|
func validMischiefVerdict(status string) bool {
|
||||||
|
switch status {
|
||||||
|
case MischiefPlaced, MischiefBouncedFunds, MischiefBouncedIneligible:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNoSuchOrder = errors.New("mischief: no such order")
|
||||||
|
|
||||||
|
// InsertMischiefOrder records a fresh, pending order and returns it with a new
|
||||||
|
// guid. Money and eligibility are gogobee's problem; all Pete asserts here is
|
||||||
|
// that the buyer is signed in and the form was well formed (checked by the
|
||||||
|
// caller). The guid is minted here so the buyer sees a stable reference the
|
||||||
|
// instant they submit, before gogobee has ever heard of it.
|
||||||
|
func InsertMischiefOrder(buyerSub, buyerUsername, targetToken, targetName, tier string, signed bool) (MischiefOrder, error) {
|
||||||
|
guid, err := newGUID()
|
||||||
|
if err != nil {
|
||||||
|
return MischiefOrder{}, err
|
||||||
|
}
|
||||||
|
now := nowUnix()
|
||||||
|
sig := 0
|
||||||
|
if signed {
|
||||||
|
sig = 1
|
||||||
|
}
|
||||||
|
if _, err := Get().Exec(
|
||||||
|
`INSERT INTO mischief_orders
|
||||||
|
(guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
guid, buyerSub, buyerUsername, targetToken, targetName, tier, sig, MischiefPending, now, now,
|
||||||
|
); err != nil {
|
||||||
|
return MischiefOrder{}, fmt.Errorf("mischief: insert order: %w", err)
|
||||||
|
}
|
||||||
|
return MischiefOrder{
|
||||||
|
GUID: guid, BuyerSub: buyerSub, BuyerUsername: buyerUsername,
|
||||||
|
TargetToken: targetToken, TargetName: targetName, Tier: tier,
|
||||||
|
Signed: signed, Status: MischiefPending, CreatedAt: now, UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingMischiefOrders is what gogobee's poll loop reads: every order still
|
||||||
|
// waiting to be acted on. There is no claimed-but-stale window like escrow has,
|
||||||
|
// because a pending order carries no intermediate state — if gogobee dies partway
|
||||||
|
// through, the order simply stays pending and is offered again next poll, and the
|
||||||
|
// guid makes the replay a no-op.
|
||||||
|
func PendingMischiefOrders(limit int) ([]MischiefOrder, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at
|
||||||
|
FROM mischief_orders
|
||||||
|
WHERE status = ?
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT ?`,
|
||||||
|
MischiefPending, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mischief: pending orders: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanMischiefOrders(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveMischiefOrder files gogobee's verdict against a pending order. It is
|
||||||
|
// idempotent: gogobee's poll loop can re-offer and re-resolve the same order, so
|
||||||
|
// a verdict that arrives twice is a no-op the second time. Only a pending order
|
||||||
|
// moves; an order already in a terminal state reports what it already decided,
|
||||||
|
// which is what stops a retried verdict from overwriting the first one.
|
||||||
|
func ResolveMischiefOrder(guid, status, detail string) (MischiefOrder, error) {
|
||||||
|
if !validMischiefVerdict(status) {
|
||||||
|
return MischiefOrder{}, fmt.Errorf("mischief: bad verdict %q", status)
|
||||||
|
}
|
||||||
|
now := nowUnix()
|
||||||
|
if _, err := Get().Exec(
|
||||||
|
`UPDATE mischief_orders SET status = ?, detail = ?, updated_at = ?
|
||||||
|
WHERE guid = ? AND status = ?`,
|
||||||
|
status, detail, now, guid, MischiefPending,
|
||||||
|
); err != nil {
|
||||||
|
return MischiefOrder{}, fmt.Errorf("mischief: resolve order: %w", err)
|
||||||
|
}
|
||||||
|
// Whether the update moved a pending order or matched nothing (missing, or
|
||||||
|
// already terminal from an earlier verdict), the current row is the
|
||||||
|
// authoritative answer — reading it back is the single path either way, and
|
||||||
|
// MischiefOrderByGUID turns a missing row into ErrNoSuchOrder for the caller.
|
||||||
|
return MischiefOrderByGUID(guid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MischiefOrderByGUID reads one order.
|
||||||
|
func MischiefOrderByGUID(guid string) (MischiefOrder, error) {
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at
|
||||||
|
FROM mischief_orders WHERE guid = ?`, guid,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return MischiefOrder{}, fmt.Errorf("mischief: read order: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out, err := scanMischiefOrders(rows)
|
||||||
|
if err != nil {
|
||||||
|
return MischiefOrder{}, err
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return MischiefOrder{}, ErrNoSuchOrder
|
||||||
|
}
|
||||||
|
return out[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MischiefOrdersByBuyer returns a buyer's own recent orders, newest first, for
|
||||||
|
// the storefront status panel. Keyed on the OIDC subject so a username change
|
||||||
|
// doesn't strand a buyer's history.
|
||||||
|
func MischiefOrdersByBuyer(buyerSub string, limit int) ([]MischiefOrder, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at
|
||||||
|
FROM mischief_orders
|
||||||
|
WHERE buyer_sub = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
buyerSub, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mischief: orders by buyer: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanMischiefOrders(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountMischiefOrdersSince counts how many orders a buyer has placed since a unix
|
||||||
|
// cutoff. It backs the storefront's burst guard — the real economic caps (per
|
||||||
|
// day, per target, boss weekly) live on gogobee, this only blunts form-spam.
|
||||||
|
func CountMischiefOrdersSince(buyerSub string, since int64) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM mischief_orders WHERE buyer_sub = ? AND created_at >= ?`,
|
||||||
|
buyerSub, since,
|
||||||
|
).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("mischief: count recent orders: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanMischiefOrders(rows *sql.Rows) ([]MischiefOrder, error) {
|
||||||
|
var out []MischiefOrder
|
||||||
|
for rows.Next() {
|
||||||
|
var o MischiefOrder
|
||||||
|
var sig int
|
||||||
|
if err := rows.Scan(&o.GUID, &o.BuyerSub, &o.BuyerUsername, &o.TargetToken,
|
||||||
|
&o.TargetName, &o.Tier, &sig, &o.Status, &o.Detail, &o.CreatedAt, &o.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("mischief: scan order: %w", err)
|
||||||
|
}
|
||||||
|
o.Signed = sig != 0
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- user_euro: the buyer's own advisory balance -------------------------------
|
||||||
|
|
||||||
|
// ReplaceUserEuro swaps the whole balance table for a new snapshot, in one
|
||||||
|
// transaction, mirroring the roster: a buyer gogobee stopped reporting (opted
|
||||||
|
// out, deleted character) must drop out rather than keep a frozen number forever.
|
||||||
|
// Replace — never merge.
|
||||||
|
func ReplaceUserEuro(balances []MischiefBalance, snapshotAt int64) error {
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`DELETE FROM user_euro`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stmt, err := tx.Prepare(`INSERT INTO user_euro (username, euro, snapshot_at) VALUES (?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
for _, b := range balances {
|
||||||
|
if b.Username == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := stmt.Exec(b.Username, b.Euro, snapshotAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MischiefBalance is one buyer's advisory euro balance in the roster push.
|
||||||
|
type MischiefBalance struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Euro float64 `json:"euro"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- mischief_tiers: the price catalog, pushed by gogobee -----------------------
|
||||||
|
|
||||||
|
// MischiefTier is one rung of the storefront's price list. gogobee is the sole
|
||||||
|
// authority on prices — it pushes the whole catalog on the roster tick so a fee
|
||||||
|
// retune reaches the storefront within a snapshot and Pete never hardcodes a
|
||||||
|
// number that can silently drift. Pete uses it only to render and to validate a
|
||||||
|
// submitted tier key; the real debit is always gogobee's, at its own price.
|
||||||
|
type MischiefTier struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Display string `json:"display"`
|
||||||
|
Fee int `json:"fee"`
|
||||||
|
SignedFee int `json:"signed_fee"`
|
||||||
|
Blurb string `json:"blurb,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceMischiefTiers swaps the whole catalog for gogobee's latest, preserving
|
||||||
|
// the push order (grunt→boss) via an ordinal column. Replace, never merge — a
|
||||||
|
// tier gogobee dropped must disappear from the storefront.
|
||||||
|
func ReplaceMischiefTiers(tiers []MischiefTier) error {
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`DELETE FROM mischief_tiers`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stmt, err := tx.Prepare(`INSERT INTO mischief_tiers (key, display, fee, signed_fee, blurb, ordinal) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
for i, t := range tiers {
|
||||||
|
if t.Key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := stmt.Exec(t.Key, t.Display, t.Fee, t.SignedFee, t.Blurb, i); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MischiefTiers returns the catalog in push order. Empty (not an error) until
|
||||||
|
// gogobee has pushed one — the storefront treats that as "catalog not ready yet".
|
||||||
|
func MischiefTiers() ([]MischiefTier, error) {
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`SELECT key, display, fee, signed_fee, COALESCE(blurb, '') FROM mischief_tiers ORDER BY ordinal`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mischief: read tiers: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []MischiefTier
|
||||||
|
for rows.Next() {
|
||||||
|
var t MischiefTier
|
||||||
|
if err := rows.Scan(&t.Key, &t.Display, &t.Fee, &t.SignedFee, &t.Blurb); err != nil {
|
||||||
|
return nil, fmt.Errorf("mischief: scan tier: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MischiefTierByKey looks up one tier for validating a submitted order.
|
||||||
|
func MischiefTierByKey(key string) (MischiefTier, bool, error) {
|
||||||
|
tiers, err := MischiefTiers()
|
||||||
|
if err != nil {
|
||||||
|
return MischiefTier{}, false, err
|
||||||
|
}
|
||||||
|
for _, t := range tiers {
|
||||||
|
if t.Key == key {
|
||||||
|
return t, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MischiefTier{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserEuro reads one buyer's advisory balance. The bool is false when gogobee has
|
||||||
|
// never reported a balance for them (never played, opted out) — the storefront
|
||||||
|
// then shows tiers without an affordability hint rather than a misleading €0.
|
||||||
|
func UserEuro(username string) (float64, bool, error) {
|
||||||
|
var euro float64
|
||||||
|
err := Get().QueryRow(`SELECT euro FROM user_euro WHERE username = ?`, username).Scan(&euro)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, fmt.Errorf("mischief: read user euro: %w", err)
|
||||||
|
}
|
||||||
|
return euro, true, nil
|
||||||
|
}
|
||||||
178
internal/storage/mischief_test.go
Normal file
178
internal/storage/mischief_test.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMischiefOrderLifecycle(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
o, err := InsertMischiefOrder("sub-1", "reala", "tok-josie", "Josie", "elite", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if o.Status != MischiefPending {
|
||||||
|
t.Fatalf("fresh order status = %q, want pending", o.Status)
|
||||||
|
}
|
||||||
|
if !o.Signed {
|
||||||
|
t.Error("signed flag lost through insert")
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := PendingMischiefOrders(10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(pending) != 1 || pending[0].GUID != o.GUID {
|
||||||
|
t.Fatalf("pending = %+v, want the one order we just placed", pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gogobee places the contract.
|
||||||
|
got, err := ResolveMischiefOrder(o.GUID, MischiefPlaced, "the word is out")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Status != MischiefPlaced || got.Detail != "the word is out" {
|
||||||
|
t.Fatalf("resolved order = %+v, want placed with detail", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// It must leave the pending set.
|
||||||
|
if pending, _ := PendingMischiefOrders(10); len(pending) != 0 {
|
||||||
|
t.Fatalf("placed order still pending: %+v", pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMischiefResolveIsIdempotent is the whole reason the guid is an end-to-end
|
||||||
|
// key: gogobee's poll loop retries, so a verdict can arrive twice, and the second
|
||||||
|
// arrival must not overwrite the first or error.
|
||||||
|
func TestMischiefResolveIsIdempotent(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
o, err := InsertMischiefOrder("sub-1", "reala", "tok", "Josie", "grunt", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ResolveMischiefOrder(o.GUID, MischiefPlaced, "first"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A second, *different* verdict arrives. First one wins.
|
||||||
|
got, err := ResolveMischiefOrder(o.GUID, MischiefBouncedFunds, "second")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("re-resolve errored: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != MischiefPlaced || got.Detail != "first" {
|
||||||
|
t.Fatalf("idempotency broken: order became %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMischiefResolveUnknownAndBadVerdict(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
if _, err := ResolveMischiefOrder("nope", MischiefPlaced, ""); !errors.Is(err, ErrNoSuchOrder) {
|
||||||
|
t.Fatalf("unknown guid err = %v, want ErrNoSuchOrder", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
o, _ := InsertMischiefOrder("sub-1", "reala", "tok", "Josie", "grunt", false)
|
||||||
|
if _, err := ResolveMischiefOrder(o.GUID, "exploded", ""); err == nil {
|
||||||
|
t.Error("a bogus verdict status was accepted")
|
||||||
|
}
|
||||||
|
// The order must survive a rejected verdict as still-pending.
|
||||||
|
if got, _ := MischiefOrderByGUID(o.GUID); got.Status != MischiefPending {
|
||||||
|
t.Fatalf("order moved off pending on a bad verdict: %q", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMischiefOrdersByBuyerAndCount(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, err := InsertMischiefOrder("sub-A", "alice", "tok", "Josie", "grunt", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := InsertMischiefOrder("sub-B", "bob", "tok", "Josie", "grunt", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mine, err := MischiefOrdersByBuyer("sub-A", 20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(mine) != 3 {
|
||||||
|
t.Fatalf("alice sees %d orders, want 3 (and none of bob's)", len(mine))
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := CountMischiefOrdersSince("sub-A", time.Now().Add(-time.Hour).Unix())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 3 {
|
||||||
|
t.Fatalf("count since an hour ago = %d, want 3", n)
|
||||||
|
}
|
||||||
|
if n, _ := CountMischiefOrdersSince("sub-A", time.Now().Add(time.Hour).Unix()); n != 0 {
|
||||||
|
t.Fatalf("count since the future = %d, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMischiefTiersReplaceAndLookup(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
tiers := []MischiefTier{
|
||||||
|
{Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50, Blurb: "theatre"},
|
||||||
|
{Key: "boss", Display: "Boss", Fee: 1200, SignedFee: 1500},
|
||||||
|
}
|
||||||
|
if err := ReplaceMischiefTiers(tiers); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := MischiefTiers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0].Key != "grunt" || got[1].Key != "boss" {
|
||||||
|
t.Fatalf("catalog order not preserved: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
tier, ok, err := MischiefTierByKey("boss")
|
||||||
|
if err != nil || !ok || tier.SignedFee != 1500 {
|
||||||
|
t.Fatalf("lookup boss = %+v ok=%v err=%v", tier, ok, err)
|
||||||
|
}
|
||||||
|
if _, ok, _ := MischiefTierByKey("dragon"); ok {
|
||||||
|
t.Error("lookup invented a tier that was never pushed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace, never merge: a dropped tier vanishes.
|
||||||
|
if err := ReplaceMischiefTiers([]MischiefTier{{Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok, _ := MischiefTierByKey("boss"); ok {
|
||||||
|
t.Error("a tier dropped from the push survived the replace")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserEuroReplaceAndRead(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
if err := ReplaceUserEuro([]MischiefBalance{{Username: "reala", Euro: 820.5}}, time.Now().Unix()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
euro, has, err := UserEuro("reala")
|
||||||
|
if err != nil || !has || euro != 820.5 {
|
||||||
|
t.Fatalf("UserEuro(reala) = %v has=%v err=%v", euro, has, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user gogobee has never reported reads as "unknown", not €0 — the
|
||||||
|
// storefront shows no hint rather than a discouraging, wrong zero.
|
||||||
|
if _, has, _ := UserEuro("stranger"); has {
|
||||||
|
t.Error("UserEuro claimed to know a stranger's balance")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace drops anyone the new snapshot omits.
|
||||||
|
if err := ReplaceUserEuro([]MischiefBalance{{Username: "bob", Euro: 10}}, time.Now().Unix()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, has, _ := UserEuro("reala"); has {
|
||||||
|
t.Error("a balance that fell out of the snapshot survived the replace")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,6 +19,10 @@ type RosterEntry struct {
|
|||||||
Day int `json:"day,omitempty"`
|
Day int `json:"day,omitempty"`
|
||||||
IdleHours int `json:"idle_hours,omitempty"`
|
IdleHours int `json:"idle_hours,omitempty"`
|
||||||
SnapshotAt int64 `json:"snapshot_at"`
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
|
// Detail is the public expanded sheet (stats + gear), carried as raw JSON so
|
||||||
|
// the board path never has to model or touch it — only the detail page decodes
|
||||||
|
// it. Empty when a snapshot predates the detail push.
|
||||||
|
Detail json.RawMessage `json:"detail,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
|
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
|
||||||
@@ -38,16 +43,20 @@ func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
|
|||||||
}
|
}
|
||||||
stmt, err := tx.Prepare(`
|
stmt, err := tx.Prepare(`
|
||||||
INSERT INTO adventure_roster
|
INSERT INTO adventure_roster
|
||||||
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at)
|
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at, detail_json)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer stmt.Close()
|
defer stmt.Close()
|
||||||
|
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
|
var detail any // NULL when absent, so the column reads as "no detail", not "{}"
|
||||||
|
if len(e.Detail) > 0 {
|
||||||
|
detail = string(e.Detail)
|
||||||
|
}
|
||||||
if _, err := stmt.Exec(e.Token, e.Name, e.Level, e.ClassRace, e.Status,
|
if _, err := stmt.Exec(e.Token, e.Name, e.Level, e.ClassRace, e.Status,
|
||||||
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt); err != nil {
|
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt, detail); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,6 +100,31 @@ func LoadRoster() ([]RosterEntry, int64, error) {
|
|||||||
return out, RosterSnapshotAt(), nil
|
return out, RosterSnapshotAt(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RosterEntryByToken looks up one adventurer by their roster token. The bool is
|
||||||
|
// false when no such token is on the current board — which is exactly the check
|
||||||
|
// the storefront needs: a buyer may only order a hit on a mark the live board is
|
||||||
|
// actually showing, never a stale or guessed token.
|
||||||
|
func RosterEntryByToken(token string) (RosterEntry, bool, error) {
|
||||||
|
var e RosterEntry
|
||||||
|
var detail sql.NullString
|
||||||
|
err := Get().QueryRow(`
|
||||||
|
SELECT token, name, level, COALESCE(class_race, ''), status,
|
||||||
|
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at, detail_json
|
||||||
|
FROM adventure_roster WHERE token = ?`, token).Scan(
|
||||||
|
&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
|
||||||
|
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt, &detail)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return RosterEntry{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return RosterEntry{}, false, err
|
||||||
|
}
|
||||||
|
if detail.Valid && detail.String != "" {
|
||||||
|
e.Detail = json.RawMessage(detail.String)
|
||||||
|
}
|
||||||
|
return e, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RosterSnapshotAt reports when the board was last refreshed, 0 if gogobee has
|
// RosterSnapshotAt reports when the board was last refreshed, 0 if gogobee has
|
||||||
// never pushed one. Read from the meta row, not the entries, so a snapshot that
|
// never pushed one. Read from the meta row, not the entries, so a snapshot that
|
||||||
// legitimately carried nobody still counts as a snapshot.
|
// legitimately carried nobody still counts as a snapshot.
|
||||||
|
|||||||
@@ -52,6 +52,83 @@ CREATE TABLE IF NOT EXISTS adventure_roster_meta (
|
|||||||
snapshot_at INTEGER NOT NULL DEFAULT 0
|
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed.
|
||||||
|
-- Keyed by localpart (== Authentik preferred_username == the session's Username),
|
||||||
|
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only
|
||||||
|
-- ever read for the one authenticated user asking about themselves, so the board
|
||||||
|
-- stays anonymous and no endpoint hands out anyone else's number. Advisory only —
|
||||||
|
-- the storefront greys out tiers it thinks you can't afford, but the real debit
|
||||||
|
-- happens on gogobee at claim time and a stale balance just bounces an order.
|
||||||
|
CREATE TABLE IF NOT EXISTS user_euro (
|
||||||
|
username TEXT PRIMARY KEY, -- Matrix localpart == session Username
|
||||||
|
euro REAL NOT NULL DEFAULT 0,
|
||||||
|
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A mischief contract a buyer placed from the web storefront, on its way to
|
||||||
|
-- gogobee. Pete never touches money and never runs the game rules — it only
|
||||||
|
-- records the *intent* and later the verdict gogobee hands back. The status
|
||||||
|
-- ladder:
|
||||||
|
--
|
||||||
|
-- pending -> placed (gogobee debited the buyer and opened a contract)
|
||||||
|
-- -> bounced_funds (buyer couldn't actually afford it)
|
||||||
|
-- -> bounced_ineligible (target no longer a valid mark: no expedition,
|
||||||
|
-- a live contract already, cooldown, cap, ...)
|
||||||
|
--
|
||||||
|
-- guid is the idempotency key end to end: gogobee passes it to DebitIdem and
|
||||||
|
-- stamps it on the contract, so a claim whose ack is lost on the wire can be
|
||||||
|
-- retried without charging the buyer twice or opening two contracts. buyer_sub
|
||||||
|
-- is the OIDC subject (stable across username changes) and keys "my orders";
|
||||||
|
-- buyer_username is what gogobee turns into @username:server. target_token is
|
||||||
|
-- the roster token of the mark — the same anonymous token the board renders, so
|
||||||
|
-- ordering a hit never needs the victim's real handle.
|
||||||
|
CREATE TABLE IF NOT EXISTS mischief_orders (
|
||||||
|
guid TEXT PRIMARY KEY,
|
||||||
|
buyer_sub TEXT NOT NULL,
|
||||||
|
buyer_username TEXT NOT NULL,
|
||||||
|
target_token TEXT NOT NULL,
|
||||||
|
target_name TEXT NOT NULL, -- display copy, frozen at order time
|
||||||
|
tier TEXT NOT NULL,
|
||||||
|
signed INTEGER NOT NULL DEFAULT 0, -- 1 = sign openly (+25%), 0 = anonymous
|
||||||
|
status TEXT NOT NULL, -- see the ladder above
|
||||||
|
detail TEXT, -- gogobee's human note on the verdict
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mischief_orders_pending ON mischief_orders(status, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mischief_orders_buyer ON mischief_orders(buyer_sub, created_at DESC);
|
||||||
|
|
||||||
|
-- The storefront price list. gogobee is the sole authority on prices and pushes
|
||||||
|
-- the whole catalog on the roster tick, so a fee retune reaches the storefront
|
||||||
|
-- within a snapshot and Pete never hardcodes a number that can drift. ordinal
|
||||||
|
-- preserves the grunt->boss order the push arrived in.
|
||||||
|
CREATE TABLE IF NOT EXISTS mischief_tiers (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
display TEXT NOT NULL,
|
||||||
|
fee INTEGER NOT NULL,
|
||||||
|
signed_fee INTEGER NOT NULL,
|
||||||
|
blurb TEXT,
|
||||||
|
ordinal INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A player's private, owner-only expansion — inventory, vault, house, pets —
|
||||||
|
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
|
||||||
|
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
|
||||||
|
-- like user_euro, it is only ever served back to the one authenticated user it
|
||||||
|
-- belongs to, never on the public board. token is that player's current roster
|
||||||
|
-- token, kept here so the detail page can prove owner↔page by a join without
|
||||||
|
-- ever reversing the one-way token — the association lives only in this
|
||||||
|
-- owner-private table and never reaches any public response. detail_json is the
|
||||||
|
-- {inventory, vault, house, pets} body; it is replaced wholesale each tick, so a
|
||||||
|
-- player who drops out of gogobee's push loses their stale self-view.
|
||||||
|
CREATE TABLE IF NOT EXISTS player_self_detail (
|
||||||
|
localpart TEXT PRIMARY KEY,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
detail_json TEXT NOT NULL,
|
||||||
|
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_player_self_detail_token ON player_self_detail(token);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS post_log (
|
CREATE TABLE IF NOT EXISTS post_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
guid TEXT NOT NULL,
|
guid TEXT NOT NULL,
|
||||||
@@ -239,9 +316,103 @@ CREATE TABLE IF NOT EXISTS game_live_hands (
|
|||||||
state TEXT NOT NULL, -- JSON: the engine's State
|
state TEXT NOT NULL, -- JSON: the engine's State
|
||||||
seed1 INTEGER NOT NULL, -- carried to the audit log when it settles
|
seed1 INTEGER NOT NULL, -- carried to the audit log when it settles
|
||||||
seed2 INTEGER NOT NULL,
|
seed2 INTEGER NOT NULL,
|
||||||
|
-- Set when the player is sitting at a shared table rather than playing alone.
|
||||||
|
-- The engine state then lives in game_tables.state, not here, and this row is
|
||||||
|
-- purely the occupancy claim: its PRIMARY KEY is what stops one player being
|
||||||
|
-- in two games at once, and it is the row the cash-out check reads. Making
|
||||||
|
-- game_seats a second uniqueness domain instead would be a split brain — see
|
||||||
|
-- the comment on game_seats.
|
||||||
|
table_id TEXT,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Shared tables: the casino with more than one person at it.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- A table other people can sit at. The state column is the engine's State,
|
||||||
|
-- exactly as game_live_hands holds it for a solo game — one blob for the whole
|
||||||
|
-- felt, because a pot is not divisible into per-player rows.
|
||||||
|
--
|
||||||
|
-- version is the concurrency authority, and the mutex in the web layer is only
|
||||||
|
-- an optimisation on top of it. Every state write is a conditional UPDATE
|
||||||
|
-- against the version the writer read; zero rows affected means somebody moved
|
||||||
|
-- first. This has to live in the database rather than in a mutex map because a
|
||||||
|
-- mutex does not survive a redeploy — during a drain, two processes hold two
|
||||||
|
-- different mutexes over the same row and both believe they are alone.
|
||||||
|
CREATE TABLE IF NOT EXISTS game_tables (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
game TEXT NOT NULL, -- 'holdem' | 'uno' | 'blackjack'
|
||||||
|
tier TEXT NOT NULL, -- the stake, as that game names it
|
||||||
|
state TEXT NOT NULL, -- JSON: the engine's State
|
||||||
|
seed1 INTEGER NOT NULL,
|
||||||
|
seed2 INTEGER NOT NULL,
|
||||||
|
phase TEXT NOT NULL, -- the engine's phase, lifted out so the lobby can read it
|
||||||
|
hand_no INTEGER NOT NULL DEFAULT 0, -- with id, the identity of one hand: the payout key
|
||||||
|
version INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- Unix seconds by which the seat to act must act, or 0 for no clock. The turn
|
||||||
|
-- clock scans this. It is set only when the turn lands on a human: bots resolve
|
||||||
|
-- inside ApplyMove and are never waited for.
|
||||||
|
deadline INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_tables_due ON game_tables(deadline) WHERE deadline > 0;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_tables_lobby ON game_tables(game, updated_at DESC);
|
||||||
|
|
||||||
|
-- Who is sitting where. A seat with no matrix_user is a bot.
|
||||||
|
--
|
||||||
|
-- This is deliberately *not* a uniqueness domain for players: there is no unique
|
||||||
|
-- index on matrix_user, and there must not be one. Occupancy is decided by
|
||||||
|
-- game_live_hands' primary key, which already stops a player being in two games,
|
||||||
|
-- already makes a double-clicked join a 409, and is already what the cash-out
|
||||||
|
-- check reads. A second domain that could disagree with the first would silently
|
||||||
|
-- switch all three off — the worst of them being a player who cashes out to zero
|
||||||
|
-- while sitting at a poker table with chips in the pot.
|
||||||
|
--
|
||||||
|
-- staked is what the player brought to the table and has not yet taken home. It
|
||||||
|
-- is the chip-conservation anchor: the chips are off their game_chips stack and
|
||||||
|
-- inside the table blob, where the idle reaper cannot see them.
|
||||||
|
CREATE TABLE IF NOT EXISTS game_seats (
|
||||||
|
table_id TEXT NOT NULL,
|
||||||
|
seat INTEGER NOT NULL,
|
||||||
|
matrix_user TEXT, -- NULL for a bot
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
staked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- Set once a human's clock has run out on them. An absent human is not a bot,
|
||||||
|
-- but the bot loop has to be allowed past their seat or a table with three
|
||||||
|
-- ghosts spends a minute an orbit folding air. They come back the moment they act.
|
||||||
|
away INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (table_id, seat)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_seats_user ON game_seats(matrix_user) WHERE matrix_user IS NOT NULL;
|
||||||
|
|
||||||
|
-- There is no payout ledger here, and its absence is deliberate — the design
|
||||||
|
-- called for one and the money model made it unnecessary. Chips cross into a
|
||||||
|
-- table when a player sits down and back out when they get up; a hand ending
|
||||||
|
-- moves the pot *within* the state blob and credits nobody's game_chips row. So
|
||||||
|
-- there is no money write to make idempotent: a settle is a state write,
|
||||||
|
-- conditional on the version, and a replayed one affects zero rows and rolls
|
||||||
|
-- back. See the header of internal/storage/tables.go.
|
||||||
|
|
||||||
|
-- Chat on the felt. Messages only — no typing indicators, which is the one thing
|
||||||
|
-- that would have justified a socket. It does not mirror into Matrix.
|
||||||
|
--
|
||||||
|
-- hand_no is kept against every line for a reason: at a table of real people,
|
||||||
|
-- collusion looks like chat, and the only way to ever answer that question is to
|
||||||
|
-- be able to read what was said during the hand it was said in.
|
||||||
|
CREATE TABLE IF NOT EXISTS game_chat (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
table_id TEXT NOT NULL,
|
||||||
|
hand_no INTEGER NOT NULL,
|
||||||
|
matrix_user TEXT, -- NULL when the house is talking
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
said_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_chat_table ON game_chat(table_id, id);
|
||||||
|
|
||||||
-- The trivia bank: questions pulled from the Open Trivia Database ahead of time,
|
-- The trivia bank: questions pulled from the Open Trivia Database ahead of time,
|
||||||
-- so that asking one is a local read.
|
-- so that asking one is a local read.
|
||||||
--
|
--
|
||||||
|
|||||||
193
internal/storage/settle_test.go
Normal file
193
internal/storage/settle_test.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The settle used to be four autocommit statements in the web layer — save the
|
||||||
|
// state, award the payout, write the audit row, clear the felt — sequenced so
|
||||||
|
// that a crash between any two of them cost the player as little as possible.
|
||||||
|
//
|
||||||
|
// These are the tests for the thing that replaced it. They are less about the
|
||||||
|
// happy path (the game tests already cover that: a hand pays what it says it
|
||||||
|
// pays) and more about the two properties the old shape did not have, and which
|
||||||
|
// a shared table with a pot in it cannot do without.
|
||||||
|
|
||||||
|
func liveBlob(game string) LiveHand {
|
||||||
|
return LiveHand{Game: game, State: []byte(`{"phase":"player"}`), Seed1: 7, Seed2: 9}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A settled hand moves the money, writes the audit row, and leaves the felt
|
||||||
|
// empty — and it does all three or none of them.
|
||||||
|
//
|
||||||
|
// The old code logged and carried on if the clear failed. That is the double-pay:
|
||||||
|
// a settled hand still sitting in game_live_hands is a hand that settles again on
|
||||||
|
// the next request, and pays again with it.
|
||||||
|
func TestSettleMovesTheMoneyAndTheFeltTogether(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 1000)
|
||||||
|
|
||||||
|
if err := Stake(player, 200); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CommitHand(player, Commit{
|
||||||
|
Live: liveBlob("blackjack"), Fresh: true, Stake: 200,
|
||||||
|
Done: true, Payout: 390,
|
||||||
|
Audit: Hand{MatrixUser: player, Game: "blackjack", Bet: 200, Payout: 390, Rake: 10, Outcome: "won"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paid: 1000 - 200 staked + 390 back.
|
||||||
|
if c := chipsOf(t, player); c != 1190 {
|
||||||
|
t.Fatalf("chips = %d after a 390 payout on a 200 stake, want 1190", c)
|
||||||
|
}
|
||||||
|
// The felt is empty, which is what stops it settling a second time.
|
||||||
|
if _, err := LoadLiveHand(player); !errors.Is(err, ErrNoLiveHand) {
|
||||||
|
t.Fatalf("live hand after settle: err = %v, want ErrNoLiveHand", err)
|
||||||
|
}
|
||||||
|
// And the house took its cut, once.
|
||||||
|
take, err := HouseTake(0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if take != 10 {
|
||||||
|
t.Fatalf("house take = %d, want 10", take)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A seat that is already taken refuses the game *and hands the stake back*, in
|
||||||
|
// the same transaction that refused it.
|
||||||
|
//
|
||||||
|
// This was two statements: the save came back ErrHandInProgress, and then a
|
||||||
|
// separate Award put the chips back. A crash in between took a player's stake for
|
||||||
|
// a game that never existed anywhere — no felt, no audit row, no way to find it.
|
||||||
|
func TestARefusedSeatGivesTheStakeBackInTheSameBreath(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 1000)
|
||||||
|
|
||||||
|
// A game is already in progress.
|
||||||
|
if err := Stake(player, 200); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CommitHand(player, Commit{Live: liveBlob("uno"), Fresh: true, Stake: 200}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c := chipsOf(t, player); c != 800 {
|
||||||
|
t.Fatalf("chips = %d with 200 staked on a live game, want 800", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second game is dealt on top of it: the stake leaves, as it must, in the
|
||||||
|
// same statement that checks it's there.
|
||||||
|
if err := Stake(player, 300); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := CommitHand(player, Commit{
|
||||||
|
Live: liveBlob("blackjack"), Fresh: true, Stake: 300,
|
||||||
|
Done: true, Payout: 600, // a natural, which would settle the instant it's dealt
|
||||||
|
Audit: Hand{MatrixUser: player, Game: "blackjack", Bet: 300, Payout: 600},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrHandInProgress) {
|
||||||
|
t.Fatalf("err = %v, want ErrHandInProgress", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 300 is back, and the natural was *not* paid — a game that was never
|
||||||
|
// seated must not settle.
|
||||||
|
if c := chipsOf(t, player); c != 800 {
|
||||||
|
t.Fatalf("chips = %d after a refused deal, want 800 (the 300 refunded, the 600 never paid)", c)
|
||||||
|
}
|
||||||
|
// And the game already in progress is untouched. This is the bit that matters:
|
||||||
|
// a natural dealt on top of a live game used to be able to settle, clear the
|
||||||
|
// felt, and take the other game's stake down with it.
|
||||||
|
live, err := LoadLiveHand(player)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the live game is gone: %v", err)
|
||||||
|
}
|
||||||
|
if live.Game != "uno" {
|
||||||
|
t.Fatalf("live game = %q, want the uno game still sitting there", live.Game)
|
||||||
|
}
|
||||||
|
// Nothing was recorded, because nothing finished.
|
||||||
|
if take, err := HouseTake(0); err != nil || take != 0 {
|
||||||
|
t.Fatalf("house take = %d (err %v), want 0 — no hand finished", take, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deadlock canary.
|
||||||
|
//
|
||||||
|
// SQLite runs at MaxOpenConns(1), so the connection is a global mutex. A bare
|
||||||
|
// Get().Exec inside an open transaction waits for the one connection that the
|
||||||
|
// transaction is holding, and waits forever — it is not an error, it is a hung
|
||||||
|
// process, and because the news app shares the pool it hangs that too.
|
||||||
|
//
|
||||||
|
// So: if anybody ever reaches for Award or RecordHand (rather than award/
|
||||||
|
// recordHand) from inside CommitHand, this test stops returning. It does not
|
||||||
|
// fail with a nice message; it hangs, and the timeout is the message. That is
|
||||||
|
// exactly the failure mode in production, which is the point of catching it here.
|
||||||
|
func TestTheSettleDoesNotDeadlockAgainstItsOwnConnection(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 1000)
|
||||||
|
if err := Stake(player, 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- CommitHand(player, Commit{
|
||||||
|
Live: liveBlob("hangman"), Fresh: true, Stake: 100,
|
||||||
|
Done: true, Payout: 234,
|
||||||
|
Audit: Hand{MatrixUser: player, Game: "hangman", Bet: 100, Payout: 234, Rake: 12, Outcome: "won"},
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("CommitHand did not return: something inside the transaction is waiting " +
|
||||||
|
"for the connection the transaction is holding. Look for a Get().Exec that " +
|
||||||
|
"should be a tx.Exec.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c := chipsOf(t, player); c != 1134 {
|
||||||
|
t.Fatalf("chips = %d, want 1134", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Being paid moves the idle clock, so the reaper leaves a player who is
|
||||||
|
// mid-session alone. Touch used to be a separate statement after the settle; it
|
||||||
|
// is inside it now, and this is what would notice if it got dropped on the way.
|
||||||
|
func TestSettlingKeepsTheReaperAway(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 1000)
|
||||||
|
|
||||||
|
// Backdate the session well past the reaper's patience.
|
||||||
|
if _, err := Get().Exec(
|
||||||
|
`UPDATE game_chips SET last_played = ? WHERE matrix_user = ?`,
|
||||||
|
nowUnix()-int64((2 * SessionIdleAfter).Seconds()), player,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Stake(player, 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CommitHand(player, Commit{
|
||||||
|
Live: liveBlob("trivia"), Fresh: true, Stake: 100,
|
||||||
|
Done: true, Payout: 150,
|
||||||
|
Audit: Hand{MatrixUser: player, Game: "trivia", Bet: 100, Payout: 150, Outcome: "won"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stacks, _, err := IdleStacks(SessionIdleAfter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(stacks) != 0 {
|
||||||
|
t.Fatalf("the reaper found %d idle stacks; a player who just settled a hand is not idle", len(stacks))
|
||||||
|
}
|
||||||
|
}
|
||||||
907
internal/storage/tables.go
Normal file
907
internal/storage/tables.go
Normal file
@@ -0,0 +1,907 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shared tables: the casino with more than one person at it.
|
||||||
|
//
|
||||||
|
// The money model is the thing to understand first, because everything else
|
||||||
|
// follows from it, and it is *not* the one the plan sketched.
|
||||||
|
//
|
||||||
|
// **Chips cross into a table when you sit down, and back out when you get up.
|
||||||
|
// Nothing in between touches game_chips at all.** Your buy-in leaves your stack
|
||||||
|
// and becomes a stack inside the engine's own state; antes, bets, pots and
|
||||||
|
// payouts are all moves within that blob; and getting up is the single write
|
||||||
|
// that turns what is in front of you back into chips.
|
||||||
|
//
|
||||||
|
// This is how hold'em already worked as a solo session, and generalising it is
|
||||||
|
// what makes a pot safe. The obvious alternative — settle each hand by crediting
|
||||||
|
// every winner's game_chips row — puts a real money write on the end of every
|
||||||
|
// hand, and a crash between the credit and the state write pays the winner twice.
|
||||||
|
// Here a settle credits nobody: it is a state write, conditional on the version,
|
||||||
|
// and a replay of it affects zero rows and rolls back. The pot cannot be paid
|
||||||
|
// twice because it is never *paid* at all, only moved.
|
||||||
|
//
|
||||||
|
// So the money invariant across a table's whole life is:
|
||||||
|
//
|
||||||
|
// sum(seat stacks in the blob) + pot == sum(game_seats.staked) - rake taken
|
||||||
|
//
|
||||||
|
// and the only two statements that move chips across the border are the stake in
|
||||||
|
// SitDown and the award in LeaveTable — each of them one transaction, each of
|
||||||
|
// them carrying the state write that justifies it.
|
||||||
|
//
|
||||||
|
// The other half is concurrency. A table has two writers where a solo game had
|
||||||
|
// one: an HTTP move, and a turn clock acting for whoever walked away. The version
|
||||||
|
// column is the authority — every state write is conditional on the version its
|
||||||
|
// writer read — and the striped mutex in the web layer is only an optimisation on
|
||||||
|
// top. Correctness has to live in the database, because a mutex does not survive
|
||||||
|
// a redeploy: during a drain, two processes hold two different mutexes over the
|
||||||
|
// same row and both of them believe they are alone.
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrStaleTable means somebody else wrote the table first. The caller's read is
|
||||||
|
// out of date; it must reload and decide again. This is not an error condition
|
||||||
|
// so much as the normal outcome of a race, and it is what a 409 is made of.
|
||||||
|
ErrStaleTable = errors.New("games: the table moved on")
|
||||||
|
// ErrNoSuchTable means there is no table with that id.
|
||||||
|
ErrNoSuchTable = errors.New("games: no such table")
|
||||||
|
// ErrSeatTaken means somebody sat down there between the read and the write.
|
||||||
|
ErrSeatTaken = errors.New("games: that seat is taken")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Table is a felt other people can sit at.
|
||||||
|
//
|
||||||
|
// State is the engine's State, serialized whole — the same blob game_live_hands
|
||||||
|
// holds for a solo game, and for the same reason: the deck is in it, so it never
|
||||||
|
// leaves the server, and a hand survives a redeploy.
|
||||||
|
type Table struct {
|
||||||
|
ID string
|
||||||
|
Game string
|
||||||
|
Tier string
|
||||||
|
State []byte
|
||||||
|
Seed1 uint64
|
||||||
|
Seed2 uint64
|
||||||
|
Phase string
|
||||||
|
// HandNo, with the id, is the identity of one hand. It is what the audit trail
|
||||||
|
// keys on now that a seed no longer reproduces a hand: at a shared table the
|
||||||
|
// cards fall the way they do because of the order the others acted, not just
|
||||||
|
// the seed, so "deal it again from seed1/seed2" stopped being a true story the
|
||||||
|
// moment there was a second player.
|
||||||
|
HandNo int64
|
||||||
|
// Version is the concurrency authority. Read it, write against it, and a write
|
||||||
|
// that finds it moved is a write that lost the race.
|
||||||
|
Version int64
|
||||||
|
// Deadline is the unix second by which the seat to act has to act, or 0 for no
|
||||||
|
// clock at all. Only a *human* to act sets one: bots resolve inside ApplyMove
|
||||||
|
// and there is nobody to wait for.
|
||||||
|
Deadline int64
|
||||||
|
CreatedAt int64
|
||||||
|
UpdatedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seat is one chair. A seat with no MatrixUser is a bot, which is what makes solo
|
||||||
|
// play just "a table nobody else has joined yet" rather than a second mode.
|
||||||
|
type Seat struct {
|
||||||
|
Seat int
|
||||||
|
MatrixUser string // "" for a bot
|
||||||
|
Name string
|
||||||
|
// Staked is what this player brought and has not yet taken home. The chips are
|
||||||
|
// off their game_chips stack and inside the table blob, where the idle reaper
|
||||||
|
// cannot see them — so this is the row that says they exist.
|
||||||
|
Staked int64
|
||||||
|
Away bool
|
||||||
|
LastSeen int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bot reports whether nobody is sitting here.
|
||||||
|
func (s Seat) Bot() bool { return s.MatrixUser == "" }
|
||||||
|
|
||||||
|
// NewTableID mints a table id. Short enough to put in a URL, random enough that
|
||||||
|
// nobody guesses their way onto somebody else's felt.
|
||||||
|
func NewTableID() (string, error) {
|
||||||
|
b := make([]byte, 9)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", fmt.Errorf("games: mint table id: %w", err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenTable creates a table and seats it — bots in every chair nobody has taken.
|
||||||
|
func OpenTable(t Table, seats []Seat) error {
|
||||||
|
now := nowUnix()
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: begin open table: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO game_tables (id, game, tier, state, seed1, seed2, phase, hand_no, version, deadline, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`,
|
||||||
|
t.ID, t.Game, t.Tier, string(t.State), int64(t.Seed1), int64(t.Seed2),
|
||||||
|
t.Phase, t.HandNo, t.Deadline, now, now,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("games: open table: %w", err)
|
||||||
|
}
|
||||||
|
for _, s := range seats {
|
||||||
|
if err := upsertSeat(tx, t.ID, s, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("games: commit open table: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSoloTable opens a table with the player already sitting at it — the "solo
|
||||||
|
// is just a table nobody else has joined yet" path. It is SitDown and OpenTable
|
||||||
|
// fused into one transaction: stake the buy-in, claim the occupancy row, create
|
||||||
|
// the table, and seat everyone (the human, and the bots filling the rest of the
|
||||||
|
// ring). Any step failing rolls the buy-in back with it, so a crash never leaves
|
||||||
|
// a player charged for a felt that does not exist.
|
||||||
|
//
|
||||||
|
// The occupancy claim is the same primary key that stops a second solo hand, so a
|
||||||
|
// player already at a table (or in another game) is refused here with
|
||||||
|
// ErrHandInProgress and their buy-in returned untouched.
|
||||||
|
func OpenSoloTable(t Table, seats []Seat, buyIn int64) error {
|
||||||
|
if buyIn <= 0 {
|
||||||
|
return ErrBadAmount
|
||||||
|
}
|
||||||
|
// The human seat is the one row that is not a bot; its player claims the table.
|
||||||
|
var user, name string
|
||||||
|
for _, s := range seats {
|
||||||
|
if !s.Bot() {
|
||||||
|
user, name = s.MatrixUser, s.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if user == "" {
|
||||||
|
return ErrBadAmount // a solo table with no human is a bug, not a table
|
||||||
|
}
|
||||||
|
now := nowUnix()
|
||||||
|
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: begin open solo: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||||
|
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ?
|
||||||
|
WHERE matrix_user = ? AND chips >= ?`,
|
||||||
|
buyIn, now, now, user, buyIn,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: stake solo buy-in: %w", err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrInsufficientChips
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err = tx.Exec(
|
||||||
|
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, table_id, updated_at)
|
||||||
|
VALUES (?, ?, '', 0, 0, ?, ?)
|
||||||
|
ON CONFLICT(matrix_user) DO NOTHING`,
|
||||||
|
user, t.Game, t.ID, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: claim solo seat: %w", err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrHandInProgress
|
||||||
|
}
|
||||||
|
_ = name
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO game_tables (id, game, tier, state, seed1, seed2, phase, hand_no, version, deadline, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`,
|
||||||
|
t.ID, t.Game, t.Tier, string(t.State), int64(t.Seed1), int64(t.Seed2),
|
||||||
|
t.Phase, t.HandNo, t.Deadline, now, now,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("games: open solo table: %w", err)
|
||||||
|
}
|
||||||
|
for _, sc := range seats {
|
||||||
|
if err := upsertSeat(tx, t.ID, sc, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("games: commit open solo: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsertSeat writes a seat row inside an open transaction, bots included.
|
||||||
|
//
|
||||||
|
// last_seen is the caller's if they set one, and only falls back to now when they
|
||||||
|
// did not. That distinction is load-bearing: the turn clock rewrites a seat to
|
||||||
|
// mark it away, and it must carry the seat's *existing* last_seen through
|
||||||
|
// unchanged — otherwise every auto-fold refreshes the away player's clock, and
|
||||||
|
// the abandoned-table reaper (which keys on how long ago a human last acted for
|
||||||
|
// themselves) could never fire.
|
||||||
|
func upsertSeat(tx *sql.Tx, tableID string, s Seat, now int64) error {
|
||||||
|
var user any
|
||||||
|
if s.MatrixUser != "" {
|
||||||
|
user = s.MatrixUser
|
||||||
|
}
|
||||||
|
seen := s.LastSeen
|
||||||
|
if seen == 0 {
|
||||||
|
seen = now
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO game_seats (table_id, seat, matrix_user, name, staked, away, last_seen)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(table_id, seat) DO UPDATE SET
|
||||||
|
matrix_user = excluded.matrix_user, name = excluded.name,
|
||||||
|
staked = excluded.staked, away = excluded.away, last_seen = excluded.last_seen`,
|
||||||
|
tableID, s.Seat, user, s.Name, s.Staked, boolInt(s.Away), seen,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("games: seat: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolInt(b bool) int64 {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadTable reads a table and everyone at it.
|
||||||
|
func LoadTable(id string) (Table, []Seat, error) {
|
||||||
|
var t Table
|
||||||
|
var state string
|
||||||
|
var s1, s2 int64
|
||||||
|
err := Get().QueryRow(
|
||||||
|
`SELECT id, game, tier, state, seed1, seed2, phase, hand_no, version, deadline, created_at, updated_at
|
||||||
|
FROM game_tables WHERE id = ?`, id,
|
||||||
|
).Scan(&t.ID, &t.Game, &t.Tier, &state, &s1, &s2, &t.Phase, &t.HandNo, &t.Version, &t.Deadline, &t.CreatedAt, &t.UpdatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Table{}, nil, ErrNoSuchTable
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Table{}, nil, fmt.Errorf("games: load table: %w", err)
|
||||||
|
}
|
||||||
|
t.State, t.Seed1, t.Seed2 = []byte(state), uint64(s1), uint64(s2)
|
||||||
|
|
||||||
|
seats, err := tableSeats(id)
|
||||||
|
if err != nil {
|
||||||
|
return Table{}, nil, err
|
||||||
|
}
|
||||||
|
return t, seats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableSeats reads the chairs, in seat order.
|
||||||
|
func tableSeats(id string) ([]Seat, error) {
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`SELECT seat, matrix_user, name, staked, away, last_seen
|
||||||
|
FROM game_seats WHERE table_id = ? ORDER BY seat`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("games: table seats: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Seat
|
||||||
|
for rows.Next() {
|
||||||
|
var s Seat
|
||||||
|
var user sql.NullString
|
||||||
|
var away int64
|
||||||
|
if err := rows.Scan(&s.Seat, &user, &s.Name, &s.Staked, &away, &s.LastSeen); err != nil {
|
||||||
|
return nil, fmt.Errorf("games: scan seat: %w", err)
|
||||||
|
}
|
||||||
|
s.MatrixUser, s.Away = user.String, away != 0
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableSummary is a table as the lobby lists it: enough to decide whether to sit
|
||||||
|
// down, and nothing that would give away a card.
|
||||||
|
type TableSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Game string `json:"game"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Humans int `json:"humans"`
|
||||||
|
Seats int `json:"seats"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LobbyTables lists the live tables, most recently played first. A game of "" is
|
||||||
|
// all of them.
|
||||||
|
func LobbyTables(game string, limit int) ([]TableSummary, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
q := `SELECT t.id, t.game, t.tier, t.phase, t.updated_at,
|
||||||
|
COUNT(s.seat),
|
||||||
|
COUNT(s.matrix_user)
|
||||||
|
FROM game_tables t
|
||||||
|
LEFT JOIN game_seats s ON s.table_id = t.id`
|
||||||
|
args := []any{}
|
||||||
|
if game != "" {
|
||||||
|
q += ` WHERE t.game = ?`
|
||||||
|
args = append(args, game)
|
||||||
|
}
|
||||||
|
q += ` GROUP BY t.id ORDER BY t.updated_at DESC LIMIT ?`
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
rows, err := Get().Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("games: lobby: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []TableSummary
|
||||||
|
for rows.Next() {
|
||||||
|
var s TableSummary
|
||||||
|
if err := rows.Scan(&s.ID, &s.Game, &s.Tier, &s.Phase, &s.UpdatedAt, &s.Seats, &s.Humans); err != nil {
|
||||||
|
return nil, fmt.Errorf("games: scan lobby row: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- writing a table back --------------------------------------------------
|
||||||
|
|
||||||
|
// TableCommit is one write-back of a shared table.
|
||||||
|
//
|
||||||
|
// There is no payout field, and its absence is the design. A hand ending at a
|
||||||
|
// shared table moves chips *within* the blob — the pot becomes somebody's stack —
|
||||||
|
// so settling one credits nobody and mints nothing. What it writes is the state,
|
||||||
|
// the audit rows, and whatever the seats now look like. The version makes it
|
||||||
|
// exactly-once: a settle that runs twice loses the race with itself.
|
||||||
|
type TableCommit struct {
|
||||||
|
// Table carries the new state and the version that was *read*. The write is
|
||||||
|
// conditional on that version and bumps it.
|
||||||
|
Table Table
|
||||||
|
// Seats to rewrite — a stack that changed hands, a seat that came back from
|
||||||
|
// away. Seats not named here are left alone.
|
||||||
|
Seats []Seat
|
||||||
|
// Audit is the per-seat record of a hand that just ended. Empty mid-hand.
|
||||||
|
Audit []Hand
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitTable writes a table back, and the hand it just finished with it, in one
|
||||||
|
// transaction.
|
||||||
|
//
|
||||||
|
// The version check is the whole safety property, and it is why this can be
|
||||||
|
// called by the turn clock and an HTTP move at the same instant without either
|
||||||
|
// having to trust the other. Whoever gets there first bumps the version; the
|
||||||
|
// loser's UPDATE matches zero rows, the transaction rolls back, and it comes back
|
||||||
|
// ErrStaleTable with nothing written — no half-settled hand, no audit row for a
|
||||||
|
// hand that did not happen.
|
||||||
|
//
|
||||||
|
// Nothing in here may call Get().Exec. The pool runs at MaxOpenConns(1), so a
|
||||||
|
// bare Exec inside an open transaction waits forever for the connection that this
|
||||||
|
// transaction is holding — and takes the news app down with it. See CommitHand.
|
||||||
|
func CommitTable(c TableCommit) error {
|
||||||
|
now := nowUnix()
|
||||||
|
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: begin commit table: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||||
|
|
||||||
|
if err := saveTable(tx, c.Table, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, s := range c.Seats {
|
||||||
|
if err := upsertSeat(tx, c.Table.ID, s, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, h := range c.Audit {
|
||||||
|
if err := recordHand(tx, h, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Playing a hand is the most deliberate thing a player does. Keep the reaper
|
||||||
|
// off them — their chips are inside the table blob, where it cannot see them
|
||||||
|
// anyway, but their game_chips row is what it reads.
|
||||||
|
if h.MatrixUser == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
|
||||||
|
now, now, h.MatrixUser,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("games: touch session: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("games: commit table: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveTable is the conditional state write: it lands only if the version is still
|
||||||
|
// the one the caller read.
|
||||||
|
func saveTable(tx *sql.Tx, t Table, now int64) error {
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`UPDATE game_tables SET state = ?, phase = ?, hand_no = ?, seed1 = ?, seed2 = ?,
|
||||||
|
deadline = ?, version = version + 1, updated_at = ?
|
||||||
|
WHERE id = ? AND version = ?`,
|
||||||
|
string(t.State), t.Phase, t.HandNo, int64(t.Seed1), int64(t.Seed2),
|
||||||
|
t.Deadline, now, t.ID, t.Version,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: save table: %w", err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrStaleTable
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sitting down and getting up -------------------------------------------
|
||||||
|
|
||||||
|
// Sit is one player taking one chair, with the table state that has them in it.
|
||||||
|
type Sit struct {
|
||||||
|
Table Table // the new state, and the version that was read
|
||||||
|
Seat Seat // MatrixUser, Name and the chair; Staked is the buy-in
|
||||||
|
BuyIn int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// SitDown moves a player's chips onto a table and puts them in a seat — the first
|
||||||
|
// of the only two statements in the casino that cross the chip/table border.
|
||||||
|
//
|
||||||
|
// It is one transaction and every step of it can refuse:
|
||||||
|
//
|
||||||
|
// - the chips leave in the same statement that checks they are there, so two
|
||||||
|
// joins fired at once cannot spend the same chip;
|
||||||
|
// - the occupancy claim is game_live_hands' primary key, exactly as it is for a
|
||||||
|
// solo hand, so a player cannot be at two tables (or at a table and in a solo
|
||||||
|
// game) at once, and a double-clicked Join is a 409;
|
||||||
|
// - the seat is taken only if a bot is sitting in it, so two players racing for
|
||||||
|
// the last chair cannot both win;
|
||||||
|
// - and the state write is conditional on the version, so the engine's idea of
|
||||||
|
// who is at the table cannot drift from the seat rows.
|
||||||
|
//
|
||||||
|
// Any of those failing rolls back the buy-in with it.
|
||||||
|
func SitDown(s Sit) error {
|
||||||
|
if s.BuyIn <= 0 {
|
||||||
|
return ErrBadAmount
|
||||||
|
}
|
||||||
|
now := nowUnix()
|
||||||
|
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: begin sit: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||||
|
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ?
|
||||||
|
WHERE matrix_user = ? AND chips >= ?`,
|
||||||
|
s.BuyIn, now, now, s.Seat.MatrixUser, s.BuyIn,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: stake buy-in: %w", err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrInsufficientChips
|
||||||
|
}
|
||||||
|
|
||||||
|
// The occupancy claim. The state column is empty on purpose: the cards live in
|
||||||
|
// game_tables, and this row exists to be a primary key.
|
||||||
|
res, err = tx.Exec(
|
||||||
|
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, table_id, updated_at)
|
||||||
|
VALUES (?, ?, '', 0, 0, ?, ?)
|
||||||
|
ON CONFLICT(matrix_user) DO NOTHING`,
|
||||||
|
s.Seat.MatrixUser, s.Table.Game, s.Table.ID, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: claim seat: %w", err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrHandInProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take the chair, but only out of a bot's hands.
|
||||||
|
res, err = tx.Exec(
|
||||||
|
`UPDATE game_seats SET matrix_user = ?, name = ?, staked = ?, away = 0, last_seen = ?
|
||||||
|
WHERE table_id = ? AND seat = ? AND matrix_user IS NULL`,
|
||||||
|
s.Seat.MatrixUser, s.Seat.Name, s.BuyIn, now, s.Table.ID, s.Seat.Seat,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: take seat: %w", err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrSeatTaken
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveTable(tx, s.Table, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("games: commit sit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave is one player getting up, with the table state that no longer has them.
|
||||||
|
type Leave struct {
|
||||||
|
Table Table // the new state, and the version that was read
|
||||||
|
Seat int
|
||||||
|
MatrixUser string
|
||||||
|
// Bot is who takes the chair over. A table always has a full complement, so
|
||||||
|
// getting up hands the seat back to the house rather than leaving a hole.
|
||||||
|
Bot string
|
||||||
|
// Amount is what is in front of them: everything they are taking home. It may
|
||||||
|
// be more than they brought, or nothing at all.
|
||||||
|
Amount int64
|
||||||
|
// Audit, if the leaving itself settles something worth recording.
|
||||||
|
Audit []Hand
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeaveTable turns what is in front of a player back into chips — the second and
|
||||||
|
// last statement that crosses the chip/table border.
|
||||||
|
//
|
||||||
|
// One transaction, and the state write is in it. As two statements this is a
|
||||||
|
// double-pay waiting to happen: award 1,240 chips, fail the state write, and the
|
||||||
|
// player reloads to find their seat still there with 1,240 in front of it. They
|
||||||
|
// get up again, and again.
|
||||||
|
func LeaveTable(l Leave) error {
|
||||||
|
now := nowUnix()
|
||||||
|
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: begin leave: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||||
|
|
||||||
|
if err := saveTable(tx, l.Table, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := upsertSeat(tx, l.Table.ID, Seat{Seat: l.Seat, Name: l.Bot}, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`DELETE FROM game_live_hands WHERE matrix_user = ? AND table_id = ?`,
|
||||||
|
l.MatrixUser, l.Table.ID,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("games: release seat claim: %w", err)
|
||||||
|
}
|
||||||
|
if err := award(tx, l.MatrixUser, l.Amount, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, h := range l.Audit {
|
||||||
|
if err := recordHand(tx, h, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("games: commit leave: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerSeat reports the table and chair a player is sitting at. It reads the
|
||||||
|
// seat row, which sit and leave keep in lockstep with the occupancy claim in one
|
||||||
|
// transaction, so a row here means a live-hand row there and vice versa.
|
||||||
|
func PlayerSeat(user string) (tableID string, seat int, err error) {
|
||||||
|
err = Get().QueryRow(
|
||||||
|
`SELECT table_id, seat FROM game_seats WHERE matrix_user = ?`, user,
|
||||||
|
).Scan(&tableID, &seat)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", 0, ErrNoLiveHand
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("games: player seat: %w", err)
|
||||||
|
}
|
||||||
|
return tableID, seat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableOf reports which table a player is sitting at, if any. Read off the
|
||||||
|
// occupancy claim, so it agrees with the cash-out check by construction.
|
||||||
|
func TableOf(user string) (string, error) {
|
||||||
|
var id sql.NullString
|
||||||
|
err := Get().QueryRow(
|
||||||
|
`SELECT table_id FROM game_live_hands WHERE matrix_user = ?`, user,
|
||||||
|
).Scan(&id)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrNoLiveHand
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("games: table of: %w", err)
|
||||||
|
}
|
||||||
|
return id.String, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseTable deletes a table nobody is sitting at. Called when the last human
|
||||||
|
// gets up: a felt with six bots on it and nobody watching is not a game, it is a
|
||||||
|
// row that the lobby would advertise forever.
|
||||||
|
func CloseTable(id string) error {
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: begin close table: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||||
|
|
||||||
|
var humans int
|
||||||
|
if err := tx.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM game_seats WHERE table_id = ? AND matrix_user IS NOT NULL`, id,
|
||||||
|
).Scan(&humans); err != nil {
|
||||||
|
return fmt.Errorf("games: count humans: %w", err)
|
||||||
|
}
|
||||||
|
if humans > 0 {
|
||||||
|
return nil // somebody is still playing; the table stays
|
||||||
|
}
|
||||||
|
for _, q := range []string{
|
||||||
|
`DELETE FROM game_seats WHERE table_id = ?`,
|
||||||
|
`DELETE FROM game_chat WHERE table_id = ?`,
|
||||||
|
`DELETE FROM game_tables WHERE id = ?`,
|
||||||
|
} {
|
||||||
|
if _, err := tx.Exec(q, id); err != nil {
|
||||||
|
return fmt.Errorf("games: close table: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("games: commit close table: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AbandonedTables lists tables everyone walked away from: every human seat is
|
||||||
|
// away, and the most recent one acted for themselves longer ago than the cutoff.
|
||||||
|
//
|
||||||
|
// It is the seated-player half of the reaper. The session reaper cashes out loose
|
||||||
|
// chips on a game_chips stack; it cannot see a player whose chips are inside a
|
||||||
|
// table blob, and those are exactly the chips a walked-away poker player has. So
|
||||||
|
// this finds the tables where nobody is coming back and hands them to ReapTable.
|
||||||
|
//
|
||||||
|
// A table with a live hand is never abandoned in this sense — the turn clock is
|
||||||
|
// still folding it forward — so only tables parked between hands qualify. Like
|
||||||
|
// DueTables it closes its rows before returning, because the caller is about to
|
||||||
|
// take a lock and open a transaction against the one connection.
|
||||||
|
func AbandonedTables(cutoff int64) ([]TableRef, error) {
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`SELECT t.id, t.version FROM game_tables t
|
||||||
|
WHERE t.phase = 'handover'
|
||||||
|
AND EXISTS (SELECT 1 FROM game_seats s
|
||||||
|
WHERE s.table_id = t.id AND s.matrix_user IS NOT NULL)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM game_seats s
|
||||||
|
WHERE s.table_id = t.id AND s.matrix_user IS NOT NULL
|
||||||
|
AND (s.away = 0 OR s.last_seen >= ?))`,
|
||||||
|
cutoff,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("games: abandoned tables: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []TableRef
|
||||||
|
for rows.Next() {
|
||||||
|
var r TableRef
|
||||||
|
if err := rows.Scan(&r.ID, &r.Version); err != nil {
|
||||||
|
return nil, fmt.Errorf("games: scan abandoned table: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reap is one abandoned table being cashed out and closed. Stacks is what each
|
||||||
|
// human seat has in front of it, read from the engine blob by the caller — the
|
||||||
|
// only game-specific fact the reaper needs, since the chips-home number lives
|
||||||
|
// inside a state only the engine can decode.
|
||||||
|
type Reap struct {
|
||||||
|
TableID string
|
||||||
|
Version int64
|
||||||
|
// Humans is the seats to cash out, each paired with the stack going home. A
|
||||||
|
// seat's Amount may be zero (they busted and never got up), which still has to
|
||||||
|
// close their occupancy row so they can play again.
|
||||||
|
Humans []ReapSeat
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReapSeat is one human being sent home from an abandoned table.
|
||||||
|
type ReapSeat struct {
|
||||||
|
Seat int
|
||||||
|
MatrixUser string
|
||||||
|
Amount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReapTable cashes out every human at an abandoned table and deletes it, in one
|
||||||
|
// transaction, conditional on the version so it cannot race a player who came
|
||||||
|
// back to the felt in the same instant.
|
||||||
|
//
|
||||||
|
// It is LeaveTable and CloseTable fused: award each stack, release each occupancy
|
||||||
|
// claim, then drop the seats, chat and table. The version guard is what makes it
|
||||||
|
// safe against a returning player — if their sit or move bumped the version
|
||||||
|
// between the scan and here, every row matches zero and the whole thing rolls
|
||||||
|
// back, leaving the table exactly as the returning player left it.
|
||||||
|
func ReapTable(r Reap) error {
|
||||||
|
now := nowUnix()
|
||||||
|
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: begin reap: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||||
|
|
||||||
|
// Bump the version first, and refuse if it moved. Nothing below is conditional,
|
||||||
|
// so this one check has to stand for the whole reap.
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`UPDATE game_tables SET version = version + 1, updated_at = ? WHERE id = ? AND version = ?`,
|
||||||
|
now, r.TableID, r.Version,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: reap bump version: %w", err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrStaleTable
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, h := range r.Humans {
|
||||||
|
if h.Amount > 0 {
|
||||||
|
if err := award(tx, h.MatrixUser, h.Amount, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`DELETE FROM game_live_hands WHERE matrix_user = ? AND table_id = ?`,
|
||||||
|
h.MatrixUser, r.TableID,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("games: reap release claim: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, q := range []string{
|
||||||
|
`DELETE FROM game_seats WHERE table_id = ?`,
|
||||||
|
`DELETE FROM game_chat WHERE table_id = ?`,
|
||||||
|
`DELETE FROM game_tables WHERE id = ?`,
|
||||||
|
} {
|
||||||
|
if _, err := tx.Exec(q, r.TableID); err != nil {
|
||||||
|
return fmt.Errorf("games: reap close: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("games: commit reap: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the turn clock --------------------------------------------------------
|
||||||
|
|
||||||
|
// TableRef is a table the clock has found expired: which one, and at what version
|
||||||
|
// it was seen.
|
||||||
|
//
|
||||||
|
// The version is the point. The clock acts only if it is *still* that version by
|
||||||
|
// the time it takes the lock, because otherwise: Bob's raise lands in the same
|
||||||
|
// second his clock expires, the action passes to Cara, and the clock — still
|
||||||
|
// holding its scan-time belief that the seat to act has run out of time — folds
|
||||||
|
// Cara, who had twenty-five seconds left. That is a one-second window that recurs
|
||||||
|
// on every single turn of every hand.
|
||||||
|
type TableRef struct {
|
||||||
|
ID string
|
||||||
|
Version int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DueTables lists the tables whose clock has run out.
|
||||||
|
//
|
||||||
|
// It closes the rows before returning, and it must: the caller is about to take a
|
||||||
|
// table lock and open a transaction, and holding a *sql.Rows across that means
|
||||||
|
// holding the only connection in the pool while waiting for it. That is not a
|
||||||
|
// slow query, it is a deadlock.
|
||||||
|
func DueTables(now int64) ([]TableRef, error) {
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`SELECT id, version FROM game_tables WHERE deadline > 0 AND deadline <= ?`, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("games: due tables: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []TableRef
|
||||||
|
for rows.Next() {
|
||||||
|
var r TableRef
|
||||||
|
if err := rows.Scan(&r.ID, &r.Version); err != nil {
|
||||||
|
return nil, fmt.Errorf("games: scan due table: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushDeadlines shoves every live clock out by a grace period. Called once on
|
||||||
|
// boot: a deploy takes a table's clock with it, and without this the first tick
|
||||||
|
// after a restart wakes up to find every deadline in the casino already expired
|
||||||
|
// and auto-folds all of them at once.
|
||||||
|
func PushDeadlines(grace int64) error {
|
||||||
|
if _, err := Get().Exec(
|
||||||
|
`UPDATE game_tables SET deadline = ? WHERE deadline > 0 AND deadline < ?`,
|
||||||
|
nowUnix()+grace, nowUnix()+grace,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("games: push deadlines: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- chat ------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ChatLine is one thing somebody said at the felt.
|
||||||
|
type ChatLine struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
HandNo int64 `json:"hand_no"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
SaidAt int64 `json:"said_at"`
|
||||||
|
// Mine is filled in by the web layer, per reader. It is not in the database.
|
||||||
|
Mine bool `json:"mine,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxChatLen is where a message stops. Long enough for a table read, short enough
|
||||||
|
// that nobody pastes a novel onto the felt.
|
||||||
|
const MaxChatLen = 240
|
||||||
|
|
||||||
|
// Say records a line of chat and returns it. Its hand_no is stamped from the
|
||||||
|
// table, which is what makes the log answer the only question chat at a money
|
||||||
|
// table ever really raises: what was said, during which hand.
|
||||||
|
func Say(tableID, user, name, body string) (ChatLine, error) {
|
||||||
|
body = strings.TrimSpace(body)
|
||||||
|
if body == "" {
|
||||||
|
return ChatLine{}, ErrBadAmount
|
||||||
|
}
|
||||||
|
if len(body) > MaxChatLen {
|
||||||
|
body = body[:MaxChatLen]
|
||||||
|
}
|
||||||
|
var handNo int64
|
||||||
|
if err := Get().QueryRow(`SELECT hand_no FROM game_tables WHERE id = ?`, tableID).Scan(&handNo); errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ChatLine{}, ErrNoSuchTable
|
||||||
|
} else if err != nil {
|
||||||
|
return ChatLine{}, fmt.Errorf("games: chat hand no: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := nowUnix()
|
||||||
|
res, err := Get().Exec(
|
||||||
|
`INSERT INTO game_chat (table_id, hand_no, matrix_user, name, body, said_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
tableID, handNo, user, name, body, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return ChatLine{}, fmt.Errorf("games: say: %w", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return ChatLine{ID: id, HandNo: handNo, Name: name, Body: body, SaidAt: now}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat reads the last few lines said at a table, oldest first.
|
||||||
|
func Chat(tableID string, limit int) ([]ChatLine, error) {
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`SELECT id, hand_no, name, body, said_at FROM game_chat
|
||||||
|
WHERE table_id = ? ORDER BY id DESC LIMIT ?`, tableID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("games: chat: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []ChatLine
|
||||||
|
for rows.Next() {
|
||||||
|
var c ChatLine
|
||||||
|
if err := rows.Scan(&c.ID, &c.HandNo, &c.Name, &c.Body, &c.SaidAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("games: scan chat: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Read newest-first so the LIMIT takes the right end; hand them back in the
|
||||||
|
// order they were said.
|
||||||
|
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
out[i], out[j] = out[j], out[i]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
271
internal/storage/tables_test.go
Normal file
271
internal/storage/tables_test.go
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openTestTable stands up a table with a full ring of bots and returns it. Six
|
||||||
|
// seats, because that is hold'em's ring and the most seats any game here has.
|
||||||
|
func openTestTable(t *testing.T, id, game string) Table {
|
||||||
|
t.Helper()
|
||||||
|
tbl := Table{
|
||||||
|
ID: id, Game: game, Tier: "1-2", State: []byte(`{}`),
|
||||||
|
Seed1: 1, Seed2: 2, Phase: "betting", HandNo: 1,
|
||||||
|
}
|
||||||
|
seats := make([]Seat, 6)
|
||||||
|
for i := range seats {
|
||||||
|
seats[i] = Seat{Seat: i, Name: "bot"}
|
||||||
|
}
|
||||||
|
if err := OpenTable(tbl, seats); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tbl
|
||||||
|
}
|
||||||
|
|
||||||
|
// reload reads a table back and fails the test if it is gone.
|
||||||
|
func reload(t *testing.T, id string) (Table, []Seat) {
|
||||||
|
t.Helper()
|
||||||
|
tbl, seats, err := LoadTable(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tbl, seats
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenTable_SeatsAreAllBots(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
openTestTable(t, "t1", "holdem")
|
||||||
|
|
||||||
|
_, seats := reload(t, "t1")
|
||||||
|
if len(seats) != 6 {
|
||||||
|
t.Fatalf("want 6 seats, got %d", len(seats))
|
||||||
|
}
|
||||||
|
for _, s := range seats {
|
||||||
|
if !s.Bot() {
|
||||||
|
t.Errorf("seat %d should be a bot", s.Seat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSitDown_MovesChipsOntoTheTable(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
tbl := openTestTable(t, "t1", "holdem")
|
||||||
|
fund(t, player, 5000)
|
||||||
|
|
||||||
|
if err := SitDown(Sit{
|
||||||
|
Table: tbl,
|
||||||
|
Seat: Seat{Seat: 2, MatrixUser: player, Name: "reala"},
|
||||||
|
BuyIn: 1000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The chips are off the stack...
|
||||||
|
if got := chipsOf(t, player); got != 4000 {
|
||||||
|
t.Errorf("stack: want 4000, got %d", got)
|
||||||
|
}
|
||||||
|
// ...and onto the seat.
|
||||||
|
_, seats := reload(t, "t1")
|
||||||
|
seat := seats[2]
|
||||||
|
if seat.MatrixUser != player || seat.Staked != 1000 {
|
||||||
|
t.Errorf("seat 2: want reala staked 1000, got %q staked %d", seat.MatrixUser, seat.Staked)
|
||||||
|
}
|
||||||
|
// The occupancy claim points at the table.
|
||||||
|
id, err := TableOf(player)
|
||||||
|
if err != nil || id != "t1" {
|
||||||
|
t.Errorf("TableOf: want t1, got %q err %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSitDown_CannotTakeATakenSeat(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
tbl := openTestTable(t, "t1", "holdem")
|
||||||
|
fund(t, player, 5000)
|
||||||
|
fund(t, "@bob:parodia.dev", 5000)
|
||||||
|
|
||||||
|
if err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 2, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tbl2, _ := reload(t, "t1")
|
||||||
|
err := SitDown(Sit{Table: tbl2, Seat: Seat{Seat: 2, MatrixUser: "@bob:parodia.dev", Name: "bob"}, BuyIn: 1000})
|
||||||
|
if !errors.Is(err, ErrSeatTaken) {
|
||||||
|
t.Fatalf("want ErrSeatTaken, got %v", err)
|
||||||
|
}
|
||||||
|
// Bob's chips did not move.
|
||||||
|
if got := chipsOf(t, "@bob:parodia.dev"); got != 5000 {
|
||||||
|
t.Errorf("bob's stack should be untouched, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSitDown_CannotSitAtTwoTables(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
tbl1 := openTestTable(t, "t1", "holdem")
|
||||||
|
tbl2 := openTestTable(t, "t2", "holdem")
|
||||||
|
fund(t, player, 5000)
|
||||||
|
|
||||||
|
if err := SitDown(Sit{Table: tbl1, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := SitDown(Sit{Table: tbl2, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000})
|
||||||
|
if !errors.Is(err, ErrHandInProgress) {
|
||||||
|
t.Fatalf("want ErrHandInProgress, got %v", err)
|
||||||
|
}
|
||||||
|
// The buy-in for the second table rolled back.
|
||||||
|
if got := chipsOf(t, player); got != 4000 {
|
||||||
|
t.Errorf("only the first buy-in should have left the stack, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSitDown_InsufficientChipsRollsBack(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
tbl := openTestTable(t, "t1", "holdem")
|
||||||
|
fund(t, player, 500)
|
||||||
|
|
||||||
|
err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000})
|
||||||
|
if !errors.Is(err, ErrInsufficientChips) {
|
||||||
|
t.Fatalf("want ErrInsufficientChips, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := TableOf(player); !errors.Is(err, ErrNoLiveHand) {
|
||||||
|
t.Errorf("no seat should have been claimed, got %v", err)
|
||||||
|
}
|
||||||
|
_, seats := reload(t, "t1")
|
||||||
|
if seats[0].MatrixUser != "" {
|
||||||
|
t.Errorf("seat should still be a bot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeaveTable_BringsChipsHome(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
tbl := openTestTable(t, "t1", "holdem")
|
||||||
|
fund(t, player, 5000)
|
||||||
|
if err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tbl2, _ := reload(t, "t1")
|
||||||
|
// Got up with 1,240 — up on the session.
|
||||||
|
if err := LeaveTable(Leave{Table: tbl2, Seat: 0, MatrixUser: player, Bot: "bot", Amount: 1240}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := chipsOf(t, player); got != 5240 {
|
||||||
|
t.Errorf("want 5240 back on the stack, got %d", got)
|
||||||
|
}
|
||||||
|
if _, err := TableOf(player); !errors.Is(err, ErrNoLiveHand) {
|
||||||
|
t.Errorf("seat claim should be gone, got %v", err)
|
||||||
|
}
|
||||||
|
_, seats := reload(t, "t1")
|
||||||
|
if seats[0].MatrixUser != "" {
|
||||||
|
t.Errorf("a bot should have taken the empty chair")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveTable_VersionGuardsTheWrite(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
openTestTable(t, "t1", "holdem")
|
||||||
|
|
||||||
|
a, _ := reload(t, "t1") // both read version 0
|
||||||
|
b, _ := reload(t, "t1")
|
||||||
|
|
||||||
|
a.State = []byte(`{"a":1}`)
|
||||||
|
if err := CommitTable(TableCommit{Table: a}); err != nil {
|
||||||
|
t.Fatalf("first write should win: %v", err)
|
||||||
|
}
|
||||||
|
b.State = []byte(`{"b":2}`)
|
||||||
|
if err := CommitTable(TableCommit{Table: b}); !errors.Is(err, ErrStaleTable) {
|
||||||
|
t.Fatalf("second write should be stale, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, _ := reload(t, "t1")
|
||||||
|
if string(after.State) != `{"a":1}` {
|
||||||
|
t.Errorf("the winning write should stand, got %s", after.State)
|
||||||
|
}
|
||||||
|
if after.Version != 1 {
|
||||||
|
t.Errorf("version should have bumped once, got %d", after.Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDueTables_OnlyExpiredClocks(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
now := nowUnix()
|
||||||
|
|
||||||
|
past := openTestTable(t, "past", "holdem")
|
||||||
|
past.Deadline = now - 5
|
||||||
|
if err := CommitTable(TableCommit{Table: past}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
future := openTestTable(t, "future", "holdem")
|
||||||
|
future.Deadline = now + 60
|
||||||
|
if err := CommitTable(TableCommit{Table: future}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
openTestTable(t, "noclock", "holdem") // deadline 0
|
||||||
|
|
||||||
|
due, err := DueTables(now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(due) != 1 || due[0].ID != "past" {
|
||||||
|
t.Fatalf("only the past-due table should show, got %+v", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChat_KeepsTheHandItWasSaidDuring(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
openTestTable(t, "t1", "holdem") // hand_no 1
|
||||||
|
|
||||||
|
if _, err := Say("t1", player, "reala", "nice hand"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The table moves to the next hand.
|
||||||
|
tbl2, _ := reload(t, "t1")
|
||||||
|
tbl2.HandNo = 2
|
||||||
|
if err := CommitTable(TableCommit{Table: tbl2}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Say("t1", player, "reala", "and another"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines, err := Chat("t1", 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(lines) != 2 {
|
||||||
|
t.Fatalf("want 2 lines, got %d", len(lines))
|
||||||
|
}
|
||||||
|
if lines[0].Body != "nice hand" || lines[0].HandNo != 1 {
|
||||||
|
t.Errorf("first line should be hand 1: %+v", lines[0])
|
||||||
|
}
|
||||||
|
if lines[1].HandNo != 2 {
|
||||||
|
t.Errorf("second line should be hand 2: %+v", lines[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseTable_KeepsATableWithAHumanAtIt(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
tbl := openTestTable(t, "t1", "holdem")
|
||||||
|
fund(t, player, 5000)
|
||||||
|
if err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := CloseTable("t1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := LoadTable("t1"); err != nil {
|
||||||
|
t.Errorf("a table with a human should survive close, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everyone leaves; now it goes.
|
||||||
|
tbl2, _ := reload(t, "t1")
|
||||||
|
if err := LeaveTable(Leave{Table: tbl2, Seat: 0, MatrixUser: player, Bot: "bot", Amount: 1000}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CloseTable("t1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := LoadTable("t1"); !errors.Is(err, ErrNoSuchTable) {
|
||||||
|
t.Errorf("an all-bot table should close, got %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.
|
||||||
@@ -35,13 +35,18 @@ func TestDevCasino(t *testing.T) {
|
|||||||
|
|
||||||
s := newCasino(t)
|
s := newCasino(t)
|
||||||
fund(t, 5000)
|
fund(t, 5000)
|
||||||
|
fundUser(t, bobPlayer, 5000)
|
||||||
seedTriviaBank(t)
|
seedTriviaBank(t)
|
||||||
|
|
||||||
payload, _ := json.Marshal(SessionUser{
|
// The full table runtime, so the turn clock and the reaper are live under the
|
||||||
Sub: "sub-1", Username: "reala", Name: "Reala",
|
// browser exactly as in production.
|
||||||
Exp: time.Now().Add(24 * time.Hour).Unix(),
|
s.StartTableClock(context.Background())
|
||||||
})
|
|
||||||
cookie := s.auth.sign(payload)
|
cookie := devCookie(s, "reala", "Reala")
|
||||||
|
// A second player, so a shared table can be reviewed — hold'em is multiplayer
|
||||||
|
// now, and one browser cannot see two people at the felt. Plant this cookie in
|
||||||
|
// a second browser profile (or a private window) to sit down as Bob.
|
||||||
|
bobCookie := devCookie(s, "bob", "Bob")
|
||||||
|
|
||||||
staticSub, err := fs.Sub(staticFS, "static")
|
staticSub, err := fs.Sub(staticFS, "static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -57,19 +62,31 @@ func TestDevCasino(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Written to a file, not printed: `go test` buffers stdout, and the browser
|
// Written to a file, not printed: `go test` buffers stdout, and the browser
|
||||||
// driver needs the cookie while the server is still running.
|
// driver needs the cookie while the server is still running. The second cookie
|
||||||
|
// rides alongside it, newline-separated, for the second browser.
|
||||||
if out := os.Getenv("PETE_DEV_COOKIE_FILE"); out != "" {
|
if out := os.Getenv("PETE_DEV_COOKIE_FILE"); out != "" {
|
||||||
if err := os.WriteFile(out, []byte(cookie), 0o600); err != nil {
|
if err := os.WriteFile(out, []byte(cookie+"\n"+bobCookie), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("\nCASINO http://localhost%s/games\nCOOKIE %s=%s\n\n", addr, sessionCookie, cookie)
|
fmt.Printf("\nCASINO http://localhost%s/games\nCOOKIE %s=%s\nBOB %s=%s\n\n",
|
||||||
|
addr, sessionCookie, cookie, sessionCookie, bobCookie)
|
||||||
|
|
||||||
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||||
t.Cleanup(func() { _ = srv.Close() })
|
t.Cleanup(func() { _ = srv.Close() })
|
||||||
_ = srv.Serve(ln)
|
_ = srv.Serve(ln)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// devCookie mints a signed session for a player the rig has funded, so the felt
|
||||||
|
// can be driven as them.
|
||||||
|
func devCookie(s *Server, username, name string) string {
|
||||||
|
payload, _ := json.Marshal(SessionUser{
|
||||||
|
Sub: "sub-" + username, Username: username, Name: name,
|
||||||
|
Exp: time.Now().Add(24 * time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
return s.auth.sign(payload)
|
||||||
|
}
|
||||||
|
|
||||||
// seedTriviaBank puts enough questions in the bank to deal a ladder of each
|
// seedTriviaBank puts enough questions in the bank to deal a ladder of each
|
||||||
// difficulty.
|
// difficulty.
|
||||||
//
|
//
|
||||||
|
|||||||
214
internal/web/games_clock.go
Normal file
214
internal/web/games_clock.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The turn clock: the first goroutine in Pete that has ever mutated game state.
|
||||||
|
//
|
||||||
|
// Every other background loop here reads, refills or prunes. This one plays. When
|
||||||
|
// a human sits at a felt and walks away mid-hand, three other people are waiting
|
||||||
|
// on a decision that is never coming, and something has to make it for them. That
|
||||||
|
// something is this.
|
||||||
|
//
|
||||||
|
// It is built on one discipline and one guard.
|
||||||
|
//
|
||||||
|
// The discipline is rule 1: **collect the due tables and close the rows before
|
||||||
|
// taking any lock.** The scan reads *sql.Rows from the one-connection pool; a lock
|
||||||
|
// taken while those rows are open would hold the connection the rows need, and the
|
||||||
|
// process would wedge. So DueTables returns a plain slice and the connection is
|
||||||
|
// free before the first table is touched.
|
||||||
|
//
|
||||||
|
// The guard is the version. The scan records each table's version; the clock acts
|
||||||
|
// on a table only if it is *still* that version by the time it holds the lock and
|
||||||
|
// has reloaded. Without that check the clock and a real move race and both land:
|
||||||
|
// Bob raises in the same second his clock expires, the action moves to Cara, and
|
||||||
|
// the clock — still believing the seat that ran out of time is to act — folds
|
||||||
|
// Cara, who had twenty-five seconds left. The version check turns that into a
|
||||||
|
// no-op: Bob's move bumped the version, the reload shows the new one, and the
|
||||||
|
// clock steps aside.
|
||||||
|
|
||||||
|
// clockInterval is how often the clock looks for expired turns. Sub-second
|
||||||
|
// precision buys nothing at a card table and would only spin the CPU.
|
||||||
|
const clockInterval = time.Second
|
||||||
|
|
||||||
|
// reaperInterval is how often idle sessions are cashed out. The reaper is a
|
||||||
|
// slow-moving safety net — a player who wandered off half an hour ago is not in a
|
||||||
|
// hurry — so it runs far less often than the clock.
|
||||||
|
const reaperInterval = time.Minute
|
||||||
|
|
||||||
|
// games returns the multiplayer engines by their storage key. It is the registry
|
||||||
|
// the clock and the handlers both dispatch through. Empty until an engine is
|
||||||
|
// wired; a clock over no games is a loop that finds nothing, which is correct.
|
||||||
|
func (s *Server) games() map[string]tableGame {
|
||||||
|
out := make(map[string]tableGame)
|
||||||
|
for _, g := range s.tableGames {
|
||||||
|
out[g.name()] = g
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTableClock launches the turn clock and the session reaper if the casino is
|
||||||
|
// on. Safe to call unconditionally.
|
||||||
|
func (s *Server) StartTableClock(ctx context.Context) {
|
||||||
|
if !s.gamesReady() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A deploy just took every table's clock down with it. Shove the live deadlines
|
||||||
|
// out so the first tick does not auto-act the whole room at once.
|
||||||
|
if err := storage.PushDeadlines(bootGrace); err != nil {
|
||||||
|
slog.Error("games: push deadlines on boot", "err", err)
|
||||||
|
}
|
||||||
|
go s.runTableClock(ctx)
|
||||||
|
go s.runSessionReaper(ctx)
|
||||||
|
go s.runTableReaper(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runTableClock(ctx context.Context) {
|
||||||
|
slog.Info("games: turn clock started", "interval", clockInterval)
|
||||||
|
ticker := time.NewTicker(clockInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.tickClock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tickClock finds the tables whose turn has expired and acts on each. The scan is
|
||||||
|
// done and the connection released before any table is locked — rule 1.
|
||||||
|
func (s *Server) tickClock() {
|
||||||
|
due, err := storage.DueTables(time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: due tables", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ref := range due {
|
||||||
|
s.runClockTable(ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runClockTable acts for the walked-away player at one table, but only if the
|
||||||
|
// table is still at the version the scan saw.
|
||||||
|
//
|
||||||
|
// The whole read-modify-write is done under the table's stripe, which is what
|
||||||
|
// keeps the clock from racing a second copy of itself — but the stripe is only an
|
||||||
|
// optimisation. The version check inside CommitTable is the real thing: even
|
||||||
|
// across a redeploy, when two processes hold two different stripes over this row,
|
||||||
|
// the one whose write lands first bumps the version and the other's write finds
|
||||||
|
// zero rows and rolls back.
|
||||||
|
func (s *Server) runClockTable(ref storage.TableRef) {
|
||||||
|
err := s.tableLocks.withTable(ref.ID, func() error {
|
||||||
|
t, seats, err := storage.LoadTable(ref.ID)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||||
|
return nil // closed out from under us; nothing to do
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Somebody moved between the scan and now. Their move set a fresh deadline (or
|
||||||
|
// cleared it), so this expiry is stale — step aside.
|
||||||
|
if t.Version != ref.Version {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// A deadline in the future means the scan is looking at an old view; leave it.
|
||||||
|
if t.Deadline == 0 || t.Deadline > time.Now().Unix() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
game := s.games()[t.Game]
|
||||||
|
if game == nil {
|
||||||
|
slog.Error("games: clock over unknown game", "game", t.Game, "table", t.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
st, newSeats, err := game.timeout(t.State, seats)
|
||||||
|
if errors.Is(err, errNotDue) {
|
||||||
|
return nil // the race resolved without us; nothing to act on
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: clock timeout", "table", t.ID, "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
|
||||||
|
if err := storage.CommitTable(storage.TableCommit{Table: t, Seats: newSeats, Audit: st.Audit}); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrStaleTable) {
|
||||||
|
return nil // lost the race after all; the winner handled it
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.publishTable(ref.ID)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: clock table", "table", ref.ID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishTable pushes the current table view to everyone watching it. It reads
|
||||||
|
// the table fresh (the authoritative state) and fans an opaque frame out through
|
||||||
|
// the hub. Called after every committed write, under no lock the hub cares about
|
||||||
|
// — the hub's sends are non-blocking, so this never stalls a caller.
|
||||||
|
//
|
||||||
|
// A view here is deliberately seat-blind: it carries only what every seat may see
|
||||||
|
// (the version and the public table shape), and each subscriber's own stream
|
||||||
|
// redacts and re-renders for the seat that is watching. That keeps a hole card
|
||||||
|
// from ever entering a frame that fans to the whole table.
|
||||||
|
func (s *Server) publishTable(tableID string) {
|
||||||
|
if s.hub.watchers(tableID) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, _, err := storage.LoadTable(tableID)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: publish table load", "table", tableID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The frame is just a nudge carrying the version: a subscriber that sees a gap
|
||||||
|
// refetches the authoritative, per-seat table. So the payload can be minimal.
|
||||||
|
// The type tells the browser a table changed (come and look) from a chat line
|
||||||
|
// (render it in place) — both ride the one stream.
|
||||||
|
data, _ := json.Marshal(map[string]any{"type": "table", "version": t.Version, "phase": t.Phase})
|
||||||
|
s.hub.publish(tableID, hubFrame{Version: t.Version, Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSessionReaper cashes out players who wandered off, on a timer. This is the
|
||||||
|
// loop the plan noted never existed: ReapIdleSessions has always been safe to run
|
||||||
|
// and nothing ever ran it, so chips in abandoned *solo* sessions sat in limbo.
|
||||||
|
//
|
||||||
|
// A seated player is invisible to it — their chips are inside a table blob, not on
|
||||||
|
// their game_chips stack — so it only ever reaps a player who is genuinely idle
|
||||||
|
// with loose chips. Getting up from a table returns them to the stack, and then
|
||||||
|
// this is what eventually sends them home.
|
||||||
|
func (s *Server) runSessionReaper(ctx context.Context) {
|
||||||
|
slog.Info("games: session reaper started", "interval", reaperInterval, "idle", storage.SessionIdleAfter)
|
||||||
|
ticker := time.NewTicker(reaperInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
n, err := storage.ReapIdleSessions(storage.SessionIdleAfter)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: reap idle sessions", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
slog.Info("games: reaped idle sessions", "count", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
internal/web/games_clock_test.go
Normal file
142
internal/web/games_clock_test.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeGame is a tableGame that records whether its clock ever fired. It lets the
|
||||||
|
// runtime tests exercise the lock discipline and the version guard without a real
|
||||||
|
// engine — the thing under test is the clock, not the cards.
|
||||||
|
type fakeGame struct {
|
||||||
|
fired int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *fakeGame) name() string { return "fake" }
|
||||||
|
|
||||||
|
func (g *fakeGame) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
|
||||||
|
g.fired++
|
||||||
|
// Act: clear the deadline and bump the hand, as a real settle would.
|
||||||
|
return step{State: []byte(`{"acted":true}`), Phase: "handover", HandNo: 2, Deadline: 0}, seats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *fakeGame) stacks(state []byte) ([]int64, error) { return []int64{0, 0}, nil }
|
||||||
|
|
||||||
|
// clockTestServer stands up a Server with just the table machinery wired and a
|
||||||
|
// fresh DB. Enough to drive the clock, nothing else.
|
||||||
|
func clockTestServer(t *testing.T, g tableGame) *Server {
|
||||||
|
t.Helper()
|
||||||
|
// Reset the storage singleton onto a temp DB.
|
||||||
|
if err := storage.Init(filepath.Join(t.TempDir(), "clock.db")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { storage.Close() })
|
||||||
|
|
||||||
|
s := &Server{hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{g}}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func openClockTable(t *testing.T, id string, deadline int64) storage.Table {
|
||||||
|
t.Helper()
|
||||||
|
tbl := storage.Table{
|
||||||
|
ID: id, Game: "fake", Tier: "1-2", State: []byte(`{}`),
|
||||||
|
Seed1: 1, Seed2: 2, Phase: "betting", HandNo: 1, Deadline: deadline,
|
||||||
|
}
|
||||||
|
seats := []storage.Seat{{Seat: 0, MatrixUser: "@reala:parodia.dev", Name: "reala"}, {Seat: 1, Name: "bot"}}
|
||||||
|
if err := storage.OpenTable(tbl, seats); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tbl
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClock_ActsOnExpiredTable(t *testing.T) {
|
||||||
|
g := &fakeGame{}
|
||||||
|
s := clockTestServer(t, g)
|
||||||
|
openClockTable(t, "t1", time.Now().Unix()-5) // already expired
|
||||||
|
|
||||||
|
s.tickClock()
|
||||||
|
|
||||||
|
if g.fired != 1 {
|
||||||
|
t.Fatalf("clock should have fired once, got %d", g.fired)
|
||||||
|
}
|
||||||
|
after, _, err := storage.LoadTable("t1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if after.Phase != "handover" || after.Deadline != 0 {
|
||||||
|
t.Errorf("table should have advanced: %+v", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClock_IgnoresFutureDeadlines(t *testing.T) {
|
||||||
|
g := &fakeGame{}
|
||||||
|
s := clockTestServer(t, g)
|
||||||
|
openClockTable(t, "t1", time.Now().Unix()+60)
|
||||||
|
|
||||||
|
s.tickClock()
|
||||||
|
|
||||||
|
if g.fired != 0 {
|
||||||
|
t.Fatalf("clock should not have fired on a future deadline, got %d", g.fired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClock_VersionGuardStopsTheDoubleMove is the scenario the whole design turns
|
||||||
|
// on. A move lands in the same tick the clock's scan found the table expired. The
|
||||||
|
// move bumps the version; the clock, acting on its stale scan, must see the new
|
||||||
|
// version and step aside rather than acting a second time.
|
||||||
|
func TestClock_VersionGuardStopsTheDoubleMove(t *testing.T) {
|
||||||
|
g := &fakeGame{}
|
||||||
|
s := clockTestServer(t, g)
|
||||||
|
tbl := openClockTable(t, "t1", time.Now().Unix()-5)
|
||||||
|
|
||||||
|
// The scan saw version 0.
|
||||||
|
due, err := storage.DueTables(time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(due) != 1 {
|
||||||
|
t.Fatalf("want 1 due table, got %d", len(due))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real move lands first, bumping the version and setting a fresh deadline for
|
||||||
|
// the next player.
|
||||||
|
tbl.State = []byte(`{"moved":true}`)
|
||||||
|
tbl.Deadline = time.Now().Unix() + 30
|
||||||
|
if err := storage.CommitTable(storage.TableCommit{Table: tbl}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the clock acts on its stale scan (version 0). It must not fire.
|
||||||
|
s.runClockTable(due[0])
|
||||||
|
|
||||||
|
if g.fired != 0 {
|
||||||
|
t.Fatalf("the version guard should have stopped the clock, but it fired %d time(s)", g.fired)
|
||||||
|
}
|
||||||
|
after, _, _ := storage.LoadTable("t1")
|
||||||
|
if string(after.State) != `{"moved":true}` {
|
||||||
|
t.Errorf("the real move should stand, got %s", after.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClock_PublishesToWatchers(t *testing.T) {
|
||||||
|
g := &fakeGame{}
|
||||||
|
s := clockTestServer(t, g)
|
||||||
|
openClockTable(t, "t1", time.Now().Unix()-5)
|
||||||
|
|
||||||
|
ch, done := s.hub.subscribe("t1")
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
s.tickClock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case f := <-ch:
|
||||||
|
if f.Version == 0 {
|
||||||
|
t.Errorf("frame should carry the bumped version, got %d", f.Version)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("a watcher should have received a frame after the clock acted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"pete/internal/games/blackjack"
|
"pete/internal/games/blackjack"
|
||||||
"pete/internal/games/holdem"
|
"pete/internal/games/holdem"
|
||||||
@@ -54,6 +56,11 @@ var seatStates = map[holdem.SeatState]string{
|
|||||||
// holdemView is the table as its player may see it.
|
// holdemView is the table as its player may see it.
|
||||||
type holdemView struct {
|
type holdemView struct {
|
||||||
Tier holdem.Tier `json:"tier"`
|
Tier holdem.Tier `json:"tier"`
|
||||||
|
// YourSeat is which chair in Seats is the viewer's. It used to be a convention
|
||||||
|
// (seat zero is you) that the felt hardcoded; at a shared table it is whatever
|
||||||
|
// chair you took, so it rides in the view and the browser reads it rather than
|
||||||
|
// assuming it.
|
||||||
|
YourSeat int `json:"your_seat"`
|
||||||
Seats []holdemSeatView `json:"seats"`
|
Seats []holdemSeatView `json:"seats"`
|
||||||
Button int `json:"button"`
|
Button int `json:"button"`
|
||||||
HandNo int `json:"hand_no"`
|
HandNo int `json:"hand_no"`
|
||||||
@@ -81,18 +88,29 @@ type holdemView struct {
|
|||||||
Payout int64 `json:"payout,omitempty"`
|
Payout int64 `json:"payout,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func viewHoldem(g holdem.State) holdemView {
|
// viewHoldem renders the table as one seat may see it. viewer is which seat is
|
||||||
|
// looking — their cards are the only hole cards it will ever put in the payload
|
||||||
|
// (until a showdown turns the rest over), and the action panel is filled in only
|
||||||
|
// when it is that seat's turn.
|
||||||
|
//
|
||||||
|
// This is the security boundary. Before SSE a missed check here leaked one bot's
|
||||||
|
// cards to one player through a bug in one handler; now the same view fans to
|
||||||
|
// every subscriber's stream, so a seat that renders anyone else's hole card fans
|
||||||
|
// it to the whole table. TestHoldemViewNeverLeaksAnotherSeatsCards renders every
|
||||||
|
// seat's view at every street and greps for cards that are not theirs.
|
||||||
|
func viewHoldem(g holdem.State, viewer int) holdemView {
|
||||||
v := holdemView{
|
v := holdemView{
|
||||||
Tier: g.Tier,
|
Tier: g.Tier,
|
||||||
|
YourSeat: viewer,
|
||||||
Button: g.Button,
|
Button: g.Button,
|
||||||
HandNo: g.HandNo,
|
HandNo: g.HandNo,
|
||||||
Street: g.Street.String(),
|
Street: g.Street.String(),
|
||||||
Pot: g.Total(),
|
Pot: g.Total(),
|
||||||
ToAct: g.ToAct,
|
ToAct: g.ToAct,
|
||||||
Phase: string(g.Phase),
|
Phase: string(g.Phase),
|
||||||
Stack: g.Seats[holdem.You].Stack,
|
Stack: g.Seats[viewer].Stack,
|
||||||
BoughtIn: g.BoughtIn,
|
BoughtIn: g.BoughtIn, // a table total; the caller overrides it with this seat's own stake
|
||||||
Rake: g.Paid, // the part you actually paid, not the part the table lifted
|
Rake: g.Seats[viewer].Paid, // this seat's rake alone, not the table's — see the ledger line on the felt
|
||||||
Payout: g.Payout,
|
Payout: g.Payout,
|
||||||
}
|
}
|
||||||
for _, p := range g.Side {
|
for _, p := range g.Side {
|
||||||
@@ -106,22 +124,22 @@ func viewHoldem(g holdem.State) holdemView {
|
|||||||
v.Board = append(v.Board, viewCard(c))
|
v.Board = append(v.Board, viewCard(c))
|
||||||
}
|
}
|
||||||
|
|
||||||
// The wall. A bot's hand crosses the wire in exactly one situation — the hand
|
// The wall. Another seat's hand crosses the wire in exactly one situation — the
|
||||||
// was shown down and they did not fold — because that is the only situation in
|
// hand was shown down and they did not fold — because that is the only situation
|
||||||
// which a player at a real table would be looking at it.
|
// in which a player at a real table would be looking at it.
|
||||||
shown := g.Street == holdem.Showdown
|
shown := g.Street == holdem.Showdown
|
||||||
for i, p := range g.Seats {
|
for i, p := range g.Seats {
|
||||||
seat := holdemSeatView{
|
seat := holdemSeatView{
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Bot: p.Bot,
|
Bot: p.Bot,
|
||||||
You: i == holdem.You,
|
You: i == viewer,
|
||||||
Stack: p.Stack,
|
Stack: p.Stack,
|
||||||
Bet: p.Bet,
|
Bet: p.Bet,
|
||||||
State: seatStates[p.State],
|
State: seatStates[p.State],
|
||||||
Pos: g.Position(i),
|
Pos: g.Position(i),
|
||||||
Won: p.Won,
|
Won: p.Won,
|
||||||
}
|
}
|
||||||
mine := i == holdem.You
|
mine := i == viewer
|
||||||
dealt := p.State != holdem.Out && p.Hole[0].Rank != 0
|
dealt := p.State != holdem.Out && p.Hole[0].Rank != 0
|
||||||
if dealt && (mine || (shown && p.State != holdem.Folded)) {
|
if dealt && (mine || (shown && p.State != holdem.Folded)) {
|
||||||
seat.Cards = []cardView{viewCard(p.Hole[0]), viewCard(p.Hole[1])}
|
seat.Cards = []cardView{viewCard(p.Hole[0]), viewCard(p.Hole[1])}
|
||||||
@@ -129,14 +147,14 @@ func viewHoldem(g holdem.State) holdemView {
|
|||||||
v.Seats = append(v.Seats, seat)
|
v.Seats = append(v.Seats, seat)
|
||||||
}
|
}
|
||||||
|
|
||||||
if g.Phase == holdem.PhaseBetting && g.ToAct == holdem.You {
|
if g.Phase == holdem.PhaseBetting && g.ToAct == viewer {
|
||||||
v.Owed = g.Owed(holdem.You)
|
v.Owed = g.Owed(viewer)
|
||||||
v.CanCheck = v.Owed == 0
|
v.CanCheck = v.Owed == 0
|
||||||
v.CanRaise = g.CanRaise(holdem.You)
|
v.CanRaise = g.CanRaise(viewer)
|
||||||
v.MinRaise = g.MinRaiseTo(holdem.You)
|
v.MinRaise = g.MinRaiseTo(viewer)
|
||||||
v.MaxRaise = g.MaxRaiseTo(holdem.You)
|
v.MaxRaise = g.MaxRaiseTo(viewer)
|
||||||
}
|
}
|
||||||
if top := g.Tier.MaxBuy - g.Seats[holdem.You].Stack; top > 0 {
|
if top := g.Tier.MaxBuy - g.Seats[viewer].Stack; top > 0 {
|
||||||
v.MaxTopUp = top
|
v.MaxTopUp = top
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
@@ -154,26 +172,71 @@ type holdemEventView struct {
|
|||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func viewHoldemEvents(evs []holdem.Event) []holdemEventView {
|
// viewHoldemEvents redacts the engine's script for one viewer. The engine emits
|
||||||
|
// every seat's hole cards now (it cannot know who a shared stream is for), so
|
||||||
|
// this is where the cards that are not the viewer's are stripped — turning a
|
||||||
|
// "deal seat 3 these two cards" beat into "deal seat 3 two face-down cards".
|
||||||
|
//
|
||||||
|
// A card may ride an event only if it is a board card (Seat < 0), the viewer's
|
||||||
|
// own, or a hand being shown down. Everything else is nulled here, and a missed
|
||||||
|
// case is the leak that fans one seat's hole card to every subscriber.
|
||||||
|
func viewHoldemEvents(evs []holdem.Event, viewer int) []holdemEventView {
|
||||||
out := make([]holdemEventView, 0, len(evs))
|
out := make([]holdemEventView, 0, len(evs))
|
||||||
for _, e := range evs {
|
for _, e := range evs {
|
||||||
v := holdemEventView{Kind: e.Kind, Seat: e.Seat, Amount: e.Amount, Total: e.Total, Text: e.Text}
|
v := holdemEventView{Kind: e.Kind, Seat: e.Seat, Amount: e.Amount, Total: e.Total, Text: e.Text}
|
||||||
for _, c := range e.Cards {
|
for _, c := range e.Cards {
|
||||||
v.Cards = append(v.Cards, viewCard(c))
|
v.Cards = append(v.Cards, viewCard(c))
|
||||||
}
|
}
|
||||||
// A card may ride an event only if it is the board, your own hand, or a hand
|
if len(v.Cards) > 0 && e.Seat >= 0 && e.Seat != viewer && e.Kind != "show" {
|
||||||
// being shown down. Anything else is a bot's business.
|
|
||||||
if len(v.Cards) > 0 && e.Seat >= 0 && e.Seat != holdem.You && e.Kind != "show" {
|
|
||||||
v.Cards = nil
|
v.Cards = nil
|
||||||
}
|
}
|
||||||
out = append(out, v)
|
out = append(out, v)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
// ---- sitting down: a table of your own, or somebody else's -----------------
|
||||||
|
|
||||||
// handleHoldemSit buys chips onto a table. The chips are staked first, in the
|
// displayName is what goes on the felt. It is the player's session name if they
|
||||||
// same statement that checks they exist, so two sit-downs fired at once cannot
|
// have one, the local part of their Matrix id otherwise — never empty, which
|
||||||
// buy in with the same chip.
|
// would sit a nameless chair at the table.
|
||||||
|
func (s *Server) displayName(r *http.Request, user string) string {
|
||||||
|
if u := s.auth.userFromRequest(r); u != nil {
|
||||||
|
if u.Name != "" {
|
||||||
|
return u.Name
|
||||||
|
}
|
||||||
|
if u.Username != "" {
|
||||||
|
return u.Username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name := strings.TrimPrefix(user, "@")
|
||||||
|
if i := strings.IndexByte(name, ':'); i > 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatRows mirrors the engine's seats into the storage rows that shadow them,
|
||||||
|
// index for index — seat i in the blob is seat i in game_seats — which is the
|
||||||
|
// alignment the view redacts by and the audit attributes by. A human's staked is
|
||||||
|
// the buy-in that actually crossed the border; a bot's is zero, because the only
|
||||||
|
// real money at the table is in the human seats and staked is where the border
|
||||||
|
// accounting lives.
|
||||||
|
func seatRows(g holdem.State, human string, buyIn int64) []storage.Seat {
|
||||||
|
rows := make([]storage.Seat, len(g.Seats))
|
||||||
|
for i := range g.Seats {
|
||||||
|
p := g.Seats[i]
|
||||||
|
row := storage.Seat{Seat: i, Name: p.Name}
|
||||||
|
if !p.Bot {
|
||||||
|
row.MatrixUser = human
|
||||||
|
row.Staked = buyIn
|
||||||
|
}
|
||||||
|
rows[i] = row
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHoldemSit seats a player: at a fresh table of their own (solo is just a
|
||||||
|
// table nobody else has joined yet), or at an open chair on somebody else's.
|
||||||
func (s *Server) handleHoldemSit(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHoldemSit(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.player(w, r)
|
user, ok := s.player(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -183,50 +246,207 @@ func (s *Server) handleHoldemSit(w http.ResponseWriter, r *http.Request) {
|
|||||||
Tier string `json:"tier"`
|
Tier string `json:"tier"`
|
||||||
Bots int `json:"bots"`
|
Bots int `json:"bots"`
|
||||||
BuyIn int64 `json:"buyin"`
|
BuyIn int64 `json:"buyin"`
|
||||||
|
Table string `json:"table"` // set to join an existing table rather than open one
|
||||||
|
Seat *int `json:"seat"` // which chair to take when joining; optional
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
http.Error(w, "bad json", http.StatusBadRequest)
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tier, err := holdem.TierBySlug(req.Tier)
|
if req.Table != "" {
|
||||||
|
s.joinHoldem(w, r, user, req.Table, req.Seat, req.BuyIn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.openHoldem(w, r, user, req.Tier, req.Bots, req.BuyIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openHoldem opens a fresh table with the player in seat zero and bots in the
|
||||||
|
// rest. It is the old solo flow, now a real shared table that simply has no other
|
||||||
|
// humans on it yet.
|
||||||
|
func (s *Server) openHoldem(w http.ResponseWriter, r *http.Request, user, tierSlug string, bots int, buyIn int64) {
|
||||||
|
tier, err := holdem.TierBySlug(tierSlug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.BuyIn < tier.MinBuy || req.BuyIn > tier.MaxBuy {
|
if buyIn < tier.MinBuy || buyIn > tier.MaxBuy {
|
||||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that isn't a legal buy-in for this table"})
|
||||||
"error": "that isn't a legal buy-in for this table",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Bots < 1 || req.Bots > holdem.MaxBots {
|
if bots < 1 || bots > holdem.MaxBots {
|
||||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick some opponents"})
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick some opponents"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := storage.Stake(user, req.BuyIn); err != nil {
|
name := s.displayName(r, user)
|
||||||
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
seed1, seed2 := newSeeds()
|
||||||
|
g, _, err := holdem.New(tier, holdem.TableSeats(tier, name, bots, buyIn), blackjack.DefaultRules().RakePct, seed1, seed2)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: holdem open", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
blob, err := json.Marshal(g)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: marshal new holdem", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := storage.NewTableID()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: mint table id", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t := storage.Table{
|
||||||
|
ID: id, Game: gameHoldem, Tier: tier.Slug, State: blob,
|
||||||
|
Seed1: seed1, Seed2: seed2, Phase: string(g.Phase), HandNo: int64(g.HandNo),
|
||||||
|
}
|
||||||
|
err = storage.OpenSoloTable(t, seatRows(g, user, buyIn), buyIn)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
|
||||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"})
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"})
|
||||||
return
|
return
|
||||||
}
|
case errors.Is(err, storage.ErrHandInProgress):
|
||||||
slog.Error("games: holdem buy-in", "user", user, "err", err)
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
slog.Error("games: open solo table", "user", user, "err", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.writeHoldemTable(w, user, nil)
|
||||||
|
}
|
||||||
|
|
||||||
seed1, seed2 := newSeeds()
|
// pickOpenSeat chooses a chair to join: the one the caller asked for if it is a
|
||||||
g, evs, err := holdem.New(tier, req.Bots, req.BuyIn, blackjack.DefaultRules().RakePct, seed1, seed2)
|
// bot's, otherwise the first bot seat. Returns -1 if there is nowhere to sit.
|
||||||
|
func pickOpenSeat(g holdem.State, want *int) int {
|
||||||
|
if want != nil {
|
||||||
|
i := *want
|
||||||
|
if i >= 0 && i < len(g.Seats) && g.Seats[i].Bot {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
for i := range g.Seats {
|
||||||
|
if g.Seats[i].Bot {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinHoldem sits a player down at an open chair on an existing table. It runs
|
||||||
|
// under the table lock, and every step of the sit-down is one transaction in
|
||||||
|
// SitDown — stake, claim, take the chair out of a bot's hands, save the state —
|
||||||
|
// so two people racing for the last seat cannot both win it.
|
||||||
|
func (s *Server) joinHoldem(w http.ResponseWriter, r *http.Request, user, tableID string, wantSeat *int, buyIn int64) {
|
||||||
|
name := s.displayName(r, user)
|
||||||
|
var respErr error
|
||||||
|
err := s.tableLocks.withTable(tableID, func() error {
|
||||||
|
t, _, err := storage.LoadTable(tableID)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||||
|
respErr = storage.ErrNoSuchTable
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = storage.Award(user, req.BuyIn) // nobody sat down, so nothing was bought
|
return err
|
||||||
slog.Error("games: holdem sit", "user", user, "err", err)
|
}
|
||||||
|
if t.Game != gameHoldem {
|
||||||
|
respErr = holdem.ErrUnknownMove
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var g holdem.State
|
||||||
|
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if g.Phase == holdem.PhaseBetting {
|
||||||
|
respErr = holdem.ErrHandLive // you join between hands, not into one
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seat := pickOpenSeat(g, wantSeat)
|
||||||
|
if seat < 0 {
|
||||||
|
respErr = holdem.ErrTableFull
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := g.Occupy(seat, name, buyIn); err != nil {
|
||||||
|
respErr = err
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
blob, err := json.Marshal(g)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.State, t.Phase, t.HandNo = blob, string(g.Phase), int64(g.HandNo)
|
||||||
|
err = storage.SitDown(storage.Sit{
|
||||||
|
Table: t,
|
||||||
|
Seat: storage.Seat{Seat: seat, MatrixUser: user, Name: name, Staked: buyIn},
|
||||||
|
BuyIn: buyIn,
|
||||||
|
})
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
|
||||||
|
respErr = storage.ErrInsufficientChips
|
||||||
|
return nil
|
||||||
|
case errors.Is(err, storage.ErrHandInProgress):
|
||||||
|
respErr = storage.ErrHandInProgress
|
||||||
|
return nil
|
||||||
|
case errors.Is(err, storage.ErrSeatTaken), errors.Is(err, storage.ErrStaleTable):
|
||||||
|
respErr = storage.ErrSeatTaken
|
||||||
|
return nil
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.publishTable(tableID)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: join holdem", "user", user, "table", tableID, "err", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.persistHoldem(w, user, g, evs, seed1, seed2, true)
|
if respErr != nil {
|
||||||
|
writeJSONStatus(w, joinStatus(respErr), map[string]string{"error": joinMessage(respErr)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeHoldemTable(w, user, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleHoldemMove plays one move: an action in a hand, or the three that are
|
func joinStatus(err error) int {
|
||||||
// about the session — deal the next hand, put more chips on the table, get up.
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrNoSuchTable), errors.Is(err, holdem.ErrTableFull),
|
||||||
|
errors.Is(err, storage.ErrSeatTaken), errors.Is(err, holdem.ErrHandLive):
|
||||||
|
return http.StatusConflict
|
||||||
|
default:
|
||||||
|
return http.StatusBadRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinMessage(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrNoSuchTable):
|
||||||
|
return "that table has closed"
|
||||||
|
case errors.Is(err, holdem.ErrTableFull), errors.Is(err, storage.ErrSeatTaken):
|
||||||
|
return "that seat is taken"
|
||||||
|
case errors.Is(err, holdem.ErrHandLive):
|
||||||
|
return "a hand is in play — sit down when it's over"
|
||||||
|
case errors.Is(err, holdem.ErrBadBuyIn):
|
||||||
|
return "that isn't a legal buy-in for this table"
|
||||||
|
case errors.Is(err, storage.ErrInsufficientChips):
|
||||||
|
return "not enough chips to sit down"
|
||||||
|
case errors.Is(err, storage.ErrHandInProgress):
|
||||||
|
return "finish the game you're in first"
|
||||||
|
default:
|
||||||
|
return "you can't sit there"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- playing a hand --------------------------------------------------------
|
||||||
|
|
||||||
|
// handleHoldemMove plays one move at the player's table: a betting action, or the
|
||||||
|
// two session moves that are not leaving (deal the next hand, top up between
|
||||||
|
// them). Leaving is its own endpoint, because it is a storage operation rather
|
||||||
|
// than an engine one.
|
||||||
func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.player(w, r)
|
user, ok := s.player(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -237,113 +457,275 @@ func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad json", http.StatusBadRequest)
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if move.Kind == holdem.Leave {
|
||||||
|
s.leaveHoldem(w, user)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
live, err := storage.LoadLiveHand(user)
|
tableID, seat, err := storage.PlayerSeat(user)
|
||||||
if errors.Is(err, storage.ErrNoLiveHand) {
|
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("games: holdem load", "user", user, "err", err)
|
slog.Error("games: holdem move seat", "user", user, "err", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if live.Game != gameHoldem {
|
|
||||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var g holdem.State
|
|
||||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
|
||||||
slog.Error("games: unreadable holdem table", "user", user, "err", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// A top-up is real money crossing the border, so the chips come off the stack
|
var respErr error
|
||||||
// before the engine is asked — and go straight back if it says no. Same order,
|
var respEvents []holdem.Event
|
||||||
// and the same reason, as doubling down at blackjack.
|
err = s.tableLocks.withTable(tableID, func() error {
|
||||||
|
t, seats, err := storage.LoadTable(tableID)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||||
|
respErr = storage.ErrNoSuchTable
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var g holdem.State
|
||||||
|
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A top-up is real chips crossing the border, so it comes off the stack
|
||||||
|
// before the engine is asked, and goes straight back if the engine says no —
|
||||||
|
// the same order, and the same reason, as doubling down at blackjack.
|
||||||
topped := int64(0)
|
topped := int64(0)
|
||||||
if move.Kind == holdem.TopUp {
|
if move.Kind == holdem.TopUp {
|
||||||
if err := storage.Stake(user, move.Amount); err != nil {
|
if err := storage.Stake(user, move.Amount); err != nil {
|
||||||
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
||||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you don't have those chips"})
|
respErr = holdem.ErrTooBig
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
slog.Error("games: holdem top-up", "user", user, "err", err)
|
return err
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
topped = move.Amount
|
topped = move.Amount
|
||||||
}
|
}
|
||||||
|
|
||||||
next, evs, err := holdem.ApplyMove(g, move)
|
next, evs, aerr := holdem.ApplyMove(g, seat, move)
|
||||||
if err != nil {
|
if aerr != nil {
|
||||||
if topped > 0 {
|
if topped > 0 {
|
||||||
_ = storage.Award(user, topped) // the top-up didn't happen
|
_ = storage.Award(user, topped) // the top-up didn't happen
|
||||||
}
|
}
|
||||||
msg := "that move isn't legal here"
|
respErr = aerr
|
||||||
switch {
|
return nil
|
||||||
case errors.Is(err, holdem.ErrHandLive):
|
|
||||||
msg = "finish the hand first"
|
|
||||||
case errors.Is(err, holdem.ErrNotYourTurn):
|
|
||||||
msg = "it isn't your turn"
|
|
||||||
case errors.Is(err, holdem.ErrCantCheck):
|
|
||||||
msg = "there's a bet to you"
|
|
||||||
case errors.Is(err, holdem.ErrTooSmall):
|
|
||||||
msg = "that's under the minimum raise"
|
|
||||||
case errors.Is(err, holdem.ErrTooBig):
|
|
||||||
msg = "you don't have that many chips"
|
|
||||||
case errors.Is(err, holdem.ErrBadBuyIn):
|
|
||||||
msg = "that would put you over the table maximum"
|
|
||||||
}
|
|
||||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.persistHoldem(w, user, next, evs, live.Seed1, live.Seed2, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// persistHoldem writes the table back and answers the browser.
|
// A solo session that just ended (the one human busted) is not a table any
|
||||||
//
|
// more: cash the seat out — for nothing, but the claim still has to be
|
||||||
// The session settles exactly once — when the player gets up, or when they have
|
// released — and close the felt. Only a one-human bust reaches PhaseDone; a
|
||||||
// nothing left to get up with. Until then Done is false and `commit` moves no
|
// shared table sits the busted seat Out and plays on.
|
||||||
// chips at all, which is what makes a hundred hands of poker a single trip across
|
if next.Phase == holdem.PhaseDone {
|
||||||
// the border rather than a hundred.
|
if err := s.settleLeave(t, next, seat, user); err != nil {
|
||||||
func (s *Server) persistHoldem(w http.ResponseWriter, user string, g holdem.State, evs []holdem.Event, seed1, seed2 uint64, fresh bool) {
|
if topped > 0 {
|
||||||
blob, err := json.Marshal(g)
|
_ = storage.Award(user, topped)
|
||||||
|
}
|
||||||
|
if errors.Is(err, storage.ErrStaleTable) {
|
||||||
|
respErr = storage.ErrStaleTable
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
respEvents = evs
|
||||||
|
s.publishTable(tableID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := holdemStep(g, next, evs, seats)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("games: marshal holdem", "user", user, "err", err)
|
if topped > 0 {
|
||||||
|
_ = storage.Award(user, topped)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
acting := seats[seat]
|
||||||
|
acting.Away = false
|
||||||
|
acting.LastSeen = time.Now().Unix()
|
||||||
|
// A top-up is more real money crossing the border, so the seat's staked row —
|
||||||
|
// the record of what they brought and can still take home — has to grow with
|
||||||
|
// it. Without this the storage invariant (stacks + pot == staked - rake) drifts
|
||||||
|
// by every top-up, and the felt under-reports what they bought in for.
|
||||||
|
acting.Staked += topped
|
||||||
|
|
||||||
|
t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
|
||||||
|
if err := storage.CommitTable(storage.TableCommit{
|
||||||
|
Table: t, Seats: []storage.Seat{acting}, Audit: st.Audit,
|
||||||
|
}); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrStaleTable) {
|
||||||
|
if topped > 0 {
|
||||||
|
_ = storage.Award(user, topped)
|
||||||
|
}
|
||||||
|
respErr = storage.ErrStaleTable
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
respEvents = evs
|
||||||
|
s.publishTable(tableID)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: holdem move", "user", user, "table", tableID, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if respErr != nil {
|
||||||
|
writeJSONStatus(w, moveStatus(respErr), map[string]string{"error": moveMessage(respErr)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeHoldemTable(w, user, respEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveStatus(err error) int {
|
||||||
|
// A 409 is a concurrency verdict: the table is not where the caller thought, so
|
||||||
|
// reload and look again. Everything else — an illegal move for this state — is
|
||||||
|
// the caller's mistake, a 400. Leaving mid-hand or acting out of turn is a 400:
|
||||||
|
// the request was simply not allowed, not raced.
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable):
|
||||||
|
return http.StatusConflict
|
||||||
|
default:
|
||||||
|
return http.StatusBadRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveMessage(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrStaleTable):
|
||||||
|
return "the table moved on — take another look"
|
||||||
|
case errors.Is(err, storage.ErrNoSuchTable):
|
||||||
|
return "that table has closed"
|
||||||
|
case errors.Is(err, holdem.ErrHandLive):
|
||||||
|
return "finish the hand first"
|
||||||
|
case errors.Is(err, holdem.ErrNotYourTurn):
|
||||||
|
return "it isn't your turn"
|
||||||
|
case errors.Is(err, holdem.ErrCantCheck):
|
||||||
|
return "there's a bet to you"
|
||||||
|
case errors.Is(err, holdem.ErrTooSmall):
|
||||||
|
return "that's under the minimum raise"
|
||||||
|
case errors.Is(err, holdem.ErrTooBig):
|
||||||
|
return "you don't have that many chips"
|
||||||
|
case errors.Is(err, holdem.ErrBadBuyIn):
|
||||||
|
return "that would put you over the table maximum"
|
||||||
|
default:
|
||||||
|
return "that move isn't legal here"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- getting up ------------------------------------------------------------
|
||||||
|
|
||||||
|
// handleHoldemLeave is the get-up endpoint. Leaving is its own route because it
|
||||||
|
// is a storage operation, not an engine move — the chips cross the border and the
|
||||||
|
// felt may close — even though the felt also lets you send it as a "leave" move.
|
||||||
|
func (s *Server) handleHoldemLeave(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.leaveHoldem(w, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// leaveHoldem gets a player up from their table, turning what is in front of them
|
||||||
|
// back into chips. It refuses mid-hand — you cannot walk out on chips you have in
|
||||||
|
// the pot — and closes the felt behind the last human to leave.
|
||||||
|
func (s *Server) leaveHoldem(w http.ResponseWriter, user string) {
|
||||||
|
tableID, seat, err := storage.PlayerSeat(user)
|
||||||
|
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: holdem leave seat", "user", user, "err", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
done := g.Phase == holdem.PhaseDone
|
var respErr error
|
||||||
outcome := "left"
|
err = s.tableLocks.withTable(tableID, func() error {
|
||||||
switch {
|
t, _, err := storage.LoadTable(tableID)
|
||||||
case done && g.Payout == 0:
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||||
outcome = "busted"
|
respErr = storage.ErrNoSuchTable
|
||||||
case done && g.Payout > g.BoughtIn:
|
return nil
|
||||||
outcome = "up"
|
|
||||||
case done && g.Payout < g.BoughtIn:
|
|
||||||
outcome = "down"
|
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
v, ok := s.commit(w, user, finished{
|
return err
|
||||||
Game: gameHoldem, Blob: blob,
|
}
|
||||||
// Paid, not Rake: the audit log is the house's income, and the house only
|
var g holdem.State
|
||||||
// makes money off the player. What it lifts off a bot's pot is not income.
|
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||||
Bet: g.BoughtIn, Payout: g.Payout, Rake: g.Paid,
|
return err
|
||||||
Outcome: outcome, Done: done,
|
}
|
||||||
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
if g.Phase == holdem.PhaseBetting {
|
||||||
|
respErr = holdem.ErrHandLive
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.settleLeave(t, g, seat, user); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrStaleTable) {
|
||||||
|
respErr = storage.ErrStaleTable
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.publishTable(tableID)
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
if !ok {
|
if err != nil {
|
||||||
|
slog.Error("games: holdem leave", "user", user, "table", tableID, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A closed session is gone from storage, so the table view has none to show —
|
if respErr != nil {
|
||||||
// but the browser still needs the last board to land the verdict on.
|
writeJSONStatus(w, moveStatus(respErr), map[string]string{"error": moveMessage(respErr)})
|
||||||
if done {
|
return
|
||||||
hv := viewHoldem(g)
|
}
|
||||||
v.Holdem = &hv
|
s.writeHoldemTable(w, user, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// settleLeave vacates a seat, credits the stack home, and closes the table if
|
||||||
|
// nobody human is left — all inside the caller's lock. The engine's Vacate turns
|
||||||
|
// the chair back into the house's (its chips become house money, rebought like
|
||||||
|
// any bot's), and storage.LeaveTable does the border crossing in one transaction
|
||||||
|
// with the state write, so a crash can never pay a player and then leave their
|
||||||
|
// seat sitting there to be cashed out again.
|
||||||
|
func (s *Server) settleLeave(t storage.Table, g holdem.State, seat int, user string) error {
|
||||||
|
home, err := g.Vacate(seat)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
blob, err := json.Marshal(g)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.State, t.Phase, t.HandNo, t.Deadline = blob, string(g.Phase), int64(g.HandNo), 0
|
||||||
|
if err := storage.LeaveTable(storage.Leave{
|
||||||
|
Table: t, Seat: seat, MatrixUser: user, Bot: g.Seats[seat].Name, Amount: home,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := storage.CloseTable(t.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the response ----------------------------------------------------------
|
||||||
|
|
||||||
|
// writeHoldemTable answers with the whole page state — the money and the table as
|
||||||
|
// the player's own seat may see it — plus, when a move produced one, the redacted
|
||||||
|
// event script for that seat to animate. A player who has just got up has no seat
|
||||||
|
// and no table; the money view carries the leftover verdict and the felt clears.
|
||||||
|
func (s *Server) writeHoldemTable(w http.ResponseWriter, user string, evs []holdem.Event) {
|
||||||
|
v, err := s.table(user)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: holdem table", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(evs) > 0 {
|
||||||
|
if _, seat, serr := storage.PlayerSeat(user); serr == nil {
|
||||||
|
v.HoldemEvents = viewHoldemEvents(evs, seat)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
v.HoldemEvents = viewHoldemEvents(evs)
|
|
||||||
writeJSON(w, v)
|
writeJSON(w, v)
|
||||||
}
|
}
|
||||||
|
|||||||
230
internal/web/games_holdem_multiplayer_test.go
Normal file
230
internal/web/games_holdem_multiplayer_test.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bobPlayer = "@bob:parodia.dev"
|
||||||
|
|
||||||
|
// fundUser puts chips in front of a named player the way the border really does.
|
||||||
|
func fundUser(t *testing.T, user string, chips int64) {
|
||||||
|
t.Helper()
|
||||||
|
e, err := storage.RequestBuyIn(user, chips)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := storage.ClaimEscrow(e.GUID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := storage.SettleEscrow(e.GUID, true, "", 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chipsOf(t *testing.T, user string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
st, err := storage.Chips(user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return st.Chips
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second person can sit down at a table somebody else opened, taking a chair a
|
||||||
|
// bot was keeping warm — which is the whole point of the thing being multiplayer.
|
||||||
|
func TestHoldemJoinTakesAnOpenSeat(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 5000) // reala
|
||||||
|
fundUser(t, bobPlayer, 5000)
|
||||||
|
|
||||||
|
if _, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||||
|
map[string]any{"tier": "low", "bots": 2, "buyin": 500})); code != 200 {
|
||||||
|
t.Fatalf("reala sit = %d, want 200", code)
|
||||||
|
}
|
||||||
|
tableID, err := storage.TableOf(testPlayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v, code := call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
|
||||||
|
map[string]any{"table": tableID, "buyin": 500}))
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("bob join = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if v.Chips != 4500 {
|
||||||
|
t.Errorf("bob's chips after a 500 buy-in = %d, want 4500", v.Chips)
|
||||||
|
}
|
||||||
|
if v.Holdem == nil {
|
||||||
|
t.Fatal("join returned no table")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, bobSeat, err := storage.PlayerSeat(bobPlayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bobSeat == 0 {
|
||||||
|
t.Errorf("bob took reala's seat %d", bobSeat)
|
||||||
|
}
|
||||||
|
if v.Holdem.YourSeat != bobSeat {
|
||||||
|
t.Errorf("the view says bob is at seat %d, storage says %d", v.Holdem.YourSeat, bobSeat)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, seats, err := storage.LoadTable(tableID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
humans := 0
|
||||||
|
for _, seat := range seats {
|
||||||
|
if seat.MatrixUser != "" {
|
||||||
|
humans++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if humans != 2 {
|
||||||
|
t.Errorf("two people at the table, storage says %d humans", humans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The felt tells each player "you bought in for X" — their own stake, not the
|
||||||
|
// table's. The engine's BoughtIn is the sum across every human (the audit wants
|
||||||
|
// that), so a two-human table would quote both of them the pair's total if the
|
||||||
|
// view read it straight. It reads each seat's own staked row from storage instead.
|
||||||
|
func TestBoughtInIsPerPlayerNotTheTableTotal(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 5000) // reala
|
||||||
|
fundUser(t, bobPlayer, 5000)
|
||||||
|
|
||||||
|
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||||
|
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
|
||||||
|
tableID, _ := storage.TableOf(testPlayer)
|
||||||
|
call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
|
||||||
|
map[string]any{"table": tableID, "buyin": 500}))
|
||||||
|
|
||||||
|
for _, who := range []string{testPlayer, bobPlayer} {
|
||||||
|
v, err := s.table(who)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v.Holdem == nil {
|
||||||
|
t.Fatalf("%s has no table", who)
|
||||||
|
}
|
||||||
|
if v.Holdem.BoughtIn != 500 {
|
||||||
|
t.Errorf("%s is shown bought-in %d, want their own 500 — not the table's 1000",
|
||||||
|
who, v.Holdem.BoughtIn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaving a shared table gives you your stack and hands your chair back to the
|
||||||
|
// house; the table stays open for the people still on it. Only the last human out
|
||||||
|
// closes the felt.
|
||||||
|
func TestHoldemLeavingSharedTableKeepsItOpen(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 5000)
|
||||||
|
fundUser(t, bobPlayer, 5000)
|
||||||
|
|
||||||
|
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||||
|
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
|
||||||
|
tableID, _ := storage.TableOf(testPlayer)
|
||||||
|
call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
|
||||||
|
map[string]any{"table": tableID, "buyin": 500}))
|
||||||
|
|
||||||
|
// Bob gets up. His 500 comes home and his chair goes back to a bot.
|
||||||
|
if _, code := call(t, s, s.handleHoldemLeave, as(t, s, "bob", "POST", "/api/games/holdem/leave", nil)); code != 200 {
|
||||||
|
t.Fatalf("bob leave = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if got := chipsOf(t, bobPlayer); got != 5000 {
|
||||||
|
t.Errorf("bob left with %d, want his 5000 back", got)
|
||||||
|
}
|
||||||
|
if _, _, err := storage.PlayerSeat(bobPlayer); err != storage.ErrNoLiveHand {
|
||||||
|
t.Errorf("bob still holds a seat after leaving: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := storage.LoadTable(tableID); err != nil {
|
||||||
|
t.Errorf("the table closed under reala when bob left: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reala is the last one out, so the felt closes behind them.
|
||||||
|
if _, code := call(t, s, s.handleHoldemLeave, as(t, s, "reala", "POST", "/api/games/holdem/leave", nil)); code != 200 {
|
||||||
|
t.Fatalf("reala leave = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if _, _, err := storage.LoadTable(tableID); err != storage.ErrNoSuchTable {
|
||||||
|
t.Errorf("an empty table survived the last human: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A table everyone has walked away from is cashed out and closed by the reaper —
|
||||||
|
// the chips inside a walked-away poker session are not left in limbo.
|
||||||
|
func TestHoldemReaperCashesOutAbandonedTable(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 5000)
|
||||||
|
|
||||||
|
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||||
|
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
|
||||||
|
tableID, _ := storage.TableOf(testPlayer)
|
||||||
|
|
||||||
|
// The seat has been away, and last acted for itself longer ago than the idle
|
||||||
|
// cutoff — the state the reaper is meant to find.
|
||||||
|
tbl, seats, err := storage.LoadTable(tableID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seats[0].Away = true
|
||||||
|
seats[0].LastSeen = time.Now().Unix() - int64(storage.SessionIdleAfter.Seconds()) - 60
|
||||||
|
if err := storage.CommitTable(storage.TableCommit{Table: tbl, Seats: []storage.Seat{seats[0]}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.reapAbandonedTables()
|
||||||
|
|
||||||
|
if got := chipsOf(t, testPlayer); got != 5000 {
|
||||||
|
t.Errorf("the reaper sent home %d, want the whole 5000 back (buy-in and all)", got)
|
||||||
|
}
|
||||||
|
if _, _, err := storage.LoadTable(tableID); err != storage.ErrNoSuchTable {
|
||||||
|
t.Errorf("the reaper left the table standing: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := storage.TableOf(testPlayer); err != storage.ErrNoLiveHand {
|
||||||
|
t.Errorf("the reaper left the occupancy claim behind: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh table is not abandoned, and the reaper leaves it alone.
|
||||||
|
func TestHoldemReaperSparesALiveTable(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 5000)
|
||||||
|
|
||||||
|
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||||
|
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
|
||||||
|
tableID, _ := storage.TableOf(testPlayer)
|
||||||
|
|
||||||
|
s.reapAbandonedTables()
|
||||||
|
|
||||||
|
if _, _, err := storage.LoadTable(tableID); err != nil {
|
||||||
|
t.Errorf("the reaper closed a table someone is sitting at: %v", err)
|
||||||
|
}
|
||||||
|
if got := chipsOf(t, testPlayer); got != 4500 {
|
||||||
|
t.Errorf("chips = %d — the reaper should not have moved a live table's money", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lobby lists a table with a seat going spare, and drops it once it is full.
|
||||||
|
func TestHoldemLobbyListsJoinableTables(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 5000)
|
||||||
|
|
||||||
|
// A one-bot table: two seats, one human, one open.
|
||||||
|
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||||
|
map[string]any{"tier": "low", "bots": 1, "buyin": 500}))
|
||||||
|
|
||||||
|
tables, err := storage.LobbyTables(gameHoldem, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(tables) != 1 {
|
||||||
|
t.Fatalf("lobby has %d tables, want 1", len(tables))
|
||||||
|
}
|
||||||
|
if tables[0].Humans != 1 || tables[0].Seats != 2 {
|
||||||
|
t.Errorf("lobby table = %d/%d humans/seats, want 1/2", tables[0].Humans, tables[0].Seats)
|
||||||
|
}
|
||||||
|
}
|
||||||
143
internal/web/games_holdem_redact_test.go
Normal file
143
internal/web/games_holdem_redact_test.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pete/internal/games/holdem"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The security boundary of the whole multiplayer table, tested the way the plan
|
||||||
|
// insists it must be: render every seat's view at every street, and string-search
|
||||||
|
// the JSON for any card that belongs to another seat. Zero hits, for every seat,
|
||||||
|
// at every point before a showdown turns cards over.
|
||||||
|
//
|
||||||
|
// This is not belt-and-braces. After SSE the view a seat renders fans to that
|
||||||
|
// seat's stream, so a single missed redaction does not leak one card to one
|
||||||
|
// player through one handler bug — it broadcasts a hole card to the whole felt,
|
||||||
|
// continuously. The engine deliberately emits every seat's cards now (it cannot
|
||||||
|
// know who a shared stream is for), which makes this function the only thing
|
||||||
|
// standing between the deck and the network tab.
|
||||||
|
|
||||||
|
// holeLabel is a seat's two cards as they would be rendered — the exact strings a
|
||||||
|
// leak would put in the JSON.
|
||||||
|
func holeLabels(g holdem.State, seat int) []string {
|
||||||
|
h := g.Seats[seat].Hole
|
||||||
|
return []string{viewCard(h[0]).Label, viewCard(h[1]).Label}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHoldemViewNeverLeaksAnotherSeatsCards(t *testing.T) {
|
||||||
|
tier := holdem.Tiers[0]
|
||||||
|
// Two humans (0, 2) and two bots (1, 3), so the test covers a seat seeing both
|
||||||
|
// another human and a bot, and a human sitting at a non-zero index.
|
||||||
|
seats := []holdem.SeatConfig{
|
||||||
|
{Name: "Ana", Stack: tier.MaxBuy},
|
||||||
|
{Name: "Bot A", Bot: true, Stack: tier.MaxBuy},
|
||||||
|
{Name: "Bo", Stack: tier.MaxBuy},
|
||||||
|
{Name: "Bot B", Bot: true, Stack: tier.MaxBuy},
|
||||||
|
}
|
||||||
|
g, _, err := holdem.New(tier, seats, tier.RakePct, 5, 6)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deal, then walk the hand to the river without anyone folding — every seat
|
||||||
|
// stays in, so every seat has cards that must not leak to the others. Checking
|
||||||
|
// and calling keeps everyone live; the redaction is checked after every step.
|
||||||
|
g, dealEvs, err := holdem.ApplyMove(g, 0, holdem.Move{Kind: holdem.Deal})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deal script carries every hole, so it is the sharpest test of event
|
||||||
|
// redaction: for each viewer, no other seat's cards may survive.
|
||||||
|
assertEventsRedacted(t, g, dealEvs)
|
||||||
|
assertViewsRedacted(t, g)
|
||||||
|
|
||||||
|
for step := 0; g.Phase == holdem.PhaseBetting && step < 80; step++ {
|
||||||
|
seat := g.ToAct
|
||||||
|
if g.Seats[seat].Bot {
|
||||||
|
t.Fatalf("advance stopped on a bot at seat %d", seat)
|
||||||
|
}
|
||||||
|
move := holdem.Move{Kind: holdem.Check}
|
||||||
|
if g.Owed(seat) > 0 {
|
||||||
|
move = holdem.Move{Kind: holdem.Call}
|
||||||
|
}
|
||||||
|
var evs []holdem.Event
|
||||||
|
g, evs, err = holdem.ApplyMove(g, seat, move)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seat %d %s: %v", seat, move.Kind, err)
|
||||||
|
}
|
||||||
|
assertEventsRedacted(t, g, evs)
|
||||||
|
assertViewsRedacted(t, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertViewsRedacted renders every seat's table view and fails if any of them
|
||||||
|
// carries a card belonging to a seat that is still hiding it.
|
||||||
|
func assertViewsRedacted(t *testing.T, g holdem.State) {
|
||||||
|
t.Helper()
|
||||||
|
shown := g.Street == holdem.Showdown
|
||||||
|
for viewer := range g.Seats {
|
||||||
|
blob, err := json.Marshal(viewHoldem(g, viewer))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
payload := string(blob)
|
||||||
|
for other := range g.Seats {
|
||||||
|
if other == viewer {
|
||||||
|
continue // your own cards are yours to see
|
||||||
|
}
|
||||||
|
// A seat's cards are legitimately visible only at a showdown, and then only
|
||||||
|
// if they did not fold. Everywhere else, a hit is a leak.
|
||||||
|
if shown && g.Seats[other].State != holdem.Folded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if g.Seats[other].State == holdem.Out {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, label := range holeLabels(g, other) {
|
||||||
|
if strings.Contains(payload, label) {
|
||||||
|
t.Fatalf("seat %d's view (%s street) leaks seat %d's card %q",
|
||||||
|
viewer, g.Street, other, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertEventsRedacted renders the event script for every viewer and fails if a
|
||||||
|
// beat carries another seat's card outside a showdown "show".
|
||||||
|
func assertEventsRedacted(t *testing.T, g holdem.State, evs []holdem.Event) {
|
||||||
|
t.Helper()
|
||||||
|
for viewer := range g.Seats {
|
||||||
|
blob, err := json.Marshal(viewHoldemEvents(evs, viewer))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
payload := string(blob)
|
||||||
|
// The engine only puts a non-viewer's cards in a "show" beat and in the hole
|
||||||
|
// beats it emits for everyone. If any card label of another seat survives the
|
||||||
|
// redaction *and* there is no show event in this batch, it leaked.
|
||||||
|
hasShow := false
|
||||||
|
for _, e := range evs {
|
||||||
|
if e.Kind == "show" {
|
||||||
|
hasShow = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasShow {
|
||||||
|
continue // a showdown legitimately turns cards over
|
||||||
|
}
|
||||||
|
for other := range g.Seats {
|
||||||
|
if other == viewer || g.Seats[other].State == holdem.Out {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, label := range holeLabels(g, other) {
|
||||||
|
if strings.Contains(payload, label) {
|
||||||
|
t.Fatalf("seat %d's event stream leaks seat %d's card %q", viewer, other, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
209
internal/web/games_holdem_table.go
Normal file
209
internal/web/games_holdem_table.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/games/holdem"
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hold'em as a shared table: the seam the runtime drives it through, and the two
|
||||||
|
// facts about a hand that only the engine can tell the runtime — when the clock
|
||||||
|
// must next act, and what a finished hand owes the audit trail.
|
||||||
|
//
|
||||||
|
// Everything about *playing* poker is still in the engine. This file is the
|
||||||
|
// translation layer between a holdem.State and the game-agnostic table runtime:
|
||||||
|
// it decodes the blob, asks the engine to act, and re-derives the deadline and
|
||||||
|
// the audit from the state that comes back.
|
||||||
|
|
||||||
|
// holdemTable is the tableGame for poker. It holds no state — the table's state
|
||||||
|
// is the blob in game_tables — so a single value in s.tableGames serves every
|
||||||
|
// felt in the room.
|
||||||
|
type holdemTable struct{}
|
||||||
|
|
||||||
|
func (holdemTable) name() string { return gameHoldem }
|
||||||
|
|
||||||
|
// timeout acts for the human whose clock ran out. At a card table the standing
|
||||||
|
// courtesy is check if you can, fold if you cannot: a walked-away player never
|
||||||
|
// puts more chips in, and folding keeps the hand moving for everyone still there.
|
||||||
|
//
|
||||||
|
// It marks the seat away so the runtime stops waiting a full clock on it next
|
||||||
|
// time — an absent human auto-folds on sight after this, rather than holding three
|
||||||
|
// other people hostage for thirty seconds an orbit.
|
||||||
|
//
|
||||||
|
// If, on decode, the seat to act is not a waiting human, the scan raced a real
|
||||||
|
// move that had not yet bumped the version: errNotDue, and the clock steps aside.
|
||||||
|
func (holdemTable) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
|
||||||
|
var g holdem.State
|
||||||
|
if err := json.Unmarshal(state, &g); err != nil {
|
||||||
|
return step{}, nil, err
|
||||||
|
}
|
||||||
|
if g.Phase != holdem.PhaseBetting {
|
||||||
|
return step{}, seats, errNotDue
|
||||||
|
}
|
||||||
|
seat := g.ToAct
|
||||||
|
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
|
||||||
|
return step{}, seats, errNotDue
|
||||||
|
}
|
||||||
|
|
||||||
|
move := holdem.Move{Kind: holdem.Fold}
|
||||||
|
if g.Owed(seat) == 0 {
|
||||||
|
move.Kind = holdem.Check
|
||||||
|
}
|
||||||
|
next, evs, err := holdem.ApplyMove(g, seat, move)
|
||||||
|
if err != nil {
|
||||||
|
return step{}, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := markAway(seats, seat)
|
||||||
|
st, err := holdemStep(g, next, evs, seats)
|
||||||
|
return st, changed, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// stacks reports the chips in front of each seat, index-aligned with the table's
|
||||||
|
// seat rows. The abandoned-table reaper reads it to know what to send each
|
||||||
|
// walked-away human home with, without having to understand poker.
|
||||||
|
func (holdemTable) stacks(state []byte) ([]int64, error) {
|
||||||
|
var g holdem.State
|
||||||
|
if err := json.Unmarshal(state, &g); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]int64, len(g.Seats))
|
||||||
|
for i := range g.Seats {
|
||||||
|
out[i] = g.Seats[i].Stack
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdemStep packages a played-out state for the runtime: the blob to persist,
|
||||||
|
// the deadline the clock must honour next, and the audit of any hand that just
|
||||||
|
// ended. prev is the state before the move, which is what lets it work out how
|
||||||
|
// much rake this one hand took.
|
||||||
|
func holdemStep(prev, next holdem.State, evs []holdem.Event, seats []storage.Seat) (step, error) {
|
||||||
|
blob, err := json.Marshal(next)
|
||||||
|
if err != nil {
|
||||||
|
return step{}, err
|
||||||
|
}
|
||||||
|
st := step{
|
||||||
|
State: blob,
|
||||||
|
Phase: string(next.Phase),
|
||||||
|
HandNo: int64(next.HandNo),
|
||||||
|
Deadline: holdemDeadline(next, seats),
|
||||||
|
Audit: holdemAudit(prev, next, evs, seats),
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdemDeadline is when the clock must next act, or 0 for never. A clock is only
|
||||||
|
// ever set on a *present* human whose turn it is: a bot resolves inside the move
|
||||||
|
// and never waits, and an away human is auto-acted the moment the clock sees them
|
||||||
|
// rather than waited on. Between hands there is no per-seat clock at all — an
|
||||||
|
// abandoned table is the reaper's job, not the turn clock's.
|
||||||
|
func holdemDeadline(g holdem.State, seats []storage.Seat) int64 {
|
||||||
|
if g.Phase != holdem.PhaseBetting {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
seat := g.ToAct
|
||||||
|
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if seatAway(seats, seat) {
|
||||||
|
return 0 // an away human doesn't get waited on; the clock acts next tick
|
||||||
|
}
|
||||||
|
return time.Now().Unix() + turnSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdemAudit is the per-hand record of a finished hand — one row per human who
|
||||||
|
// was in it. Empty until a hand actually ends, which is exactly when the engine
|
||||||
|
// emits an "end" beat.
|
||||||
|
//
|
||||||
|
// The rake is the trap the plan flagged. game_hands.rake is summed into the
|
||||||
|
// house's income (HouseTake), so a pot's rake must be recorded once and once
|
||||||
|
// only. It rides on the winner's row alone; every other seat carries zero. And it
|
||||||
|
// is this hand's rake, not the session's: next.Paid is cumulative, so the hand's
|
||||||
|
// take is the amount it climbed by since prev — the part of *this* pot that came
|
||||||
|
// out of a human's winnings.
|
||||||
|
func holdemAudit(prev, next holdem.State, evs []holdem.Event, seats []storage.Seat) []storage.Hand {
|
||||||
|
if !handEnded(evs) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
handRake := next.Paid - prev.Paid
|
||||||
|
|
||||||
|
// The human who won the most is who the rake is attributed to: rake only ever
|
||||||
|
// comes out of a pot a human won, so when handRake is positive there is one.
|
||||||
|
winner, best := -1, int64(0)
|
||||||
|
for i := range next.Seats {
|
||||||
|
if next.Seats[i].Bot {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if next.Seats[i].Won > best {
|
||||||
|
winner, best = i, next.Seats[i].Won
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var audit []storage.Hand
|
||||||
|
for i := range next.Seats {
|
||||||
|
p := next.Seats[i]
|
||||||
|
if p.Bot || i >= len(seats) || seats[i].MatrixUser == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Total == 0 && p.Won == 0 {
|
||||||
|
continue // dealt out, or never in the hand — nothing to record
|
||||||
|
}
|
||||||
|
outcome := "lost"
|
||||||
|
switch {
|
||||||
|
case p.Won > p.Total:
|
||||||
|
outcome = "won"
|
||||||
|
case p.Won == p.Total:
|
||||||
|
outcome = "push"
|
||||||
|
}
|
||||||
|
rake := int64(0)
|
||||||
|
if i == winner {
|
||||||
|
rake = handRake
|
||||||
|
}
|
||||||
|
audit = append(audit, storage.Hand{
|
||||||
|
MatrixUser: seats[i].MatrixUser,
|
||||||
|
Game: gameHoldem,
|
||||||
|
Bet: p.Total,
|
||||||
|
Payout: p.Won,
|
||||||
|
Rake: rake,
|
||||||
|
Outcome: outcome,
|
||||||
|
Seed1: next.Seed1,
|
||||||
|
Seed2: next.Seed2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return audit
|
||||||
|
}
|
||||||
|
|
||||||
|
// handEnded reports whether a hand finished in this batch of events. endHand is
|
||||||
|
// the only thing that emits "end", and it emits exactly one, so this is the clean
|
||||||
|
// signal that Won and Total on the seats are this hand's final numbers.
|
||||||
|
func handEnded(evs []holdem.Event) bool {
|
||||||
|
for _, e := range evs {
|
||||||
|
if e.Kind == "end" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- seat bookkeeping ------------------------------------------------------
|
||||||
|
|
||||||
|
// seatAway reports whether the seat at an index is a human who has walked away.
|
||||||
|
func seatAway(seats []storage.Seat, seat int) bool {
|
||||||
|
return seat >= 0 && seat < len(seats) && seats[seat].Away
|
||||||
|
}
|
||||||
|
|
||||||
|
// markAway returns the one seat row the clock needs to write back: the timed-out
|
||||||
|
// seat, flagged away, with its last_seen left exactly as it was. Preserving
|
||||||
|
// last_seen is the whole point — the reaper measures abandonment from when a
|
||||||
|
// human last acted *for themselves*, and an auto-fold is not that.
|
||||||
|
func markAway(seats []storage.Seat, seat int) []storage.Seat {
|
||||||
|
if seat < 0 || seat >= len(seats) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := seats[seat]
|
||||||
|
s.Away = true
|
||||||
|
return []storage.Seat{s}
|
||||||
|
}
|
||||||
117
internal/web/games_hub.go
Normal file
117
internal/web/games_hub.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The SSE hub: how a move one player makes reaches the phones of everyone else
|
||||||
|
// at the felt.
|
||||||
|
//
|
||||||
|
// It is in-memory and it is intentionally dumb. It holds no game state and makes
|
||||||
|
// no decisions — it is a fan-out of opaque byte frames, keyed by table id. The
|
||||||
|
// authority is always the database; a frame is a nudge that says "the table at
|
||||||
|
// this version changed, come and look", and a subscriber that misses one (a
|
||||||
|
// dropped send, a reconnect) refetches the table, which is authoritative anyway.
|
||||||
|
// So a lost frame is a cosmetic hiccup, never a wrong balance.
|
||||||
|
//
|
||||||
|
// Two rules hold it together, and both are load-bearing:
|
||||||
|
//
|
||||||
|
// 1. **Sends are non-blocking.** A subscriber's channel is buffered, and a send
|
||||||
|
// that would block is dropped, not waited on. The publish happens under the
|
||||||
|
// table lock (which is what orders frames correctly for free), so a blocking
|
||||||
|
// send would hold that lock while one phone on a train stalls — and the turn
|
||||||
|
// clock behind that lock stalls with it, for the whole casino. A dropped frame
|
||||||
|
// costs that one subscriber a refetch; a held lock costs everyone the room.
|
||||||
|
//
|
||||||
|
// 2. **The publisher never touches the database.** The hub is reached only after
|
||||||
|
// the DB work is done and the connection released. Holding a *sql.Rows or a tx
|
||||||
|
// open for the life of a stream would hold the one connection in the pool
|
||||||
|
// forever, and a single subscriber would brick the whole application.
|
||||||
|
|
||||||
|
// hubFrame is what goes down the wire: an opaque payload the browser knows how to
|
||||||
|
// read (a JSON table view), tagged with the version it represents so a subscriber
|
||||||
|
// can tell a frame it already has from one it missed.
|
||||||
|
type hubFrame struct {
|
||||||
|
Version int64
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableSub is one open EventSource: a buffered channel and the id that lets the
|
||||||
|
// subscriber unregister itself when the stream closes.
|
||||||
|
type tableSub struct {
|
||||||
|
id int64
|
||||||
|
ch chan hubFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
// gamesHub fans table frames out to whoever is watching each table.
|
||||||
|
type gamesHub struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
tables map[string]map[int64]*tableSub
|
||||||
|
nextID atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGamesHub() *gamesHub {
|
||||||
|
return &gamesHub{tables: make(map[string]map[int64]*tableSub)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subChanBuffer is how many frames a slow subscriber can fall behind before the
|
||||||
|
// hub starts dropping theirs. A few is plenty: a subscriber that far behind is
|
||||||
|
// going to refetch the authoritative table anyway, so buffering more just delays
|
||||||
|
// that with staler frames.
|
||||||
|
const subChanBuffer = 8
|
||||||
|
|
||||||
|
// subscribe registers a new watcher of a table and returns its channel plus the
|
||||||
|
// unsubscribe to defer. The channel is buffered so a publish never blocks on a
|
||||||
|
// reader that is mid-write to its socket.
|
||||||
|
func (h *gamesHub) subscribe(tableID string) (<-chan hubFrame, func()) {
|
||||||
|
sub := &tableSub{id: h.nextID.Add(1), ch: make(chan hubFrame, subChanBuffer)}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
subs := h.tables[tableID]
|
||||||
|
if subs == nil {
|
||||||
|
subs = make(map[int64]*tableSub)
|
||||||
|
h.tables[tableID] = subs
|
||||||
|
}
|
||||||
|
subs[sub.id] = sub
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
return sub.ch, func() { h.unsubscribe(tableID, sub.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *gamesHub) unsubscribe(tableID string, id int64) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
subs := h.tables[tableID]
|
||||||
|
if subs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(subs, id)
|
||||||
|
if len(subs) == 0 {
|
||||||
|
delete(h.tables, tableID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publish pushes a frame to everyone watching a table, dropping it for any
|
||||||
|
// subscriber whose buffer is full rather than waiting on them. See rule 1: this
|
||||||
|
// is called under the table lock, so it must never block.
|
||||||
|
func (h *gamesHub) publish(tableID string, f hubFrame) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
for _, sub := range h.tables[tableID] {
|
||||||
|
select {
|
||||||
|
case sub.ch <- f:
|
||||||
|
default:
|
||||||
|
// Full buffer: this subscriber is behind. Dropping is correct — they will
|
||||||
|
// refetch the authoritative table when they next read a version gap.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchers reports how many streams are open on a table. Used by the caller that
|
||||||
|
// decides whether a frame is worth rendering at all.
|
||||||
|
func (h *gamesHub) watchers(tableID string) int {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
return len(h.tables[tableID])
|
||||||
|
}
|
||||||
94
internal/web/games_hub_test.go
Normal file
94
internal/web/games_hub_test.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHub_DeliversToSubscribers(t *testing.T) {
|
||||||
|
h := newGamesHub()
|
||||||
|
ch, done := h.subscribe("t1")
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
h.publish("t1", hubFrame{Version: 3, Data: []byte("hi")})
|
||||||
|
f := <-ch
|
||||||
|
if f.Version != 3 || string(f.Data) != "hi" {
|
||||||
|
t.Fatalf("got %+v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_OnlyToTheRightTable(t *testing.T) {
|
||||||
|
h := newGamesHub()
|
||||||
|
ch1, d1 := h.subscribe("t1")
|
||||||
|
defer d1()
|
||||||
|
ch2, d2 := h.subscribe("t2")
|
||||||
|
defer d2()
|
||||||
|
|
||||||
|
h.publish("t1", hubFrame{Version: 1})
|
||||||
|
select {
|
||||||
|
case <-ch2:
|
||||||
|
t.Fatal("t2 should not have received t1's frame")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if f := <-ch1; f.Version != 1 {
|
||||||
|
t.Fatalf("t1 got %+v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHub_PublishNeverBlocks is the load-bearing property: a subscriber that
|
||||||
|
// never reads must not be able to hold up a publish, because publish happens
|
||||||
|
// under the table lock and a blocked publish stalls the turn clock for everyone.
|
||||||
|
func TestHub_PublishNeverBlocks(t *testing.T) {
|
||||||
|
h := newGamesHub()
|
||||||
|
_, done := h.subscribe("t1") // never read from
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
// Far more than the buffer. If any of these blocked, the test would hang.
|
||||||
|
blocked := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < subChanBuffer*10; i++ {
|
||||||
|
h.publish("t1", hubFrame{Version: int64(i)})
|
||||||
|
}
|
||||||
|
close(blocked)
|
||||||
|
}()
|
||||||
|
<-blocked
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_UnsubscribeStopsDelivery(t *testing.T) {
|
||||||
|
h := newGamesHub()
|
||||||
|
ch, done := h.subscribe("t1")
|
||||||
|
done()
|
||||||
|
|
||||||
|
if h.watchers("t1") != 0 {
|
||||||
|
t.Fatalf("watchers should be 0 after unsubscribe, got %d", h.watchers("t1"))
|
||||||
|
}
|
||||||
|
h.publish("t1", hubFrame{Version: 1})
|
||||||
|
select {
|
||||||
|
case _, ok := <-ch:
|
||||||
|
if ok {
|
||||||
|
t.Fatal("a frame arrived after unsubscribe")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_ConcurrentSubscribers(t *testing.T) {
|
||||||
|
h := newGamesHub()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
ch, done := h.subscribe("t1")
|
||||||
|
defer done()
|
||||||
|
<-ch
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
// Let them all register, then flood so every one of them reads at least one.
|
||||||
|
for h.watchers("t1") < 50 {
|
||||||
|
}
|
||||||
|
for i := 0; i < subChanBuffer; i++ {
|
||||||
|
h.publish("t1", hubFrame{Version: int64(i)})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
58
internal/web/games_lock.go
Normal file
58
internal/web/games_lock.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"hash/fnv"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The striped table lock, and why it is only ever an optimisation.
|
||||||
|
//
|
||||||
|
// The database's version column is the real concurrency authority: every write
|
||||||
|
// to a table is conditional on the version the writer read, so two writers that
|
||||||
|
// race produce one winner and one ErrStaleTable no matter what happens in
|
||||||
|
// memory. This lock exists purely to make the loser lose *before* it does the
|
||||||
|
// work, rather than after — it serialises the read-modify-write on a table so the
|
||||||
|
// common case doesn't burn an engine step and a marshal only to be told it was
|
||||||
|
// stale.
|
||||||
|
//
|
||||||
|
// It is a fixed array hashed on table id, never a map you can delete from, and
|
||||||
|
// that is deliberate. A map of mutexes keyed by table id, cleaned up when a table
|
||||||
|
// empties, will hand two goroutines two different mutex objects for the same
|
||||||
|
// table across a delete-and-recreate — which is no lock at all. A fixed array has
|
||||||
|
// no lifecycle: the same id always hashes to the same mutex, forever. The only
|
||||||
|
// cost is that two unrelated tables can collide onto one stripe and briefly wait
|
||||||
|
// on each other, which is harmless.
|
||||||
|
//
|
||||||
|
// A redeploy is the case that proves the version column has to be the authority:
|
||||||
|
// during a drain two processes are running, each with its own array, so a table
|
||||||
|
// is "locked" by two mutexes that know nothing about each other. The version
|
||||||
|
// column is the only thing both processes share, and it is what keeps them
|
||||||
|
// correct while the mutexes are useless.
|
||||||
|
|
||||||
|
// lockStripes is how many mutexes the array holds. A power of two so the mask is
|
||||||
|
// clean; large enough that collisions between live tables are rare.
|
||||||
|
const lockStripes = 256
|
||||||
|
|
||||||
|
type stripedLocks struct {
|
||||||
|
m [lockStripes]sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStripedLocks() *stripedLocks { return &stripedLocks{} }
|
||||||
|
|
||||||
|
// forTable returns the mutex a given table hashes onto. The same id always
|
||||||
|
// returns the same mutex.
|
||||||
|
func (s *stripedLocks) forTable(id string) *sync.Mutex {
|
||||||
|
h := fnv.New32a()
|
||||||
|
_, _ = h.Write([]byte(id))
|
||||||
|
return &s.m[h.Sum32()&(lockStripes-1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// withTable runs fn while holding the table's stripe. The lock is released when
|
||||||
|
// fn returns — it never spans a network read or an SSE send, only the
|
||||||
|
// read-modify-write against the database.
|
||||||
|
func (s *stripedLocks) withTable(id string, fn func() error) error {
|
||||||
|
mu := s.forTable(id)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
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"
|
||||||
@@ -68,6 +69,11 @@ func roomAt(hour int) room {
|
|||||||
// 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)
|
||||||
@@ -117,11 +124,21 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart)
|
mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart)
|
||||||
mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer)
|
mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer)
|
||||||
|
|
||||||
mux.HandleFunc("POST /api/games/uno/start", s.handleUnoStart)
|
mux.HandleFunc("POST /api/games/uno/sit", s.handleUnoSit)
|
||||||
mux.HandleFunc("POST /api/games/uno/move", s.handleUnoMove)
|
mux.HandleFunc("POST /api/games/uno/move", s.handleUnoMove)
|
||||||
|
mux.HandleFunc("POST /api/games/uno/leave", s.handleUnoLeave)
|
||||||
|
mux.HandleFunc("GET /api/games/uno/tables", s.handleUnoLobby)
|
||||||
|
mux.HandleFunc("GET /api/games/uno/stream", s.handleTableStream)
|
||||||
|
mux.HandleFunc("GET /api/games/uno/chat", s.handleTableChat)
|
||||||
|
mux.HandleFunc("POST /api/games/uno/say", s.handleTableSay)
|
||||||
|
|
||||||
mux.HandleFunc("POST /api/games/holdem/sit", s.handleHoldemSit)
|
mux.HandleFunc("POST /api/games/holdem/sit", s.handleHoldemSit)
|
||||||
mux.HandleFunc("POST /api/games/holdem/move", s.handleHoldemMove)
|
mux.HandleFunc("POST /api/games/holdem/move", s.handleHoldemMove)
|
||||||
|
mux.HandleFunc("POST /api/games/holdem/leave", s.handleHoldemLeave)
|
||||||
|
mux.HandleFunc("GET /api/games/holdem/tables", s.handleHoldemLobby)
|
||||||
|
mux.HandleFunc("GET /api/games/holdem/stream", s.handleTableStream)
|
||||||
|
mux.HandleFunc("GET /api/games/holdem/chat", s.handleTableChat)
|
||||||
|
mux.HandleFunc("POST /api/games/holdem/say", s.handleTableSay)
|
||||||
}
|
}
|
||||||
|
|
||||||
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
|
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
|
||||||
@@ -140,10 +157,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 +207,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))
|
||||||
|
|||||||
@@ -92,34 +92,61 @@ func viewCard(c cards.Card) cardView {
|
|||||||
// 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"`
|
Bet int64 `json:"bet"` // everything staked this deal, across every hand
|
||||||
Player []cardView `json:"player"`
|
Hands []spotView `json:"hands"`
|
||||||
|
Active int `json:"active"`
|
||||||
Dealer []cardView `json:"dealer"`
|
Dealer []cardView `json:"dealer"`
|
||||||
Hole bool `json:"hole"` // true: the dealer has a face-down card
|
Hole bool `json:"hole"` // true: the dealer has a face-down card
|
||||||
Total int `json:"total"`
|
|
||||||
Soft bool `json:"soft"`
|
|
||||||
DTotal int `json:"dealer_total"` // what the *shown* dealer cards add to
|
DTotal int `json:"dealer_total"` // what the *shown* dealer cards add to
|
||||||
|
|
||||||
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"`
|
Rake int64 `json:"rake,omitempty"`
|
||||||
Net int64 `json:"net"`
|
Net int64 `json:"net"`
|
||||||
Double bool `json:"can_double"`
|
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"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Soft bool `json:"soft"`
|
||||||
|
Doubled bool `json:"doubled"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
Outcome string `json:"outcome,omitempty"`
|
||||||
|
Payout int64 `json:"payout,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -262,18 +290,68 @@ func (s *Server) table(user string) (tableView, error) {
|
|||||||
tv := viewTrivia(g, time.Now())
|
tv := viewTrivia(g, time.Now())
|
||||||
v.Trivia = &tv
|
v.Trivia = &tv
|
||||||
case gameUno:
|
case gameUno:
|
||||||
|
// A seated UNO player's cards are in game_tables, not here — this row is only
|
||||||
|
// their occupancy claim. Load the table and render it as their own seat sees it.
|
||||||
|
if live.TableID == "" {
|
||||||
|
return s.dropUnreadable(user, v, fmt.Errorf("uno row with no table"))
|
||||||
|
}
|
||||||
|
t, tableSeats, err := storage.LoadTable(live.TableID)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||||
|
return s.dropUnreadable(user, v, fmt.Errorf("uno table %s gone", live.TableID))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return tableView{}, err
|
||||||
|
}
|
||||||
|
_, seat, err := storage.PlayerSeat(user)
|
||||||
|
if err != nil {
|
||||||
|
return tableView{}, err
|
||||||
|
}
|
||||||
var g uno.State
|
var g uno.State
|
||||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||||
return s.dropUnreadable(user, v, err)
|
return s.dropUnreadable(user, v, err)
|
||||||
}
|
}
|
||||||
uv := viewUno(g)
|
uv := viewUno(g, seat)
|
||||||
|
for _, ts := range tableSeats {
|
||||||
|
if ts.Seat == seat {
|
||||||
|
uv.BoughtIn = ts.Staked
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
v.Uno = &uv
|
v.Uno = &uv
|
||||||
case gameHoldem:
|
case gameHoldem:
|
||||||
|
// A seated hold'em player's cards are in game_tables, not here — this row is
|
||||||
|
// only their occupancy claim, so its state is empty. Load the table and render
|
||||||
|
// it as their own seat sees it.
|
||||||
|
if live.TableID == "" {
|
||||||
|
return s.dropUnreadable(user, v, fmt.Errorf("holdem row with no table"))
|
||||||
|
}
|
||||||
|
t, tableSeats, err := storage.LoadTable(live.TableID)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||||
|
// The table closed under them (reaped, or the last hand cashed them out).
|
||||||
|
// Their claim is stale; clear it so they can sit down again.
|
||||||
|
return s.dropUnreadable(user, v, fmt.Errorf("holdem table %s gone", live.TableID))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return tableView{}, err
|
||||||
|
}
|
||||||
|
_, seat, err := storage.PlayerSeat(user)
|
||||||
|
if err != nil {
|
||||||
|
return tableView{}, err
|
||||||
|
}
|
||||||
var g holdem.State
|
var g holdem.State
|
||||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||||
return s.dropUnreadable(user, v, err)
|
return s.dropUnreadable(user, v, err)
|
||||||
}
|
}
|
||||||
hv := viewHoldem(g)
|
hv := viewHoldem(g, seat)
|
||||||
|
// bought_in is a per-player figure — "you bought in for X" — but the engine's
|
||||||
|
// BoughtIn is the table's total across every human. The player's own stake is
|
||||||
|
// border accounting, which lives in storage; take it from their seat row.
|
||||||
|
for _, ts := range tableSeats {
|
||||||
|
if ts.Seat == seat {
|
||||||
|
hv.BoughtIn = ts.Staked
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
v.Holdem = &hv
|
v.Holdem = &hv
|
||||||
default:
|
default:
|
||||||
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
|
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
|
||||||
@@ -492,31 +570,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
|
||||||
@@ -551,57 +643,43 @@ type finished struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// commit writes a game back and settles it if it's over. It is the money path,
|
// commit writes a game back and settles it if it's over. It is the money path,
|
||||||
// and both games go through it so that neither has to re-derive an ordering
|
// and every game goes through it so that none has to re-derive an ordering that
|
||||||
// that took a while to get right.
|
// took a while to get right.
|
||||||
|
//
|
||||||
|
// The ordering now lives in storage.CommitHand, which does the whole thing —
|
||||||
|
// seat, pay, record, clear, touch — in one transaction. It used to be four
|
||||||
|
// autocommit statements here, carefully sequenced so that a crash between them
|
||||||
|
// cost the player as little as possible. That was survivable for a game owned by
|
||||||
|
// one player. It is not survivable for a game with a pot in it, which is what
|
||||||
|
// the tables are about to become: pay the winner, die before the state write,
|
||||||
|
// and the hand still reads as live, so it settles again and pays them twice.
|
||||||
//
|
//
|
||||||
// It returns the table as it now stands. ok is false when it has already
|
// It returns the table as it now stands. ok is false when it has already
|
||||||
// written an error response and the caller must simply return.
|
// written an error response and the caller must simply return.
|
||||||
func (s *Server) commit(w http.ResponseWriter, user string, f finished) (tableView, bool) {
|
func (s *Server) commit(w http.ResponseWriter, user string, f finished) (tableView, bool) {
|
||||||
// Seat the game before doing anything else with it — even one that is already
|
err := storage.CommitHand(user, storage.Commit{
|
||||||
// over, because a blackjack natural settles the instant it's dealt. The insert
|
Live: storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2},
|
||||||
// is what enforces one game at a time, and it has to happen for *every* new
|
Fresh: f.Fresh,
|
||||||
// one: a natural dealt on top of a game already in progress would otherwise
|
Stake: f.Bet,
|
||||||
// settle, clear the felt, and take the other game's stake down with it.
|
Done: f.Done,
|
||||||
live := storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2}
|
Payout: f.Payout,
|
||||||
save := storage.SaveLiveHand
|
Audit: storage.Hand{
|
||||||
if f.Fresh {
|
|
||||||
save = storage.StartLiveHand
|
|
||||||
}
|
|
||||||
if err := save(user, live); err != nil {
|
|
||||||
if errors.Is(err, storage.ErrHandInProgress) {
|
|
||||||
// Somebody was already sitting here. This game was never seated, so the
|
|
||||||
// chips it staked go back: the player is in one game, not two.
|
|
||||||
_ = storage.Award(user, f.Bet)
|
|
||||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"})
|
|
||||||
return tableView{}, false
|
|
||||||
}
|
|
||||||
slog.Error("games: save game", "user", user, "game", f.Game, "err", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return tableView{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if f.Done {
|
|
||||||
// Pay first, then clear. If Pete dies between the two, the player has been
|
|
||||||
// paid and the worst case is a settled game still showing on the felt —
|
|
||||||
// which reads as done and can be cleared. The other order loses them a win.
|
|
||||||
if err := storage.Award(user, f.Payout); err != nil {
|
|
||||||
slog.Error("games: award", "user", user, "payout", f.Payout, "err", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return tableView{}, false
|
|
||||||
}
|
|
||||||
if err := storage.RecordHand(storage.Hand{
|
|
||||||
MatrixUser: user, Game: f.Game,
|
MatrixUser: user, Game: f.Game,
|
||||||
Bet: f.Bet, Payout: f.Payout, Rake: f.Rake,
|
Bet: f.Bet, Payout: f.Payout, Rake: f.Rake,
|
||||||
Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2,
|
Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2,
|
||||||
}); err != nil {
|
},
|
||||||
slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's game
|
})
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrHandInProgress):
|
||||||
|
// Somebody was already sitting here. The game was never seated and the chips
|
||||||
|
// it staked have gone back — in the same transaction that refused it.
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"})
|
||||||
|
return tableView{}, false
|
||||||
|
case err != nil:
|
||||||
|
slog.Error("games: commit", "user", user, "game", f.Game, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return tableView{}, false
|
||||||
}
|
}
|
||||||
if err := storage.ClearLiveHand(user); err != nil {
|
|
||||||
slog.Error("games: clear game", "user", user, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
storage.Touch(user)
|
|
||||||
|
|
||||||
v, err := s.table(user)
|
v, err := s.table(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
89
internal/web/games_runtime.go
Normal file
89
internal/web/games_runtime.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errNotDue is a timeout that turned out to have nothing to do. The clock scanned
|
||||||
|
// a table as expired, took the lock, and found — on decoding the state — that the
|
||||||
|
// seat to act is not in fact a waiting human: a real move landed in the instant
|
||||||
|
// between the scan and the lock and had not yet bumped the version the clock
|
||||||
|
// checked. It is not an error, it is the race resolving the right way, so the
|
||||||
|
// clock swallows it silently rather than logging.
|
||||||
|
var errNotDue = errors.New("games: nothing to time out")
|
||||||
|
|
||||||
|
// The table runtime: the game-agnostic half of a shared table.
|
||||||
|
//
|
||||||
|
// A shared table has two writers where a solo game had one — an HTTP move, and a
|
||||||
|
// turn clock acting for whoever walked away — and the whole job of this layer is
|
||||||
|
// to let those two coexist without either trusting the other. The rule that makes
|
||||||
|
// that work is the database's version column: every write is conditional on the
|
||||||
|
// version its writer read, so the two can race freely and exactly one wins.
|
||||||
|
//
|
||||||
|
// Everything specific to a game — how a move advances it, whose turn it is now,
|
||||||
|
// what a settled hand pays — lives behind the tableGame interface. The clock, the
|
||||||
|
// lock discipline and the SSE publish do not know whether they are driving poker
|
||||||
|
// or UNO, and that is what lets Phase B ship before any engine is multiway.
|
||||||
|
|
||||||
|
// turnSeconds is how long a human has to act before the clock acts for them. Long
|
||||||
|
// enough to read the table and think, short enough that a walked-away player does
|
||||||
|
// not hold three others hostage.
|
||||||
|
const turnSeconds = 30
|
||||||
|
|
||||||
|
// bootGrace is how far the turn clock shoves every live deadline out on boot. A
|
||||||
|
// deploy takes the in-memory clock with it, so without this the first tick after
|
||||||
|
// a restart would find every deadline in the casino already past and auto-act the
|
||||||
|
// whole room at once.
|
||||||
|
const bootGrace = 30
|
||||||
|
|
||||||
|
// step is what a game hands back after a move or a timeout: the new state, ready
|
||||||
|
// to persist, plus everything the runtime needs to settle and to schedule.
|
||||||
|
//
|
||||||
|
// The chips are inside State — a hand ending moves the pot within the blob and
|
||||||
|
// credits nobody — so there is no payout field. What comes out is the state, the
|
||||||
|
// audit of any hand that just ended, and the clock's next deadline.
|
||||||
|
type step struct {
|
||||||
|
// State is the engine's whole state, marshalled, ready for the table blob.
|
||||||
|
State []byte
|
||||||
|
// Phase is lifted out of the state so the lobby can read it without decoding.
|
||||||
|
Phase string
|
||||||
|
// HandNo is the hand this state is on. It advances when a new hand is dealt,
|
||||||
|
// and it is the audit key now that a seed no longer reproduces a shared hand.
|
||||||
|
HandNo int64
|
||||||
|
// Deadline is when the clock must next act, or 0 for none. It is nonzero only
|
||||||
|
// when the turn has landed on a *present* human: a bot resolves inside the move
|
||||||
|
// and an away human is auto-acted on sight, so neither is ever waited for.
|
||||||
|
Deadline int64
|
||||||
|
// Audit is the per-seat record of a hand that ended in this step. Empty if no
|
||||||
|
// hand settled.
|
||||||
|
Audit []storage.Hand
|
||||||
|
// Events is the script the felt plays back — the same shape every solo game
|
||||||
|
// already returns. It is what the SSE frame and the acting player both animate.
|
||||||
|
Events any
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableGame is everything the runtime needs from an engine to run it at a shared
|
||||||
|
// table. Each multiplayer game implements it; the clock and the handlers hold it
|
||||||
|
// as an interface so they stay game-agnostic.
|
||||||
|
type tableGame interface {
|
||||||
|
// name is the storage key and the lobby label: "holdem", "uno", "blackjack".
|
||||||
|
name() string
|
||||||
|
|
||||||
|
// timeout acts for the human whose clock has expired — check if the rules
|
||||||
|
// allow it, fold otherwise — and advances the table as far as the next
|
||||||
|
// decision, exactly as a real move would. seats is the current roster, so the
|
||||||
|
// engine can mark the timed-out player away.
|
||||||
|
//
|
||||||
|
// It returns ErrNotDue (via a nil step, see runClockTable) if, on decode, the
|
||||||
|
// seat to act is not in fact a waiting human — which happens when a real move
|
||||||
|
// landed in the same instant the clock fired and the version had not yet been
|
||||||
|
// bumped when the clock scanned.
|
||||||
|
timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error)
|
||||||
|
|
||||||
|
// stacks reports the chips in front of each seat, index-aligned with the
|
||||||
|
// table's seat rows, so the abandoned-table reaper can cash out a walked-away
|
||||||
|
// player without knowing how their game is played.
|
||||||
|
stacks(state []byte) ([]int64, error)
|
||||||
|
}
|
||||||
299
internal/web/games_table.go
Normal file
299
internal/web/games_table.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The runtime's public surface: the lobby a player finds a table in, the stream
|
||||||
|
// that keeps their felt live while other people play on it, and the chat that
|
||||||
|
// runs along the rail. None of this knows poker from UNO — it is keyed on table
|
||||||
|
// id and moves opaque frames — which is what lets it serve every shared table.
|
||||||
|
|
||||||
|
// ---- the lobby -------------------------------------------------------------
|
||||||
|
|
||||||
|
// handleHoldemLobby and handleUnoLobby list the tables of their game with a seat
|
||||||
|
// going spare. A table with every chair taken is not shown, because a lobby you
|
||||||
|
// cannot join from is just a list; bots keep every open table populated, so there
|
||||||
|
// is always something to sit down at.
|
||||||
|
func (s *Server) handleHoldemLobby(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.lobby(w, r, gameHoldem)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUnoLobby(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.lobby(w, r, gameUno)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) lobby(w http.ResponseWriter, r *http.Request, game string) {
|
||||||
|
if _, ok := s.player(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tables, err := storage.LobbyTables(game, 50)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: lobby", "game", game, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
open := make([]storage.TableSummary, 0, len(tables))
|
||||||
|
for _, t := range tables {
|
||||||
|
if t.Humans < t.Seats {
|
||||||
|
open = append(open, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"tables": open})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the live stream -------------------------------------------------------
|
||||||
|
|
||||||
|
// streamPing is how often the stream sends a comment down an idle connection.
|
||||||
|
// EventSource reconnects itself, but a proxy will hang up a stream that has gone
|
||||||
|
// quiet, and a heartbeat well under the usual idle timeout keeps it open.
|
||||||
|
const streamPing = 25 * time.Second
|
||||||
|
|
||||||
|
// handleHoldemStream is the player's live view of their table: a Server-Sent
|
||||||
|
// Events stream that carries a nudge every time the table changes and every line
|
||||||
|
// of chat as it is said.
|
||||||
|
//
|
||||||
|
// It obeys the one rule that keeps a stream from bricking the app (rule from
|
||||||
|
// games_hub.go): it touches the database exactly once, at the top, to find which
|
||||||
|
// table the player is at. After that it only ever reads its channel. Holding a
|
||||||
|
// query open for the life of a stream would hold the single pooled connection for
|
||||||
|
// the life of a stream, and one idle subscriber would take the whole site down.
|
||||||
|
func (s *Server) handleTableStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tableID, err := storage.TableOf(user)
|
||||||
|
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: stream table of", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no") // tell nginx-likes not to buffer us
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
ch, unsubscribe := s.hub.subscribe(tableID)
|
||||||
|
defer unsubscribe()
|
||||||
|
|
||||||
|
// Nudge the client to fetch straight away, so a stream that opens after a change
|
||||||
|
// already missed does not sit blank until the next one.
|
||||||
|
fmt.Fprint(w, "event: sync\ndata: {}\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
ping := time.NewTicker(streamPing)
|
||||||
|
defer ping.Stop()
|
||||||
|
ctx := r.Context()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ping.C:
|
||||||
|
fmt.Fprint(w, ": ping\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
case f, open := <-ch:
|
||||||
|
if !open {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", f.Data)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- chat ------------------------------------------------------------------
|
||||||
|
|
||||||
|
// handleHoldemChat reads the recent rail of a player's table, oldest first, with
|
||||||
|
// their own lines flagged so the felt can lay them out on the right.
|
||||||
|
func (s *Server) handleTableChat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tableID, err := storage.TableOf(user)
|
||||||
|
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||||
|
writeJSON(w, map[string]any{"chat": []storage.ChatLine{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: chat table of", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lines, err := storage.Chat(tableID, 50)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: chat", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range lines {
|
||||||
|
lines[i].Mine = false // filled below; the DB does not know who is asking
|
||||||
|
}
|
||||||
|
name := s.displayName(r, user)
|
||||||
|
for i := range lines {
|
||||||
|
lines[i].Mine = lines[i].Name == name
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"chat": lines})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHoldemSay records a line of chat and fans it to the table. The line is
|
||||||
|
// stamped with the hand it was said during — the one question chat at a money
|
||||||
|
// table ever really raises — and pushed to every open stream so it lands on the
|
||||||
|
// rail in real time.
|
||||||
|
func (s *Server) handleTableSay(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tableID, err := storage.TableOf(user)
|
||||||
|
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: say table of", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := s.displayName(r, user)
|
||||||
|
line, err := storage.Say(tableID, user, name, req.Body)
|
||||||
|
if errors.Is(err, storage.ErrBadAmount) {
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "say something"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: say", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publishChat(tableID, line)
|
||||||
|
line.Mine = true
|
||||||
|
writeJSON(w, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishChat fans a chat line to everyone watching a table. Like publishTable it
|
||||||
|
// is a non-blocking send after the database work is done — the line is already
|
||||||
|
// saved, this only delivers it — so a slow subscriber costs itself a missed line
|
||||||
|
// (which its next chat fetch recovers), never the lock.
|
||||||
|
func (s *Server) publishChat(tableID string, line storage.ChatLine) {
|
||||||
|
if s.hub.watchers(tableID) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(map[string]any{"type": "chat", "line": line})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.hub.publish(tableID, hubFrame{Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the abandoned-table reaper --------------------------------------------
|
||||||
|
|
||||||
|
// runTableReaper cashes out tables that everyone has walked away from, on the
|
||||||
|
// same slow timer as the session reaper. It is the seated-player counterpart to
|
||||||
|
// that loop: a walked-away poker player's chips are inside a table blob, where
|
||||||
|
// the session reaper (which reads the game_chips stack) cannot see them, so
|
||||||
|
// without this they would sit in limbo until the player happened to come back.
|
||||||
|
func (s *Server) runTableReaper(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(reaperInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.reapAbandonedTables()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reapAbandonedTables finds the tables nobody is coming back to and closes each,
|
||||||
|
// sending every seated human home with whatever is in front of them.
|
||||||
|
func (s *Server) reapAbandonedTables() {
|
||||||
|
cutoff := time.Now().Unix() - int64(storage.SessionIdleAfter.Seconds())
|
||||||
|
refs, err := storage.AbandonedTables(cutoff)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: abandoned tables", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ref := range refs {
|
||||||
|
s.reapTable(ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reapTable cashes out and closes one abandoned table, under its lock and only if
|
||||||
|
// it is still the version the scan saw — so a player who wandered back to the
|
||||||
|
// felt in the same instant keeps their seat and their chips.
|
||||||
|
func (s *Server) reapTable(ref storage.TableRef) {
|
||||||
|
err := s.tableLocks.withTable(ref.ID, func() error {
|
||||||
|
t, seats, err := storage.LoadTable(ref.ID)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if t.Version != ref.Version {
|
||||||
|
return nil // somebody came back between the scan and here
|
||||||
|
}
|
||||||
|
game := s.games()[t.Game]
|
||||||
|
if game == nil {
|
||||||
|
slog.Error("games: reaper over unknown game", "game", t.Game, "table", t.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stacks, err := game.stacks(t.State)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var humans []storage.ReapSeat
|
||||||
|
for _, seat := range seats {
|
||||||
|
if seat.MatrixUser == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
amount := int64(0)
|
||||||
|
if seat.Seat >= 0 && seat.Seat < len(stacks) {
|
||||||
|
amount = stacks[seat.Seat]
|
||||||
|
}
|
||||||
|
humans = append(humans, storage.ReapSeat{
|
||||||
|
Seat: seat.Seat, MatrixUser: seat.MatrixUser, Amount: amount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := storage.ReapTable(storage.Reap{TableID: t.ID, Version: t.Version, Humans: humans}); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrStaleTable) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("games: reaped abandoned table", "table", t.ID, "humans", len(humans))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: reap table", "table", ref.ID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,57 +5,58 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"pete/internal/games/blackjack"
|
"pete/internal/games/blackjack"
|
||||||
"pete/internal/games/uno"
|
"pete/internal/games/uno"
|
||||||
"pete/internal/storage"
|
"pete/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UNO, played for chips against bots.
|
// UNO, played for chips at a shared table.
|
||||||
//
|
//
|
||||||
// The seam is the same as every other table, but there is one thing here that no
|
// Like hold'em, this is a session: you sit down with a stack, ante into a pot each
|
||||||
// other table has: opponents. The obvious way to give a browser opponents is a
|
// hand, and leave with what is in front of you. Chips cross the border twice —
|
||||||
// socket, and the plan says solo UNO must not need one — so it doesn't. A move
|
// sit-down and get-up — and every ante and pot in between moves inside the state
|
||||||
// goes up, and what comes back is the player's move *plus every bot turn it
|
// blob. Solo play is just a table nobody else has joined.
|
||||||
// handed off to*, as a script of events. One request, one round of the table.
|
|
||||||
//
|
//
|
||||||
// What the browser is allowed to see: its own hand, the card in play, the colour
|
// The seam is the same as hold'em: one request plays a human's move plus every
|
||||||
// in play, and how many cards each bot is holding. Not the deck, not a bot's
|
// bot turn it hands off to, returned as a script the felt animates. What a viewer
|
||||||
// hand, not even the face of a card a bot drew. That last one is most of the
|
// is allowed to see is their own hand, the card and colour in play, the pot, and
|
||||||
// deck, and it is the thing that would turn a game of counting cards into a game
|
// how many cards each other seat holds — never the deck, another seat's hand, or
|
||||||
// of reading the network tab.
|
// the face of a card a bot drew. The engine emits every seat's hand (a shared
|
||||||
|
// stream cannot know who is watching); the redaction that keeps a hand private is
|
||||||
|
// here, and a missed case fans it to every subscriber.
|
||||||
|
|
||||||
// unoCardView is one card, ready to draw. The browser gets the colour and the
|
// unoCardView is one card, ready to draw: colour and face as words, not the
|
||||||
// face as words, not as the engine's integers — the same bargain the blackjack
|
// engine's integers.
|
||||||
// table makes, and for the same reason: the browser draws faces, not logic.
|
|
||||||
type unoCardView struct {
|
type unoCardView struct {
|
||||||
Color string `json:"color"` // "red" | "blue" | "yellow" | "green" | "wild"
|
Color string `json:"color"`
|
||||||
Value string `json:"value"` // "0"…"9" | "skip" | "reverse" | "+2" | "wild" | "+4"
|
Value string `json:"value"`
|
||||||
Wild bool `json:"wild"` // it's a wild, whatever colour it was played as
|
Wild bool `json:"wild"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func viewUnoCard(c uno.Card) unoCardView {
|
func viewUnoCard(c uno.Card) unoCardView {
|
||||||
return unoCardView{
|
return unoCardView{Color: c.Color.String(), Value: c.Value.String(), Wild: c.IsWild()}
|
||||||
Color: c.Color.String(),
|
|
||||||
Value: c.Value.String(),
|
|
||||||
Wild: c.IsWild(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// unoSeatView is one seat at the table: a name, and a number of cards. A bot's
|
// unoSeatView is one seat at the table: a name, a card count, and a stack. A
|
||||||
// cards are a *count*. There is no field here for what they are.
|
// seat's cards are a *count* — there is no field here for what they are.
|
||||||
type unoSeatView struct {
|
type unoSeatView struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Cards int `json:"cards"`
|
Bot bool `json:"bot"`
|
||||||
You bool `json:"you"`
|
You bool `json:"you"`
|
||||||
|
Cards int `json:"cards"`
|
||||||
|
Stack int64 `json:"stack"`
|
||||||
|
Ante int64 `json:"ante,omitempty"`
|
||||||
Uno bool `json:"uno"` // down to one card
|
Uno bool `json:"uno"` // down to one card
|
||||||
Called bool `json:"called"` // …and said so. A seat where Uno is true and this isn't is a seat you can catch
|
Called bool `json:"called"` // …and said so. Uno true and this false is a seat you can catch
|
||||||
Out bool `json:"out"` // No Mercy: buried at twenty-five, and not coming back
|
Out bool `json:"out"` // not in this hand — mercy-killed, or sitting one out
|
||||||
}
|
}
|
||||||
|
|
||||||
// unoView is a game as its player may see it.
|
// unoView is a table as one seat may see it.
|
||||||
type unoView struct {
|
type unoView struct {
|
||||||
Tier uno.Tier `json:"tier"`
|
Tier uno.Tier `json:"tier"`
|
||||||
|
YourSeat int `json:"your_seat"`
|
||||||
Seats []unoSeatView `json:"seats"`
|
Seats []unoSeatView `json:"seats"`
|
||||||
|
|
||||||
Hand []unoCardView `json:"hand"` // yours, and only yours
|
Hand []unoCardView `json:"hand"` // yours, and only yours
|
||||||
@@ -64,138 +65,138 @@ type unoView struct {
|
|||||||
Color string `json:"color"` // the colour in play, which a wild renames
|
Color string `json:"color"` // the colour in play, which a wild renames
|
||||||
Deck int `json:"deck"` // cards left to draw
|
Deck int `json:"deck"` // cards left to draw
|
||||||
|
|
||||||
// The UNO call. UnoAt is which of your cards would leave you holding exactly
|
UnoAt []int `json:"uno_at"` // your cards that would leave you on one
|
||||||
// one if you played it — the table asks for the call on those, and it asks the
|
Catchable []int `json:"catchable"` // seats sitting on one card they never called
|
||||||
// engine which they are because No Mercy's "discard all" makes counting the
|
|
||||||
// hand the wrong answer. Catchable is which seats are sitting on one card they
|
|
||||||
// never announced.
|
|
||||||
UnoAt []int `json:"uno_at"`
|
|
||||||
Catchable []int `json:"catchable"`
|
|
||||||
|
|
||||||
Turn int `json:"turn"`
|
Turn int `json:"turn"`
|
||||||
Dir int `json:"dir"`
|
Dir int `json:"dir"`
|
||||||
|
Dealer int `json:"dealer"`
|
||||||
|
HandNo int `json:"hand_no"`
|
||||||
|
|
||||||
// No Mercy: the bill a stack of draw cards has run up. Whoever stops stacking
|
Pending int `json:"pending"` // No Mercy: the bill a stack has run up
|
||||||
// pays it, and while it stands it is the only thing on the table that matters —
|
|
||||||
// so the felt has to be able to say what it is.
|
|
||||||
Pending int `json:"pending"`
|
|
||||||
|
|
||||||
Bet int64 `json:"bet"`
|
Pot int64 `json:"pot"` // the antes riding on this hand
|
||||||
Pays int64 `json:"pays"` // what going out right now would actually pay
|
Ante int64 `json:"ante"` // what each seat puts in
|
||||||
|
Stack int64 `json:"stack"` // what's in front of you
|
||||||
|
BoughtIn int64 `json:"bought_in"` // your own buy-in, from the border ledger
|
||||||
Phase string `json:"phase"`
|
Phase string `json:"phase"`
|
||||||
Outcome string `json:"outcome,omitempty"`
|
|
||||||
|
// The last hand's verdict, so the felt can land it between hands.
|
||||||
Winner int `json:"winner"`
|
Winner int `json:"winner"`
|
||||||
Payout int64 `json:"payout,omitempty"`
|
LastPot int64 `json:"last_pot,omitempty"`
|
||||||
Rake int64 `json:"rake,omitempty"`
|
Rake int64 `json:"rake,omitempty"`
|
||||||
Net int64 `json:"net"`
|
Outcome string `json:"outcome,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func viewUno(g uno.State) unoView {
|
// viewUno renders the table as one seat may see it. viewer is which seat is
|
||||||
|
// looking — their hand is the only one it will ever put in the payload.
|
||||||
|
//
|
||||||
|
// This is the security boundary. The same view fans to every subscriber's stream,
|
||||||
|
// so a seat that renders anyone else's cards fans them to the whole table.
|
||||||
|
// TestUnoViewNeverLeaksAnotherSeatsCards renders every seat's view and greps for
|
||||||
|
// cards that are not theirs.
|
||||||
|
func viewUno(g uno.State, viewer int) unoView {
|
||||||
v := unoView{
|
v := unoView{
|
||||||
Tier: g.Tier,
|
Tier: g.Tier,
|
||||||
|
YourSeat: viewer,
|
||||||
Top: viewUnoCard(g.Top()),
|
Top: viewUnoCard(g.Top()),
|
||||||
Color: g.Color.String(),
|
Color: g.Color.String(),
|
||||||
Deck: g.Left(),
|
Deck: g.Left(),
|
||||||
Turn: g.Turn,
|
Turn: g.Turn,
|
||||||
Dir: g.Dir,
|
Dir: g.Dir,
|
||||||
|
Dealer: g.Dealer,
|
||||||
|
HandNo: g.HandNo,
|
||||||
Pending: g.Pending,
|
Pending: g.Pending,
|
||||||
Bet: g.Bet,
|
Pot: g.Pot,
|
||||||
Pays: g.Pays(),
|
Ante: g.Tier.Ante,
|
||||||
|
BoughtIn: g.BoughtIn, // a table total; the caller overrides it with this seat's own stake
|
||||||
Phase: string(g.Phase),
|
Phase: string(g.Phase),
|
||||||
Outcome: string(g.Outcome),
|
Winner: g.Winner,
|
||||||
Winner: -1,
|
LastPot: g.LastPot,
|
||||||
Payout: g.Payout,
|
|
||||||
Rake: g.Rake,
|
Rake: g.Rake,
|
||||||
Net: g.Net(),
|
Outcome: string(g.Outcome),
|
||||||
}
|
}
|
||||||
// An empty hand is a seat that went out — *unless* the mercy rule took it, in
|
if viewer >= 0 && viewer < len(g.Seats) {
|
||||||
// which case an empty hand is a grave. Those two look identical from the count
|
v.Stack = g.Seats[viewer].Stack
|
||||||
// alone, which is why a buried seat is asked about rather than inferred.
|
}
|
||||||
for i, n := range g.Counts() {
|
counts := g.Counts()
|
||||||
|
for i := range g.Seats {
|
||||||
|
p := g.Seats[i]
|
||||||
live := g.Live(i)
|
live := g.Live(i)
|
||||||
seat := unoSeatView{
|
seat := unoSeatView{
|
||||||
Cards: n, You: i == uno.You, Uno: live && n == 1, Out: !live,
|
Name: p.Name,
|
||||||
|
Bot: p.Bot,
|
||||||
|
You: i == viewer,
|
||||||
|
Cards: counts[i],
|
||||||
|
Stack: p.Stack,
|
||||||
|
Ante: p.Ante,
|
||||||
|
Uno: live && counts[i] == 1,
|
||||||
Called: i < len(g.Called) && g.Called[i],
|
Called: i < len(g.Called) && g.Called[i],
|
||||||
}
|
Out: !live,
|
||||||
if i == uno.You {
|
|
||||||
seat.Name = "You"
|
|
||||||
} else if i-1 < len(g.Bots) {
|
|
||||||
seat.Name = g.Bots[i-1]
|
|
||||||
}
|
}
|
||||||
v.Seats = append(v.Seats, seat)
|
v.Seats = append(v.Seats, seat)
|
||||||
if live && n == 0 {
|
|
||||||
v.Winner = i
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// And you can win a No Mercy table without ever going out: outlive it, and the
|
// The wall. Only the viewer's own hand crosses the wire.
|
||||||
// last seat standing is you, with a hand still in it.
|
if viewer >= 0 && viewer < len(g.Hands) {
|
||||||
if v.Winner < 0 && g.Outcome.Won() {
|
for _, c := range g.Hands[viewer] {
|
||||||
v.Winner = uno.You
|
|
||||||
}
|
|
||||||
for _, c := range g.Hands[uno.You] {
|
|
||||||
v.Hand = append(v.Hand, viewUnoCard(c))
|
v.Hand = append(v.Hand, viewUnoCard(c))
|
||||||
}
|
}
|
||||||
v.Playable = g.Playable()
|
}
|
||||||
|
if v.Hand == nil {
|
||||||
|
v.Hand = []unoCardView{}
|
||||||
|
}
|
||||||
|
// Empty arrays, never null: the felt indexes into these.
|
||||||
|
v.Playable = g.Playable(viewer)
|
||||||
if v.Playable == nil {
|
if v.Playable == nil {
|
||||||
v.Playable = []int{}
|
v.Playable = []int{}
|
||||||
}
|
}
|
||||||
// Empty arrays, never null: the table indexes into these, and `null.indexOf`
|
v.UnoAt = g.UnoAt(viewer)
|
||||||
// is a broken game rather than a quiet no-op.
|
|
||||||
v.UnoAt = g.UnoAt()
|
|
||||||
if v.UnoAt == nil {
|
if v.UnoAt == nil {
|
||||||
v.UnoAt = []int{}
|
v.UnoAt = []int{}
|
||||||
}
|
}
|
||||||
v.Catchable = g.Catchable()
|
v.Catchable = g.Catchable(viewer)
|
||||||
if v.Catchable == nil {
|
if v.Catchable == nil {
|
||||||
v.Catchable = []int{}
|
v.Catchable = []int{}
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// unoEventView is one beat of the script the table plays back: a card going
|
// unoEventView is one beat of the script the felt plays back. The engine attaches
|
||||||
// down, a seat eating a +4, the turn coming round. The engine's own events carry
|
// every seat's hand and drawn cards; this is the wall where the ones the viewer
|
||||||
// engine types, so they are re-rendered here rather than shipped raw — and this
|
// isn't entitled to are stripped.
|
||||||
// is also the wall where a bot's drawn card is dropped on the floor.
|
|
||||||
type unoEventView struct {
|
type unoEventView struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Seat int `json:"seat"`
|
Seat int `json:"seat"`
|
||||||
Card *unoCardView `json:"card,omitempty"`
|
Card *unoCardView `json:"card,omitempty"`
|
||||||
Color string `json:"color,omitempty"`
|
Color string `json:"color,omitempty"`
|
||||||
N int `json:"n,omitempty"`
|
N int `json:"n,omitempty"`
|
||||||
Left int `json:"left"` // never omitempty: a seat that goes out leaves zero, and zero is the number the table has to draw
|
Left int `json:"left"`
|
||||||
By int `json:"by"` // who did the catching. Seat zero is you, and "you" is a real answer, so never omitempty either
|
By int `json:"by"`
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
|
|
||||||
// Hand is your hand as it stands after this event, on the events that changed
|
|
||||||
// it. The table redraws your fan from this as the lap plays back — see the
|
|
||||||
// engine's Event.Hand for why. It is your own hand, so there is nothing here
|
|
||||||
// the browser wasn't already holding.
|
|
||||||
Hand []unoCardView `json:"hand,omitempty"`
|
Hand []unoCardView `json:"hand,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func viewUnoEvents(evs []uno.Event) []unoEventView {
|
func viewUnoEvents(evs []uno.Event, viewer int) []unoEventView {
|
||||||
out := make([]unoEventView, 0, len(evs))
|
out := make([]unoEventView, 0, len(evs))
|
||||||
for _, e := range evs {
|
for _, e := range evs {
|
||||||
v := unoEventView{Kind: e.Kind, Seat: e.Seat, N: e.N, Left: e.Left, By: e.By, Text: e.Text}
|
v := unoEventView{Kind: e.Kind, Seat: e.Seat, N: e.N, Left: e.Left, By: e.By, Text: e.Text}
|
||||||
if e.Color != uno.Wild {
|
if e.Color != uno.Wild {
|
||||||
v.Color = e.Color.String()
|
v.Color = e.Color.String()
|
||||||
}
|
}
|
||||||
// The engine only stamps a hand on an event about seat zero. This is the belt
|
// A hand rides an event only if it is the viewer's own. The engine stamps every
|
||||||
// to that brace: whatever the engine thinks it's doing, no hand but yours
|
// seat's hand; this strips the rest.
|
||||||
// leaves this building.
|
if e.Seat == viewer && e.Hand != nil {
|
||||||
if e.Seat == uno.You && e.Hand != nil {
|
|
||||||
v.Hand = make([]unoCardView, 0, len(e.Hand))
|
v.Hand = make([]unoCardView, 0, len(e.Hand))
|
||||||
for _, c := range e.Hand {
|
for _, c := range e.Hand {
|
||||||
v.Hand = append(v.Hand, viewUnoCard(c))
|
v.Hand = append(v.Hand, viewUnoCard(c))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A card rides an event only if it is a card played face up, or one the viewer
|
||||||
|
// drew. A bot's (or another human's) drawn card never carries a face.
|
||||||
if e.Card != nil {
|
if e.Card != nil {
|
||||||
// The engine only ever attaches a card to an event the seat is entitled
|
|
||||||
// to see it in — a card played face up, or one *you* drew. This check is
|
|
||||||
// the belt to that pair of braces: a bot's draw never carries a face,
|
|
||||||
// whatever the engine thinks it's doing.
|
|
||||||
if e.Kind == uno.EvDraw || e.Kind == uno.EvForced {
|
if e.Kind == uno.EvDraw || e.Kind == uno.EvForced {
|
||||||
if e.Seat == uno.You {
|
if e.Seat == viewer {
|
||||||
c := viewUnoCard(*e.Card)
|
c := viewUnoCard(*e.Card)
|
||||||
v.Card = &c
|
v.Card = &c
|
||||||
}
|
}
|
||||||
@@ -209,52 +210,227 @@ func viewUnoEvents(evs []uno.Event) []unoEventView {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUnoStart takes the bet and deals. Same order as every other table: the
|
// ---- sitting down ----------------------------------------------------------
|
||||||
// chips are staked first, in the same statement that checks they exist, so two
|
|
||||||
// deals fired at once cannot bet the same chip.
|
// unoSeatRows mirrors the engine's seats into the storage rows that shadow them,
|
||||||
func (s *Server) handleUnoStart(w http.ResponseWriter, r *http.Request) {
|
// index for index. A human's staked is the buy-in that crossed the border; a
|
||||||
|
// bot's is zero.
|
||||||
|
func unoSeatRows(g uno.State, human string, buyIn int64) []storage.Seat {
|
||||||
|
rows := make([]storage.Seat, len(g.Seats))
|
||||||
|
for i := range g.Seats {
|
||||||
|
p := g.Seats[i]
|
||||||
|
row := storage.Seat{Seat: i, Name: p.Name}
|
||||||
|
if !p.Bot {
|
||||||
|
row.MatrixUser = human
|
||||||
|
row.Staked = buyIn
|
||||||
|
}
|
||||||
|
rows[i] = row
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnoSit seats a player at a fresh table of their own or at an open chair on
|
||||||
|
// somebody else's.
|
||||||
|
func (s *Server) handleUnoSit(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.player(w, r)
|
user, ok := s.player(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Bet int64 `json:"bet"`
|
|
||||||
Tier string `json:"tier"`
|
Tier string `json:"tier"`
|
||||||
|
BuyIn int64 `json:"buyin"`
|
||||||
|
Table string `json:"table"`
|
||||||
|
Seat *int `json:"seat"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tier, err := uno.TierBySlug(req.Tier)
|
if req.Table != "" {
|
||||||
|
s.joinUno(w, r, user, req.Table, req.Seat, req.BuyIn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.openUno(w, r, user, req.Tier, req.BuyIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openUno opens a fresh table with the player in seat zero and bots in the rest —
|
||||||
|
// the old solo flow, now a real shared table with no other humans on it yet.
|
||||||
|
func (s *Server) openUno(w http.ResponseWriter, r *http.Request, user, tierSlug string, buyIn int64) {
|
||||||
|
tier, err := uno.TierBySlug(tierSlug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if buyIn < tier.MinBuy || buyIn > tier.MaxBuy {
|
||||||
if err := storage.Stake(user, req.Bet); err != nil {
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that isn't a legal buy-in for this table"})
|
||||||
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
|
||||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("games: uno stake", "user", user, "err", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
name := s.displayName(r, user)
|
||||||
seed1, seed2 := newSeeds()
|
seed1, seed2 := newSeeds()
|
||||||
g, evs, err := uno.New(req.Bet, tier, blackjack.DefaultRules().RakePct, seed1, seed2)
|
g, _, err := uno.New(tier, uno.TableSeats(tier, name, tier.Bots, buyIn), blackjack.DefaultRules().RakePct, seed1, seed2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The game never happened, so the stake never should have left.
|
slog.Error("games: uno open", "user", user, "err", err)
|
||||||
_ = storage.Award(user, req.Bet)
|
|
||||||
slog.Error("games: uno deal", "user", user, "err", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.persistUno(w, user, g, evs, seed1, seed2, true)
|
blob, err := json.Marshal(g)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: marshal new uno", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := storage.NewTableID()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: mint table id", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUnoMove plays one turn: a card, a draw, or passing on the card you drew.
|
t := storage.Table{
|
||||||
// The bots' turns come back with it.
|
ID: id, Game: gameUno, Tier: tier.Slug, State: blob,
|
||||||
|
Seed1: seed1, Seed2: seed2, Phase: string(g.Phase), HandNo: int64(g.HandNo),
|
||||||
|
}
|
||||||
|
err = storage.OpenSoloTable(t, unoSeatRows(g, user, buyIn), buyIn)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"})
|
||||||
|
return
|
||||||
|
case errors.Is(err, storage.ErrHandInProgress):
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
slog.Error("games: open solo uno table", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeUnoTable(w, user, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickOpenUnoSeat chooses a chair to join: the one asked for if it is a bot's,
|
||||||
|
// otherwise the first bot seat. Returns -1 if there is nowhere to sit.
|
||||||
|
func pickOpenUnoSeat(g uno.State, want *int) int {
|
||||||
|
if want != nil {
|
||||||
|
i := *want
|
||||||
|
if i >= 0 && i < len(g.Seats) && g.Seats[i].Bot {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
for i := range g.Seats {
|
||||||
|
if g.Seats[i].Bot {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinUno sits a player at an open chair on an existing table, one transaction
|
||||||
|
// under the table lock, so two people racing for the last seat cannot both win it.
|
||||||
|
func (s *Server) joinUno(w http.ResponseWriter, r *http.Request, user, tableID string, wantSeat *int, buyIn int64) {
|
||||||
|
name := s.displayName(r, user)
|
||||||
|
var respErr error
|
||||||
|
err := s.tableLocks.withTable(tableID, func() error {
|
||||||
|
t, _, err := storage.LoadTable(tableID)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||||
|
respErr = storage.ErrNoSuchTable
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if t.Game != gameUno {
|
||||||
|
respErr = uno.ErrUnknownMove
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var g uno.State
|
||||||
|
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if unoPlaying(g) {
|
||||||
|
respErr = uno.ErrHandLive // you join between hands
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seat := pickOpenUnoSeat(g, wantSeat)
|
||||||
|
if seat < 0 {
|
||||||
|
respErr = uno.ErrTableFull
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := g.Occupy(seat, name, buyIn); err != nil {
|
||||||
|
respErr = err
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
blob, err := json.Marshal(g)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.State, t.Phase, t.HandNo = blob, string(g.Phase), int64(g.HandNo)
|
||||||
|
err = storage.SitDown(storage.Sit{
|
||||||
|
Table: t,
|
||||||
|
Seat: storage.Seat{Seat: seat, MatrixUser: user, Name: name, Staked: buyIn},
|
||||||
|
BuyIn: buyIn,
|
||||||
|
})
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
|
||||||
|
respErr = storage.ErrInsufficientChips
|
||||||
|
return nil
|
||||||
|
case errors.Is(err, storage.ErrHandInProgress):
|
||||||
|
respErr = storage.ErrHandInProgress
|
||||||
|
return nil
|
||||||
|
case errors.Is(err, storage.ErrSeatTaken), errors.Is(err, storage.ErrStaleTable):
|
||||||
|
respErr = storage.ErrSeatTaken
|
||||||
|
return nil
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.publishTable(tableID)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: join uno", "user", user, "table", tableID, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if respErr != nil {
|
||||||
|
writeJSONStatus(w, unoJoinStatus(respErr), map[string]string{"error": unoJoinMessage(respErr)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeUnoTable(w, user, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unoJoinStatus(err error) int {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrNoSuchTable), errors.Is(err, uno.ErrTableFull),
|
||||||
|
errors.Is(err, storage.ErrSeatTaken), errors.Is(err, uno.ErrHandLive):
|
||||||
|
return http.StatusConflict
|
||||||
|
default:
|
||||||
|
return http.StatusBadRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unoJoinMessage(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrNoSuchTable):
|
||||||
|
return "that table has closed"
|
||||||
|
case errors.Is(err, uno.ErrTableFull), errors.Is(err, storage.ErrSeatTaken):
|
||||||
|
return "that seat is taken"
|
||||||
|
case errors.Is(err, uno.ErrHandLive):
|
||||||
|
return "a hand is in play — sit down when it's over"
|
||||||
|
case errors.Is(err, uno.ErrBadBuyIn):
|
||||||
|
return "that isn't a legal buy-in for this table"
|
||||||
|
case errors.Is(err, storage.ErrInsufficientChips):
|
||||||
|
return "not enough chips to sit down"
|
||||||
|
case errors.Is(err, storage.ErrHandInProgress):
|
||||||
|
return "finish the game you're in first"
|
||||||
|
default:
|
||||||
|
return "you can't sit there"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- playing a hand --------------------------------------------------------
|
||||||
|
|
||||||
|
// handleUnoMove plays one move at the player's table: a hand move, or dealing the
|
||||||
|
// next hand. Leaving is its own endpoint.
|
||||||
func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.player(w, r)
|
user, ok := s.player(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -265,80 +441,237 @@ func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad json", http.StatusBadRequest)
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if move.Kind == uno.MoveLeave {
|
||||||
|
s.leaveUno(w, user)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
live, err := storage.LoadLiveHand(user)
|
tableID, seat, err := storage.PlayerSeat(user)
|
||||||
if errors.Is(err, storage.ErrNoLiveHand) {
|
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"})
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("games: uno load", "user", user, "err", err)
|
slog.Error("games: uno move seat", "user", user, "err", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if live.Game != gameUno {
|
|
||||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
|
var respErr error
|
||||||
return
|
var respEvents []uno.Event
|
||||||
|
err = s.tableLocks.withTable(tableID, func() error {
|
||||||
|
t, seats, err := storage.LoadTable(tableID)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||||
|
respErr = storage.ErrNoSuchTable
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
var g uno.State
|
var g uno.State
|
||||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||||
slog.Error("games: unreadable uno game", "user", user, "err", err)
|
return err
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
next, evs, err := uno.ApplyMove(g, move)
|
next, evs, aerr := uno.ApplyMove(g, seat, move)
|
||||||
if err != nil {
|
if aerr != nil {
|
||||||
// The refusals a player can actually cause, said in words rather than as
|
respErr = aerr
|
||||||
// "that move isn't legal here" — which, in a game with this many rules, is
|
return nil
|
||||||
// the table refusing to explain itself.
|
|
||||||
msg := "that move isn't legal here"
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, uno.ErrCantPlay):
|
|
||||||
msg = "that card doesn't go on this one"
|
|
||||||
case errors.Is(err, uno.ErrNeedColor):
|
|
||||||
msg = "pick a colour for the wild"
|
|
||||||
case errors.Is(err, uno.ErrMustPlayNow):
|
|
||||||
msg = "play the card you drew, or pass"
|
|
||||||
case errors.Is(err, uno.ErrCantPass):
|
|
||||||
msg = "draw first, then you can pass"
|
|
||||||
case errors.Is(err, uno.ErrMustStack):
|
|
||||||
msg = "answer the stack with a draw card, or take it"
|
|
||||||
case errors.Is(err, uno.ErrNoStack):
|
|
||||||
msg = "there's nothing pointed at you to take"
|
|
||||||
case errors.Is(err, uno.ErrNoCatch):
|
|
||||||
msg = "there's nobody in that seat to catch"
|
|
||||||
}
|
|
||||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.persistUno(w, user, next, evs, live.Seed1, live.Seed2, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// persistUno writes the game back and answers the browser.
|
// A solo session that just ended (the one human got up or busted at the deal)
|
||||||
func (s *Server) persistUno(w http.ResponseWriter, user string, g uno.State, evs []uno.Event, seed1, seed2 uint64, fresh bool) {
|
// is not a table any more: cash the seat out and close the felt. Only a solo
|
||||||
blob, err := json.Marshal(g)
|
// table reaches PhaseDone; a shared table plays on.
|
||||||
if err != nil {
|
if next.Phase == uno.PhaseDone {
|
||||||
slog.Error("games: marshal uno", "user", user, "err", err)
|
if err := s.settleUnoLeave(t, next, seat, user); err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
if errors.Is(err, storage.ErrStaleTable) {
|
||||||
return
|
respErr = storage.ErrStaleTable
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
done := g.Phase == uno.PhaseDone
|
return err
|
||||||
v, ok := s.commit(w, user, finished{
|
}
|
||||||
Game: gameUno, Blob: blob,
|
respEvents = evs
|
||||||
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
|
s.publishTable(tableID)
|
||||||
Outcome: string(g.Outcome), Done: done,
|
return nil
|
||||||
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
}
|
||||||
|
|
||||||
|
st, err := unoStep(next, evs, seats)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
acting := seats[seat]
|
||||||
|
acting.Away = false
|
||||||
|
acting.LastSeen = time.Now().Unix()
|
||||||
|
|
||||||
|
t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
|
||||||
|
if err := storage.CommitTable(storage.TableCommit{
|
||||||
|
Table: t, Seats: []storage.Seat{acting}, Audit: st.Audit,
|
||||||
|
}); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrStaleTable) {
|
||||||
|
respErr = storage.ErrStaleTable
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
respEvents = evs
|
||||||
|
s.publishTable(tableID)
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: uno move", "user", user, "table", tableID, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if respErr != nil {
|
||||||
|
writeJSONStatus(w, unoMoveStatus(respErr), map[string]string{"error": unoMoveMessage(respErr)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeUnoTable(w, user, respEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unoMoveStatus(err error) int {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable):
|
||||||
|
return http.StatusConflict
|
||||||
|
default:
|
||||||
|
return http.StatusBadRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unoMoveMessage(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrStaleTable):
|
||||||
|
return "the table moved on — take another look"
|
||||||
|
case errors.Is(err, storage.ErrNoSuchTable):
|
||||||
|
return "that table has closed"
|
||||||
|
case errors.Is(err, uno.ErrHandLive):
|
||||||
|
return "finish the hand first"
|
||||||
|
case errors.Is(err, uno.ErrNoHand):
|
||||||
|
return "there's no hand in play — deal one"
|
||||||
|
case errors.Is(err, uno.ErrNotYourTurn):
|
||||||
|
return "it isn't your turn"
|
||||||
|
case errors.Is(err, uno.ErrCantPlay):
|
||||||
|
return "that card doesn't go on this one"
|
||||||
|
case errors.Is(err, uno.ErrNeedColor):
|
||||||
|
return "pick a colour for the wild"
|
||||||
|
case errors.Is(err, uno.ErrMustPlayNow):
|
||||||
|
return "play the card you drew, or pass"
|
||||||
|
case errors.Is(err, uno.ErrCantPass):
|
||||||
|
return "draw first, then you can pass"
|
||||||
|
case errors.Is(err, uno.ErrMustStack):
|
||||||
|
return "answer the stack with a draw card, or take it"
|
||||||
|
case errors.Is(err, uno.ErrNoStack):
|
||||||
|
return "there's nothing pointed at you to take"
|
||||||
|
case errors.Is(err, uno.ErrNoCatch):
|
||||||
|
return "there's nobody in that seat to catch"
|
||||||
|
default:
|
||||||
|
return "that move isn't legal here"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- getting up ------------------------------------------------------------
|
||||||
|
|
||||||
|
// handleUnoLeave is the get-up endpoint. Leaving is its own route because it is a
|
||||||
|
// storage operation, not an engine move — the chips cross the border and the felt
|
||||||
|
// may close.
|
||||||
|
func (s *Server) handleUnoLeave(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A finished game is gone from storage, so the table has none to show — but the
|
s.leaveUno(w, user)
|
||||||
// browser still needs the final board to land the verdict on.
|
}
|
||||||
if done {
|
|
||||||
uv := viewUno(g)
|
// leaveUno gets a player up from their table, turning what is in front of them
|
||||||
v.Uno = &uv
|
// back into chips. It refuses mid-hand and closes the felt behind the last human.
|
||||||
|
func (s *Server) leaveUno(w http.ResponseWriter, user string) {
|
||||||
|
tableID, seat, err := storage.PlayerSeat(user)
|
||||||
|
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: uno leave seat", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var respErr error
|
||||||
|
err = s.tableLocks.withTable(tableID, func() error {
|
||||||
|
t, _, err := storage.LoadTable(tableID)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||||
|
respErr = storage.ErrNoSuchTable
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var g uno.State
|
||||||
|
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if unoPlaying(g) {
|
||||||
|
respErr = uno.ErrHandLive
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.settleUnoLeave(t, g, seat, user); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrStaleTable) {
|
||||||
|
respErr = storage.ErrStaleTable
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.publishTable(tableID)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: uno leave", "user", user, "table", tableID, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if respErr != nil {
|
||||||
|
writeJSONStatus(w, unoMoveStatus(respErr), map[string]string{"error": unoMoveMessage(respErr)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeUnoTable(w, user, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// settleUnoLeave vacates a seat, credits the stack home, and closes the table if
|
||||||
|
// nobody human is left — all inside the caller's lock.
|
||||||
|
func (s *Server) settleUnoLeave(t storage.Table, g uno.State, seat int, user string) error {
|
||||||
|
home, err := g.Vacate(seat)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
blob, err := json.Marshal(g)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.State, t.Phase, t.HandNo, t.Deadline = blob, string(g.Phase), int64(g.HandNo), 0
|
||||||
|
if err := storage.LeaveTable(storage.Leave{
|
||||||
|
Table: t, Seat: seat, MatrixUser: user, Bot: g.Seats[seat].Name, Amount: home,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return storage.CloseTable(t.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the response ----------------------------------------------------------
|
||||||
|
|
||||||
|
// writeUnoTable answers with the whole page state — the money and the table as the
|
||||||
|
// player's own seat may see it — plus, when a move produced one, the redacted event
|
||||||
|
// script for that seat to animate.
|
||||||
|
func (s *Server) writeUnoTable(w http.ResponseWriter, user string, evs []uno.Event) {
|
||||||
|
v, err := s.table(user)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: uno table", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(evs) > 0 {
|
||||||
|
if _, seat, serr := storage.PlayerSeat(user); serr == nil {
|
||||||
|
v.UnoEvents = viewUnoEvents(evs, seat)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
v.UnoEvents = viewUnoEvents(evs)
|
|
||||||
writeJSON(w, v)
|
writeJSON(w, v)
|
||||||
}
|
}
|
||||||
|
|||||||
187
internal/web/games_uno_table.go
Normal file
187
internal/web/games_uno_table.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/games/uno"
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UNO as a shared table: the seam the runtime drives it through, and the two
|
||||||
|
// facts about a hand only the engine can tell the runtime — when the clock must
|
||||||
|
// next act, and what a finished hand owes the audit trail.
|
||||||
|
//
|
||||||
|
// Everything about *playing* UNO is still in the engine. This file is the
|
||||||
|
// translation layer between a uno.State and the game-agnostic table runtime.
|
||||||
|
|
||||||
|
// unoTable is the tableGame for UNO. It holds no state — the table's state is the
|
||||||
|
// blob in game_tables — so a single value serves every felt in the room.
|
||||||
|
type unoTable struct{}
|
||||||
|
|
||||||
|
func (unoTable) name() string { return gameUno }
|
||||||
|
|
||||||
|
// timeout plays a passive move for the human whose clock ran out and marks them
|
||||||
|
// away, so the runtime auto-acts them on sight after this rather than waiting a
|
||||||
|
// full clock every orbit. The move is the gentlest one that still advances the
|
||||||
|
// turn: give in to a stack, pass a drawn card (or play it where the rules force
|
||||||
|
// it), otherwise play a legal card if there is one, or draw.
|
||||||
|
//
|
||||||
|
// If, on decode, the seat to act is not a waiting human, the scan raced a real
|
||||||
|
// move that had not yet bumped the version: errNotDue, and the clock steps aside.
|
||||||
|
func (unoTable) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
|
||||||
|
var g uno.State
|
||||||
|
if err := json.Unmarshal(state, &g); err != nil {
|
||||||
|
return step{}, nil, err
|
||||||
|
}
|
||||||
|
if !unoPlaying(g) {
|
||||||
|
return step{}, seats, errNotDue
|
||||||
|
}
|
||||||
|
seat := g.Turn
|
||||||
|
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
|
||||||
|
return step{}, seats, errNotDue
|
||||||
|
}
|
||||||
|
|
||||||
|
move := unoIdleMove(g, seat)
|
||||||
|
next, evs, err := uno.ApplyMove(g, seat, move)
|
||||||
|
if err != nil {
|
||||||
|
return step{}, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := markAway(seats, seat)
|
||||||
|
st, err := unoStep(next, evs, seats)
|
||||||
|
return st, changed, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// unoIdleMove is the move the clock makes for a walked-away seat: the most passive
|
||||||
|
// legal one that still hands the turn on.
|
||||||
|
func unoIdleMove(g uno.State, seat int) uno.Move {
|
||||||
|
switch g.Phase {
|
||||||
|
case uno.PhaseStack:
|
||||||
|
return uno.Move{Kind: uno.MoveTake}
|
||||||
|
case uno.PhaseDrawn:
|
||||||
|
if g.Tier.NoMercy {
|
||||||
|
// No Mercy makes you play the card you drew; it is the last in the hand.
|
||||||
|
return uno.Move{Kind: uno.MovePlay, Index: len(g.Hands[seat]) - 1, Color: uno.Red}
|
||||||
|
}
|
||||||
|
return uno.Move{Kind: uno.MovePass}
|
||||||
|
default:
|
||||||
|
if p := g.Playable(seat); len(p) > 0 {
|
||||||
|
return uno.Move{Kind: uno.MovePlay, Index: p[0], Color: uno.Red}
|
||||||
|
}
|
||||||
|
return uno.Move{Kind: uno.MoveDraw}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stacks reports the chips in front of each seat, index-aligned with the table's
|
||||||
|
// seat rows, so the abandoned-table reaper can cash out a walked-away human.
|
||||||
|
func (unoTable) stacks(state []byte) ([]int64, error) {
|
||||||
|
var g uno.State
|
||||||
|
if err := json.Unmarshal(state, &g); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]int64, len(g.Seats))
|
||||||
|
for i := range g.Seats {
|
||||||
|
out[i] = g.Seats[i].Stack
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unoStep packages a played-out state for the runtime: the blob to persist, the
|
||||||
|
// deadline the clock must honour next, and the audit of any hand that just ended.
|
||||||
|
func unoStep(next uno.State, evs []uno.Event, seats []storage.Seat) (step, error) {
|
||||||
|
blob, err := json.Marshal(next)
|
||||||
|
if err != nil {
|
||||||
|
return step{}, err
|
||||||
|
}
|
||||||
|
st := step{
|
||||||
|
State: blob,
|
||||||
|
Phase: string(next.Phase),
|
||||||
|
HandNo: int64(next.HandNo),
|
||||||
|
Deadline: unoDeadline(next, seats),
|
||||||
|
Audit: unoAudit(next, evs, seats),
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unoPlaying reports whether a hand is in progress — the only phase a turn clock
|
||||||
|
// has anything to do in.
|
||||||
|
func unoPlaying(g uno.State) bool {
|
||||||
|
switch g.Phase {
|
||||||
|
case uno.PhasePlay, uno.PhaseDrawn, uno.PhaseStack:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// unoDeadline is when the clock must next act, or 0 for never. A clock is only set
|
||||||
|
// on a *present* human whose turn it is: a bot resolves inside the move, an away
|
||||||
|
// human is auto-acted on sight, and between hands there is no per-seat clock —
|
||||||
|
// dealing the next hand is a seated player's call, and an abandoned table is the
|
||||||
|
// reaper's job.
|
||||||
|
func unoDeadline(g uno.State, seats []storage.Seat) int64 {
|
||||||
|
if !unoPlaying(g) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
seat := g.Turn
|
||||||
|
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if seatAway(seats, seat) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Now().Unix() + turnSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// unoAudit is the per-hand record of a finished hand — one row per human who was
|
||||||
|
// in it. Empty until a hand actually ends, which is exactly when a settle beat is
|
||||||
|
// emitted.
|
||||||
|
//
|
||||||
|
// The rake rides on the winner's row alone, and every other seat carries zero, so
|
||||||
|
// game_hands.rake (which HouseTake sums) records a pot's rake once and once only.
|
||||||
|
// A seat's ante is its bet; a refunded tie pays each seat its ante straight back.
|
||||||
|
func unoAudit(g uno.State, evs []uno.Event, seats []storage.Seat) []storage.Hand {
|
||||||
|
if !unoHandEnded(evs) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var audit []storage.Hand
|
||||||
|
for i := range g.Seats {
|
||||||
|
p := g.Seats[i]
|
||||||
|
if p.Bot || i >= len(seats) || seats[i].MatrixUser == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Ante == 0 {
|
||||||
|
continue // sat this hand out — nothing to record
|
||||||
|
}
|
||||||
|
outcome, payout, rake := "lost", int64(0), int64(0)
|
||||||
|
switch {
|
||||||
|
case i == g.Winner:
|
||||||
|
outcome, payout, rake = "won", p.Won, g.Rake
|
||||||
|
case g.Outcome == uno.OutcomeTie:
|
||||||
|
outcome, payout = "push", p.Ante // the ante came straight back
|
||||||
|
}
|
||||||
|
audit = append(audit, storage.Hand{
|
||||||
|
MatrixUser: seats[i].MatrixUser,
|
||||||
|
Game: gameUno,
|
||||||
|
Bet: p.Ante,
|
||||||
|
Payout: payout,
|
||||||
|
Rake: rake,
|
||||||
|
Outcome: outcome,
|
||||||
|
Seed1: g.Seed1,
|
||||||
|
Seed2: g.Seed2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return audit
|
||||||
|
}
|
||||||
|
|
||||||
|
// unoHandEnded reports whether a hand finished in this batch of events. A settle
|
||||||
|
// beat is emitted exactly once, when a hand ends, so it is the clean signal that
|
||||||
|
// the seats' Ante/Won are this hand's final numbers.
|
||||||
|
func unoHandEnded(evs []uno.Event) bool {
|
||||||
|
for _, e := range evs {
|
||||||
|
if e.Kind == uno.EvSettle {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -4,57 +4,54 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"pete/internal/games/uno"
|
"pete/internal/games/uno"
|
||||||
"pete/internal/storage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The one thing this table cannot get wrong: the stake leaves the stack, and no
|
// Sitting down is the only time chips leave your stack at this table, and getting
|
||||||
// card a player is not entitled to see leaves the server. At UNO that is not one
|
// up is the only time they come back. The ante and the pot move inside the engine.
|
||||||
// hole card — it is the deck and every bot's hand, which is most of the game.
|
func TestUnoSitTakesTheBuyIn(t *testing.T) {
|
||||||
func TestUnoStartTakesTheStakeAndKeepsTheCards(t *testing.T) {
|
|
||||||
s := newCasino(t)
|
s := newCasino(t)
|
||||||
fund(t, 1000)
|
fund(t, 5000)
|
||||||
|
|
||||||
v, code := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
|
v, code := call(t, s, s.handleUnoSit, as(t, s, "reala", "POST", "/api/games/uno/sit",
|
||||||
map[string]any{"bet": 100, "tier": "full"}))
|
map[string]any{"tier": "full", "buyin": 1000}))
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("start = %d, want 200", code)
|
t.Fatalf("sit = %d, want 200", code)
|
||||||
}
|
}
|
||||||
if v.Chips != 900 {
|
if v.Chips != 4000 {
|
||||||
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
|
t.Fatalf("chips after a 1000 buy-in = %d, want 4000", v.Chips)
|
||||||
}
|
}
|
||||||
if v.Game != gameUno || v.Uno == nil {
|
if v.Game != gameUno || v.Uno == nil {
|
||||||
t.Fatalf("start returned no uno game: game=%q", v.Game)
|
t.Fatalf("sit returned no table: game=%q", v.Game)
|
||||||
}
|
}
|
||||||
|
|
||||||
g := v.Uno
|
g := v.Uno
|
||||||
if len(g.Hand) != uno.HandSize {
|
if g.Stack != 1000 {
|
||||||
t.Errorf("you were dealt %d cards, want %d", len(g.Hand), uno.HandSize)
|
t.Errorf("you sat down with %d, want the 1000 you bought in for", g.Stack)
|
||||||
}
|
}
|
||||||
if len(g.Seats) != 4 {
|
if len(g.Seats) != 4 {
|
||||||
t.Fatalf("a full house is four seats, got %d", len(g.Seats))
|
t.Fatalf("three bots and you is four seats, got %d", len(g.Seats))
|
||||||
}
|
}
|
||||||
// A bot is a name and a number. There is no field here that could carry a
|
if g.Phase != "handover" {
|
||||||
// card, which is the point — this is a compile-time guarantee, and the test
|
t.Errorf("phase %q — a table you just sat at has no hand on it yet", g.Phase)
|
||||||
// exists to make deleting it loud.
|
|
||||||
for i, seat := range g.Seats {
|
|
||||||
if i == 0 {
|
|
||||||
if !seat.You || seat.Name != "You" {
|
|
||||||
t.Errorf("seat 0 should be you: %+v", seat)
|
|
||||||
}
|
}
|
||||||
continue
|
|
||||||
|
// Deal a hand: chips do not move (the ante is within the blob), and you are dealt
|
||||||
|
// seven cards and to act.
|
||||||
|
deal, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
||||||
|
map[string]any{"kind": "deal"}))
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("deal = %d, want 200", code)
|
||||||
}
|
}
|
||||||
if seat.You || seat.Name == "" {
|
if deal.Chips != 4000 {
|
||||||
t.Errorf("seat %d should be a named bot: %+v", i, seat)
|
t.Errorf("chips moved on the deal: %d, want 4000 — the ante is inside the engine", deal.Chips)
|
||||||
}
|
}
|
||||||
if seat.Cards != uno.HandSize {
|
if deal.Uno == nil || len(deal.Uno.Hand) != uno.HandSize {
|
||||||
t.Errorf("seat %d holds %d cards, want %d", i, seat.Cards, uno.HandSize)
|
t.Fatalf("you were dealt the wrong hand: %+v", deal.Uno)
|
||||||
}
|
}
|
||||||
|
if deal.Uno.Turn != 0 {
|
||||||
|
t.Errorf("you act first at your own table, turn is %d", deal.Uno.Turn)
|
||||||
}
|
}
|
||||||
if g.Top.Value == "" {
|
if deal.Uno.Pot != deal.Uno.Ante*int64(len(deal.Uno.Seats)) {
|
||||||
t.Error("no card in play")
|
t.Errorf("pot is %d, want one ante per seat", deal.Uno.Pot)
|
||||||
}
|
|
||||||
if g.Turn != 0 {
|
|
||||||
t.Errorf("you play first, turn is %d", g.Turn)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,16 +59,16 @@ func TestUnoStartTakesTheStakeAndKeepsTheCards(t *testing.T) {
|
|||||||
// back never carries a bot's drawn card.
|
// back never carries a bot's drawn card.
|
||||||
func TestUnoMovePlaysTheWholeLap(t *testing.T) {
|
func TestUnoMovePlaysTheWholeLap(t *testing.T) {
|
||||||
s := newCasino(t)
|
s := newCasino(t)
|
||||||
fund(t, 1000)
|
fund(t, 5000)
|
||||||
|
|
||||||
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
|
call(t, s, s.handleUnoSit, as(t, s, "reala", "POST", "/api/games/uno/sit",
|
||||||
map[string]any{"bet": 100, "tier": "table"}))
|
map[string]any{"tier": "table", "buyin": 1000}))
|
||||||
if v.Uno == nil {
|
deal, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
||||||
t.Fatal("no game")
|
map[string]any{"kind": "deal"}))
|
||||||
|
if code != 200 || deal.Uno == nil {
|
||||||
|
t.Fatalf("deal = %d", code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw, which is legal from any hand: the turn passes to the bots and comes
|
|
||||||
// back, unless the card drawn happens to be playable.
|
|
||||||
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
||||||
map[string]any{"kind": "draw"}))
|
map[string]any{"kind": "draw"}))
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
@@ -93,110 +90,144 @@ func TestUnoMovePlaysTheWholeLap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// You cannot play a wild without naming a colour — and the zero value of the
|
// TestUnoViewNeverLeaksAnotherSeatsCards is the security boundary of the whole
|
||||||
// colour field is not red, so a move that simply omits it is refused rather than
|
// multiplayer table. There is no showdown in UNO, so the rule is absolute: no
|
||||||
// quietly played as one.
|
// seat's hand, and no card a seat drew, ever reaches another viewer. Rendered for
|
||||||
func TestUnoWildWithNoColourIsRefused(t *testing.T) {
|
// every seat, after every step of a hand played to its end.
|
||||||
s := newCasino(t)
|
//
|
||||||
fund(t, 1000)
|
// After SSE the view a seat renders fans to that seat's stream, so a single missed
|
||||||
|
// redaction broadcasts a hand to the whole felt. The engine emits every seat's
|
||||||
// Deal until a wild lands in the opening hand: it's four cards in 108, so it
|
// cards (it cannot know who a shared stream is for), which makes viewUno and
|
||||||
// doesn't take long, and this is the only way to get one without rigging the
|
// viewUnoEvents the only thing between the deck and the network tab.
|
||||||
// deck through a door the server doesn't have.
|
func TestUnoViewNeverLeaksAnotherSeatsCards(t *testing.T) {
|
||||||
var wild = -1
|
tier, _ := uno.TierBySlug("full")
|
||||||
for try := 0; try < 40 && wild < 0; try++ {
|
// Two humans (0, 2) and two bots (1, 3), so the test covers a viewer seeing both
|
||||||
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
|
// another human and a bot, and a human at a non-zero index.
|
||||||
map[string]any{"bet": 10, "tier": "duel"}))
|
seats := []uno.SeatConfig{
|
||||||
if v.Uno == nil {
|
{Name: "Ana", Stack: 2000},
|
||||||
t.Fatal("no game")
|
{Name: "Bot A", Bot: true, Stack: 2000},
|
||||||
|
{Name: "Bo", Stack: 2000},
|
||||||
|
{Name: "Bot B", Bot: true, Stack: 2000},
|
||||||
}
|
}
|
||||||
for i, c := range v.Uno.Hand {
|
g, _, err := uno.New(tier, seats, tier.RakePct, 5, 6)
|
||||||
if c.Wild {
|
if err != nil {
|
||||||
wild = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if wild < 0 {
|
|
||||||
// Abandon this deal and take another. The live row is keyed on the
|
|
||||||
// player, so it has to go before the next start can be seated.
|
|
||||||
if err := storage.ClearLiveHand(testPlayer); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
g, evs, err := uno.ApplyMove(g, 0, uno.Move{Kind: uno.MoveDeal})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
assertUnoRedacted(t, g, evs)
|
||||||
if wild < 0 {
|
|
||||||
t.Skip("40 deals and not one wild — vanishingly unlikely, but not a failure")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
for step := 0; unoPlaying(g) && step < 200; step++ {
|
||||||
map[string]any{"kind": "play", "index": wild}))
|
seat := g.Turn
|
||||||
if code != 400 {
|
if g.Seats[seat].Bot {
|
||||||
t.Fatalf("a wild with no colour = %d, want 400", code)
|
t.Fatalf("advance stopped on a bot at seat %d", seat)
|
||||||
}
|
}
|
||||||
|
move := unoHumanMove(g, seat)
|
||||||
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
var stepEvs []uno.Event
|
||||||
map[string]any{"kind": "play", "index": wild, "color": int(uno.Green)}))
|
g, stepEvs, err = uno.ApplyMove(g, seat, move)
|
||||||
if code != 200 {
|
if err != nil {
|
||||||
t.Fatalf("a wild played as green = %d, want 200", code)
|
t.Fatalf("seat %d %s: %v", seat, move.Kind, err)
|
||||||
}
|
}
|
||||||
if v.Uno != nil && v.Uno.Phase != "done" && v.Uno.Color != "green" && v.Uno.Color != "" {
|
assertUnoRedacted(t, g, stepEvs)
|
||||||
// The bots have played since, so the colour may have moved on — what must
|
|
||||||
// not happen is the wild going down as anything we didn't ask for.
|
|
||||||
t.Logf("colour in play after the bots: %s", v.Uno.Color)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A seat the mercy rule has buried holds no cards. So does a seat that has just
|
// unoHumanMove picks a legal move for the human to act: play a legal card, take a
|
||||||
// gone out and won. The view has to tell them apart, because everything it says
|
// stack you can't answer, pass a drawn card, otherwise draw.
|
||||||
// afterwards hangs off it: who won, whose fan of cards to rub out, and whether the
|
func unoHumanMove(g uno.State, seat int) uno.Move {
|
||||||
// player is looking at a payout or a grave.
|
if p := g.Playable(seat); len(p) > 0 {
|
||||||
//
|
m := uno.Move{Kind: uno.MovePlay, Index: p[0], Color: uno.Red}
|
||||||
// This is the whole reason the view asks the engine (Live) instead of inferring it
|
for _, at := range g.UnoAt(seat) {
|
||||||
// from a count of zero.
|
if at == p[0] {
|
||||||
func TestABuriedSeatIsNotTheWinner(t *testing.T) {
|
m.Uno = true
|
||||||
g, _, err := uno.New(100, mustTier(t, "nm-full"), 0.05, 7, 9)
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
switch g.Phase {
|
||||||
|
case uno.PhaseStack:
|
||||||
|
return uno.Move{Kind: uno.MoveTake}
|
||||||
|
case uno.PhaseDrawn:
|
||||||
|
return uno.Move{Kind: uno.MovePass}
|
||||||
|
default:
|
||||||
|
return uno.Move{Kind: uno.MoveDraw}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertUnoRedacted renders every seat's view and event script and fails if any of
|
||||||
|
// them carries a card belonging to another seat.
|
||||||
|
func assertUnoRedacted(t *testing.T, g uno.State, evs []uno.Event) {
|
||||||
|
t.Helper()
|
||||||
|
for viewer := range g.Seats {
|
||||||
|
v := viewUno(g, viewer)
|
||||||
|
// The view's hand is the viewer's own, exactly — no more, no fewer.
|
||||||
|
if len(v.Hand) != len(g.Hands[viewer]) {
|
||||||
|
t.Fatalf("seat %d's view carries %d cards, but the seat holds %d",
|
||||||
|
viewer, len(v.Hand), len(g.Hands[viewer]))
|
||||||
|
}
|
||||||
|
// The event script: a hand rides only the viewer's own beat, and a drawn card
|
||||||
|
// only the viewer's own draw. (A card a bot plays is public — it lands on the
|
||||||
|
// pile — so only a *drawn* card is a secret, which is why this is scoped to the
|
||||||
|
// draw and forced beats.) Undo either guard in viewUnoEvents and this fails.
|
||||||
|
for _, e := range viewUnoEvents(evs, viewer) {
|
||||||
|
if len(e.Hand) > 0 && e.Seat != viewer {
|
||||||
|
t.Fatalf("seat %d's event stream carries seat %d's hand", viewer, e.Seat)
|
||||||
|
}
|
||||||
|
if e.Card != nil && (e.Kind == uno.EvDraw || e.Kind == uno.EvForced) && e.Seat != viewer {
|
||||||
|
t.Fatalf("seat %d's event stream carries seat %d's drawn card", viewer, e.Seat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A seat the mercy rule has buried holds no cards; so does one that just went out.
|
||||||
|
// The view tells them apart by asking the engine (Live), not by inferring from a
|
||||||
|
// count of zero.
|
||||||
|
func TestABuriedSeatIsRenderedOut(t *testing.T) {
|
||||||
|
tier, _ := uno.TierBySlug("nm-full")
|
||||||
|
g, _, err := uno.New(tier, uno.SoloSeats(tier, tier.Bots, 1000), 0.05, 7, 9)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g, _, err = uno.ApplyMove(g, 0, uno.Move{Kind: uno.MoveDeal})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bury seat 2, the way the engine does: no cards, and out.
|
// Bury seat 2 the way the engine does: no cards, and out.
|
||||||
g.Hands[2] = nil
|
g.Hands[2] = nil
|
||||||
g.Out[2] = true
|
g.Out[2] = true
|
||||||
|
|
||||||
v := viewUno(g)
|
v := viewUno(g, 0)
|
||||||
if !v.Seats[2].Out {
|
if !v.Seats[2].Out {
|
||||||
t.Error("a buried seat must say so; the felt draws it as a grave")
|
t.Error("a buried seat must say so; the felt draws it as a grave")
|
||||||
}
|
}
|
||||||
if v.Seats[2].Uno {
|
if v.Seats[2].Uno {
|
||||||
t.Error("a buried seat is not one card away from going out")
|
t.Error("a buried seat is not one card away from going out")
|
||||||
}
|
}
|
||||||
if v.Winner == 2 {
|
|
||||||
t.Fatal("the buried seat was reported as the winner — an empty hand is not a win")
|
|
||||||
}
|
|
||||||
if v.Winner != -1 {
|
if v.Winner != -1 {
|
||||||
t.Errorf("nobody has gone out yet, so there is no winner: got %d", v.Winner)
|
t.Errorf("nobody has taken the pot mid-hand, so there is no winner: got %d", v.Winner)
|
||||||
}
|
|
||||||
|
|
||||||
// And the other ending only this deck has: you win by outliving the table, with
|
|
||||||
// a hand still in front of you.
|
|
||||||
g.Phase, g.Outcome, g.Payout = uno.PhaseDone, uno.OutcomeWon, g.Pays()
|
|
||||||
if v := viewUno(g); v.Winner != uno.You {
|
|
||||||
t.Errorf("you outlived the table; winner = %d, want you (%d)", v.Winner, uno.You)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The bill a stack has run up is the only thing on the table while it stands, so it
|
// The bill a stack has run up is the only thing on the table while it stands, so it
|
||||||
// has to reach the browser. It is a No Mercy field on a struct the normal game
|
// has to reach the browser.
|
||||||
// shares, which is exactly the kind of field that quietly never gets copied across.
|
|
||||||
func TestTheStackBillCrossesTheWire(t *testing.T) {
|
func TestTheStackBillCrossesTheWire(t *testing.T) {
|
||||||
g, _, err := uno.New(100, mustTier(t, "nm-duel"), 0.05, 3, 4)
|
tier, _ := uno.TierBySlug("nm-duel")
|
||||||
|
g, _, err := uno.New(tier, uno.SoloSeats(tier, tier.Bots, 1000), 0.05, 3, 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g, _, err = uno.ApplyMove(g, 0, uno.Move{Kind: uno.MoveDeal})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
g.Pending = 6
|
g.Pending = 6
|
||||||
g.Phase = uno.PhaseStack
|
g.Phase = uno.PhaseStack
|
||||||
|
|
||||||
v := viewUno(g)
|
v := viewUno(g, 0)
|
||||||
if v.Pending != 6 {
|
if v.Pending != 6 {
|
||||||
t.Errorf("the felt was told the bill is %d, want 6", v.Pending)
|
t.Errorf("the felt was told the bill is %d, want 6", v.Pending)
|
||||||
}
|
}
|
||||||
@@ -204,12 +235,3 @@ func TestTheStackBillCrossesTheWire(t *testing.T) {
|
|||||||
t.Errorf("phase = %q, want %q", v.Phase, uno.PhaseStack)
|
t.Errorf("phase = %q, want %q", v.Phase, uno.PhaseStack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustTier(t *testing.T, slug string) uno.Tier {
|
|
||||||
t.Helper()
|
|
||||||
tier, err := uno.TierBySlug(slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("no tier %q: %v", slug, err)
|
|
||||||
}
|
|
||||||
return tier
|
|
||||||
}
|
|
||||||
|
|||||||
262
internal/web/mischief.go
Normal file
262
internal/web/mischief.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buyerLocalpart normalises a session's Authentik username to the Matrix
|
||||||
|
// localpart gogobee keys everything on. Matrix localparts are lowercase; an
|
||||||
|
// Authentik display of the same name may not be, and gogobee pushes balances and
|
||||||
|
// resolves orders under the lowercase form. Lowercasing on both sides is what
|
||||||
|
// keeps the buyer's balance lookup and their order's identity pointing at the
|
||||||
|
// same person.
|
||||||
|
func buyerLocalpart(u *SessionUser) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(u.Username))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mischief storefront's web seam.
|
||||||
|
//
|
||||||
|
// Two audiences, two auth models, same file. Browsers hit the OIDC-gated buy
|
||||||
|
// endpoints: a signed-in buyer picks a mark off the anonymous board and places a
|
||||||
|
// hit, and reads back their own orders' standing. gogobee hits the bearer-authed
|
||||||
|
// pair: it polls pending orders and pushes a verdict, exactly like the escrow
|
||||||
|
// seam in games.go and under the same standing rule — Pete never calls gogobee,
|
||||||
|
// never moves money, never runs a game rule. It records intent and files a
|
||||||
|
// verdict; everything real happens on the game box.
|
||||||
|
|
||||||
|
// mischiefBurstWindow / mischiefBurstMax are Pete's own anti-spam guard, and
|
||||||
|
// nothing more. The real economic caps — two contracts a day, one live per mark,
|
||||||
|
// a boss a week — are gogobee's and are enforced at claim time. This only stops a
|
||||||
|
// signed-in buyer from spooling the orders table with a stuck mouse button.
|
||||||
|
const (
|
||||||
|
mischiefBurstWindow = time.Hour
|
||||||
|
mischiefBurstMax = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// mischiefOrderReq is the browser's buy request. Price and eligibility are not
|
||||||
|
// the buyer's to assert: they send a tier key and a mark, never a number.
|
||||||
|
type mischiefOrderReq struct {
|
||||||
|
TargetToken string `json:"target_token"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
Signed bool `json:"signed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMischiefOrder places a pending order for the signed-in buyer. It asserts
|
||||||
|
// only what Pete can honestly know: the buyer is signed in with a username the
|
||||||
|
// game box can resolve, the tier is one gogobee currently offers, and the mark is
|
||||||
|
// actually on the live board. Money and the full rulebook are gogobee's, checked
|
||||||
|
// when it claims the order.
|
||||||
|
func (s *Server) handleMischiefOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := s.requireUser(w, r)
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A session minted before the storefront existed carries no username, and
|
||||||
|
// without one gogobee can't name the buyer to its ledger. Send them back
|
||||||
|
// through sign-in to mint a fresh one rather than place an unfulfillable order.
|
||||||
|
buyer := buyerLocalpart(u)
|
||||||
|
if buyer == "" {
|
||||||
|
writeMischiefError(w, http.StatusConflict, "please sign in again")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req mischiefOrderReq
|
||||||
|
if !decodeStateBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.TargetToken == "" {
|
||||||
|
writeMischiefError(w, http.StatusBadRequest, "no target")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tier, ok, err := storage.MischiefTierByKey(req.Tier)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("mischief: tier lookup", "err", err)
|
||||||
|
writeMischiefError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
writeMischiefError(w, http.StatusBadRequest, "no such tier")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mark, ok, err := storage.RosterEntryByToken(req.TargetToken)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("mischief: target lookup", "err", err)
|
||||||
|
writeMischiefError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
// The board moved on, or the token was never real. Either way there is no
|
||||||
|
// one to send trouble to.
|
||||||
|
writeMischiefError(w, http.StatusNotFound, "that adventurer is no longer on the board")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Burst guard. Keyed on the OIDC subject so a username change can't reset it.
|
||||||
|
since := time.Now().Add(-mischiefBurstWindow).Unix()
|
||||||
|
if n, err := storage.CountMischiefOrdersSince(u.Sub, since); err != nil {
|
||||||
|
slog.Error("mischief: burst count", "err", err)
|
||||||
|
writeMischiefError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
} else if n >= mischiefBurstMax {
|
||||||
|
writeMischiefError(w, http.StatusTooManyRequests, "slow down — too many orders in a short while")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := storage.InsertMischiefOrder(u.Sub, buyer, mark.Token, mark.Name, tier.Key, req.Signed)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("mischief: insert order", "err", err)
|
||||||
|
writeMischiefError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("mischief: order placed", "guid", order.GUID, "buyer", buyer, "tier", tier.Key, "signed", req.Signed)
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMischiefOrders returns the signed-in buyer's own orders for the status
|
||||||
|
// panel, newest first. Scoped to their OIDC subject — a buyer sees their history
|
||||||
|
// and no one else's.
|
||||||
|
func (s *Server) handleMischiefOrders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := s.requireUser(w, r)
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orders, err := storage.MischiefOrdersByBuyer(u.Sub, 20)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("mischief: orders by buyer", "err", err)
|
||||||
|
writeMischiefError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if orders == nil {
|
||||||
|
orders = []storage.MischiefOrder{}
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mischiefCatalogResp is what the storefront reads to draw itself: the live price
|
||||||
|
// list plus the signed-in buyer's own advisory balance, so it can grey out tiers
|
||||||
|
// they plainly can't afford. HasBalance distinguishes "gogobee says €0" from
|
||||||
|
// "gogobee has never mentioned this buyer" — the latter shows no affordability
|
||||||
|
// hint rather than a discouraging, possibly-wrong zero.
|
||||||
|
type mischiefCatalogResp struct {
|
||||||
|
Tiers []storage.MischiefTier `json:"tiers"`
|
||||||
|
Euro float64 `json:"euro"`
|
||||||
|
HasBalance bool `json:"has_balance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMischiefCatalog serves the price list and the buyer's balance together.
|
||||||
|
func (s *Server) handleMischiefCatalog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := s.requireUser(w, r)
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tiers, err := storage.MischiefTiers()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("mischief: catalog", "err", err)
|
||||||
|
writeMischiefError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tiers == nil {
|
||||||
|
tiers = []storage.MischiefTier{}
|
||||||
|
}
|
||||||
|
resp := mischiefCatalogResp{Tiers: tiers}
|
||||||
|
if lp := buyerLocalpart(u); lp != "" {
|
||||||
|
euro, has, err := storage.UserEuro(lp)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("mischief: balance", "err", err)
|
||||||
|
} else {
|
||||||
|
resp.Euro, resp.HasBalance = euro, has
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the gogobee wire: bearer-authed, idempotent -------------------------------
|
||||||
|
|
||||||
|
// handleMischiefPending is gogobee's poll: every order still waiting to be acted
|
||||||
|
// on. A pending order carries no intermediate state, so unlike escrow there is no
|
||||||
|
// stale-reoffer window — a gogobee that dies mid-claim simply leaves the order
|
||||||
|
// pending to be offered again, and the guid makes the replay a no-op.
|
||||||
|
func (s *Server) handleMischiefPending(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orders, err := storage.PendingMischiefOrders(mischiefPollLimit)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("mischief: pending", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if orders == nil {
|
||||||
|
orders = []storage.MischiefOrder{}
|
||||||
|
}
|
||||||
|
writeJSON(w, orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mischiefPollLimit caps one poll, matching the escrow seam.
|
||||||
|
const mischiefPollLimit = 50
|
||||||
|
|
||||||
|
// mischiefVerdict is gogobee's answer on a claimed order: the terminal status and
|
||||||
|
// a human note to render.
|
||||||
|
type mischiefVerdict struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMischiefClaim files gogobee's verdict against a pending order. Idempotent:
|
||||||
|
// gogobee's poll loop retries, so the same verdict can arrive more than once and
|
||||||
|
// only the first one moves the order. An unknown guid is a 400 — by this point
|
||||||
|
// gogobee has already debited the buyer for an order Pete has no record of, and
|
||||||
|
// under the seam's contract a 400 parks the row for a human rather than retrying
|
||||||
|
// forever against a row that will never exist.
|
||||||
|
func (s *Server) handleMischiefClaim(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var v mischiefVerdict
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.GUID == "" {
|
||||||
|
http.Error(w, "guid is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := storage.ResolveMischiefOrder(v.GUID, v.Status, v.Detail)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchOrder) {
|
||||||
|
slog.Error("mischief: verdict for an order we have never heard of — "+
|
||||||
|
"gogobee may have debited a buyer for it", "guid", v.GUID, "status", v.Status)
|
||||||
|
http.Error(w, "no such order", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// A malformed verdict status lands here — also a contract mismatch, so 400.
|
||||||
|
slog.Error("mischief: resolve", "guid", v.GUID, "status", v.Status, "err", err)
|
||||||
|
http.Error(w, "bad verdict", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("mischief: order resolved", "guid", order.GUID, "status", order.Status)
|
||||||
|
writeJSON(w, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMischiefError(w http.ResponseWriter, code int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
|
}
|
||||||
222
internal/web/mischief_test.go
Normal file
222
internal/web/mischief_test.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newStore is a server with the adventure seam on, a signed-in buyer, and a
|
||||||
|
// board + catalog already pushed. Auth is built by hand for the same reason the
|
||||||
|
// casino tests do it: the OIDC handshake is a network call and none of what's
|
||||||
|
// under test is about it.
|
||||||
|
func newStore(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
push := rosterPush{
|
||||||
|
SnapshotAt: now,
|
||||||
|
Adventurers: []storage.RosterEntry{entry("tok-josie", "Josie", "expedition", "holymachina")},
|
||||||
|
Tiers: []storage.MischiefTier{
|
||||||
|
{Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50},
|
||||||
|
{Key: "boss", Display: "Boss", Fee: 1200, SignedFee: 1500},
|
||||||
|
},
|
||||||
|
Balances: []storage.MischiefBalance{{Username: "reala", Euro: 500}},
|
||||||
|
}
|
||||||
|
if w := postRoster(t, s, "tok", push); w.Code != 200 {
|
||||||
|
t.Fatalf("seed roster = %d", w.Code)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonReq builds a bearer-authed (or unauthed, empty token) machine request.
|
||||||
|
func jsonReq(t *testing.T, method, path, token string, body any) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if body != nil {
|
||||||
|
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest(method, path, &buf)
|
||||||
|
if token != "" {
|
||||||
|
r.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeOrder(t *testing.T, s *Server, username string, req mischiefOrderReq) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
r := as(t, s, username, "POST", "/api/mischief/order", req)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleMischiefOrder(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMischiefOrderHappyPath: a signed-in buyer names a mark on the board and a
|
||||||
|
// tier gogobee offers, and gets a pending order back.
|
||||||
|
func TestMischiefOrderHappyPath(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
|
||||||
|
w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt", Signed: false})
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("order = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var o storage.MischiefOrder
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if o.Status != storage.MischiefPending || o.BuyerUsername != "reala" || o.TargetName != "Josie" {
|
||||||
|
t.Fatalf("order = %+v", o)
|
||||||
|
}
|
||||||
|
if pending, _ := storage.PendingMischiefOrders(10); len(pending) != 1 {
|
||||||
|
t.Fatalf("order didn't land in the pending set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMischiefOrderRejections(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
|
||||||
|
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "dragon"}); w.Code != 400 {
|
||||||
|
t.Errorf("unknown tier = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "ghost", Tier: "grunt"}); w.Code != 404 {
|
||||||
|
t.Errorf("off-board target = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
if w := placeOrder(t, s, "reala", mischiefOrderReq{Tier: "grunt"}); w.Code != 400 {
|
||||||
|
t.Errorf("empty target = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
// Signed in, but no username the ledger can name (a pre-storefront session).
|
||||||
|
if w := placeOrder(t, s, "", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 409 {
|
||||||
|
t.Errorf("usernameless session = %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMischiefOrderBurstGuard(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
for i := 0; i < mischiefBurstMax; i++ {
|
||||||
|
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 200 {
|
||||||
|
t.Fatalf("order %d = %d, want 200", i, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 429 {
|
||||||
|
t.Fatalf("over-cap order = %d, want 429", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMischiefOrderRequiresAuth(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
r := httptest.NewRequest("POST", "/api/mischief/order", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleMischiefOrder(w, r)
|
||||||
|
if w.Code != 401 {
|
||||||
|
t.Fatalf("anonymous order = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMischiefCatalogCarriesBalance(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
r := as(t, s, "reala", "GET", "/api/mischief/catalog", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleMischiefCatalog(w, r)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("catalog = %d", w.Code)
|
||||||
|
}
|
||||||
|
var resp mischiefCatalogResp
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(resp.Tiers) != 2 || !resp.HasBalance || resp.Euro != 500 {
|
||||||
|
t.Fatalf("catalog resp = %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the bearer wire -----------------------------------------------------------
|
||||||
|
|
||||||
|
func TestMischiefPendingAndClaimBearer(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "boss", Signed: true}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed order = %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing bearer is rejected.
|
||||||
|
{
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "", nil))
|
||||||
|
if w.Code != 401 {
|
||||||
|
t.Fatalf("no-bearer pending = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll returns the order.
|
||||||
|
var pending []storage.MischiefOrder
|
||||||
|
{
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "tok", nil))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("pending = %d", w.Code)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pending) != 1 {
|
||||||
|
t.Fatalf("pending count = %d, want 1", len(pending))
|
||||||
|
}
|
||||||
|
guid := pending[0].GUID
|
||||||
|
|
||||||
|
// Claim it placed.
|
||||||
|
{
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok",
|
||||||
|
mischiefVerdict{GUID: guid, Status: storage.MischiefPlaced, Detail: "out for delivery"}))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("claim = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got, _ := storage.MischiefOrderByGUID(guid); got.Status != storage.MischiefPlaced {
|
||||||
|
t.Fatalf("order status after claim = %q", got.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it's gone from the pending set.
|
||||||
|
{
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "tok", nil))
|
||||||
|
var again []storage.MischiefOrder
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &again)
|
||||||
|
if len(again) != 0 {
|
||||||
|
t.Fatalf("placed order still polled: %d", len(again))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMischiefClaimUnknownGUIDis400(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok",
|
||||||
|
mischiefVerdict{GUID: "nope", Status: storage.MischiefPlaced}))
|
||||||
|
if w.Code != 400 {
|
||||||
|
t.Fatalf("unknown-guid claim = %d, want 400 (so gogobee parks it)", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMischiefClaimBadVerdictIs400(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 200 {
|
||||||
|
t.Fatal("seed")
|
||||||
|
}
|
||||||
|
pending, _ := storage.PendingMischiefOrders(10)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok",
|
||||||
|
mischiefVerdict{GUID: pending[0].GUID, Status: "exploded"}))
|
||||||
|
if w.Code != 400 {
|
||||||
|
t.Fatalf("bad-verdict claim = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,13 +41,26 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// rosterPush is the payload gogobee POSTs to /api/ingest/roster.
|
// rosterPush is the payload gogobee POSTs to /api/ingest/roster.
|
||||||
|
//
|
||||||
|
// Balances ride the same tick as the board but live in a separate keyspace: they
|
||||||
|
// are keyed by localpart (a buyer's own sign-in name), not by the anonymous
|
||||||
|
// roster token, and Pete only ever reads one back for the authenticated user
|
||||||
|
// asking about themselves. So the board stays anonymous while the storefront can
|
||||||
|
// still grey out tiers a buyer plainly can't afford.
|
||||||
type rosterPush struct {
|
type rosterPush struct {
|
||||||
SnapshotAt int64 `json:"snapshot_at"`
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
Adventurers []storage.RosterEntry `json:"adventurers"`
|
Adventurers []storage.RosterEntry `json:"adventurers"`
|
||||||
|
Balances []storage.MischiefBalance `json:"balances,omitempty"`
|
||||||
|
Tiers []storage.MischiefTier `json:"tiers,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RosterView is one row as the page renders it.
|
// RosterView is one row as the page renders it.
|
||||||
|
//
|
||||||
|
// Token is the mark's anonymous roster token, exposed so the storefront can name
|
||||||
|
// a target without ever knowing their real handle. It is safe on a public page by
|
||||||
|
// design — non-reversible, stable, and already how gogobee keys the board.
|
||||||
type RosterView struct {
|
type RosterView struct {
|
||||||
|
Token string
|
||||||
Name string
|
Name string
|
||||||
Level int
|
Level int
|
||||||
ClassRace string
|
ClassRace string
|
||||||
@@ -103,7 +116,66 @@ func (s *Server) handleRosterIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers))
|
// Advisory balances are best-effort: a board that landed is worth keeping
|
||||||
|
// even if the balances behind it didn't, so a failure here is logged, not
|
||||||
|
// fatal. Stale affordability only ever bounces an order, never miscounts money.
|
||||||
|
if err := storage.ReplaceUserEuro(push.Balances, push.SnapshotAt); err != nil {
|
||||||
|
slog.Error("roster ingest: balance replace failed", "err", err)
|
||||||
|
}
|
||||||
|
// The tier catalog is likewise best-effort and only refreshed when the push
|
||||||
|
// actually carried one, so a gogobee build that predates the storefront (no
|
||||||
|
// tiers field) can't wipe a catalog a newer one already established.
|
||||||
|
if len(push.Tiers) > 0 {
|
||||||
|
if err := storage.ReplaceMischiefTiers(push.Tiers); err != nil {
|
||||||
|
slog.Error("roster ingest: tier replace failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers), "balances", len(push.Balances))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// detailPush is the payload gogobee POSTs to /api/ingest/detail: the private,
|
||||||
|
// owner-only expansion for every player, in its own keyspace (keyed by localpart)
|
||||||
|
// and on its own endpoint so a fat inventory can't blow the roster push's budget.
|
||||||
|
type detailPush struct {
|
||||||
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
|
Players []storage.PlayerDetail `json:"players"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDetailIngest replaces the private self-detail set with gogobee's latest
|
||||||
|
// push. Bearer-authed like the roster; the data is owner-private and only ever
|
||||||
|
// served back to the one authenticated user it belongs to (see PlayerDetailByOwner).
|
||||||
|
func (s *Server) handleDetailIngest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.adv.Enabled {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A generous cap: this carries every player's inventory + vault, heavier than
|
||||||
|
// the board, but still a bounded realm — the ceiling only stops a runaway push.
|
||||||
|
var push detailPush
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&push); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(push.Players) > rosterMaxEntries {
|
||||||
|
http.Error(w, "detail set too large", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if push.SnapshotAt <= 0 {
|
||||||
|
push.SnapshotAt = time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.ReplacePlayerDetail(push.Players, push.SnapshotAt); err != nil {
|
||||||
|
slog.Error("detail ingest: replace failed", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("detail ingest: self-view set replaced", "players", len(push.Players))
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +214,7 @@ func (s *Server) roster() (views []RosterView, stale bool, snapshotAt int64) {
|
|||||||
|
|
||||||
func toRosterView(e storage.RosterEntry) RosterView {
|
func toRosterView(e storage.RosterEntry) RosterView {
|
||||||
v := RosterView{
|
v := RosterView{
|
||||||
|
Token: e.Token,
|
||||||
Name: e.Name,
|
Name: e.Name,
|
||||||
Level: e.Level,
|
Level: e.Level,
|
||||||
ClassRace: e.ClassRace,
|
ClassRace: e.ClassRace,
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ type Server struct {
|
|||||||
metricsMu sync.Mutex
|
metricsMu sync.Mutex
|
||||||
saltDay int64
|
saltDay int64
|
||||||
salt [16]byte
|
salt [16]byte
|
||||||
|
|
||||||
|
// The shared-table machinery. hub fans SSE frames out to the phones at a felt;
|
||||||
|
// tableLocks is the striped optimisation over the DB's version column (see
|
||||||
|
// games_table.go). Both are nil-safe to construct always: they cost nothing
|
||||||
|
// until a table is opened.
|
||||||
|
hub *gamesHub
|
||||||
|
tableLocks *stripedLocks
|
||||||
|
tableGames []tableGame // the multiplayer engines, dispatched through games()
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds the server. Templates are parsed once at startup. Each page gets
|
// New builds the server. Templates are parsed once at startup. Each page gets
|
||||||
@@ -94,8 +102,8 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
shared []string
|
shared []string
|
||||||
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", "who"}},
|
||||||
{"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 {
|
||||||
@@ -136,7 +144,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
live = append(live, ch)
|
live = append(live, ch)
|
||||||
}
|
}
|
||||||
|
|
||||||
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live}
|
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}, unoTable{}}}
|
||||||
|
|
||||||
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
|
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
|
||||||
// provider is unreachable at boot we log and serve anonymously rather than
|
// provider is unreachable at boot we log and serve anonymously rather than
|
||||||
@@ -211,6 +219,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest)
|
mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest)
|
||||||
mux.HandleFunc("GET /api/roster", s.handleRosterAPI)
|
mux.HandleFunc("GET /api/roster", s.handleRosterAPI)
|
||||||
|
|
||||||
|
// The private, owner-only self-detail (inventory/house/pets). Bearer-authed
|
||||||
|
// ingest like the roster; there is no public read — it is only ever served
|
||||||
|
// back to its owner, inline on the detail page below.
|
||||||
|
mux.HandleFunc("POST /api/ingest/detail", s.handleDetailIngest)
|
||||||
|
|
||||||
|
// One adventurer's detail page and its live re-poll. Both public (stats +
|
||||||
|
// gear); the page adds the owner's private extras when the signed-in viewer
|
||||||
|
// owns it. Three path segments, so it never overlaps /adventure/{guid} (two)
|
||||||
|
// or /adventure/art/{type}.
|
||||||
|
mux.HandleFunc("GET /adventure/who/{token}", s.handleAdventureWho)
|
||||||
|
mux.HandleFunc("GET /api/adventure/who/{token}", s.handleAdventureWhoAPI)
|
||||||
|
|
||||||
// Per-dispatch permalink (the article_url every ingested story points at).
|
// Per-dispatch permalink (the article_url every ingested story points at).
|
||||||
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
|
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
|
||||||
// channel listing, registered in the channels loop above).
|
// channel listing, registered in the channels loop above).
|
||||||
@@ -228,6 +248,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim)
|
mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim)
|
||||||
mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled)
|
mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled)
|
||||||
|
|
||||||
|
// The mischief storefront's game-box wire: gogobee polls pending orders and
|
||||||
|
// pushes a verdict. Bearer-authed on the same ingest token, same reason as the
|
||||||
|
// escrow seam — the caller is a machine on the tailnet. The buyer-facing half
|
||||||
|
// hangs off the auth block below.
|
||||||
|
mux.HandleFunc("GET /api/mischief/pending", s.handleMischiefPending)
|
||||||
|
mux.HandleFunc("POST /api/mischief/claim", s.handleMischiefClaim)
|
||||||
|
|
||||||
// The casino. Signed-in only — there is money in it — so these hang off the
|
// The casino. Signed-in only — there is money in it — so these hang off the
|
||||||
// auth block, and gamesReady() also insists on a Matrix server name: without
|
// auth block, and gamesReady() also insists on a Matrix server name: without
|
||||||
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
||||||
@@ -246,6 +273,16 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
mux.HandleFunc("GET /api/state", s.handleState)
|
mux.HandleFunc("GET /api/state", s.handleState)
|
||||||
mux.HandleFunc("GET /bookmarks", s.handleBookmarks)
|
mux.HandleFunc("GET /bookmarks", s.handleBookmarks)
|
||||||
mux.HandleFunc("GET /for-you", s.handleForYou)
|
mux.HandleFunc("GET /for-you", s.handleForYou)
|
||||||
|
|
||||||
|
// The mischief storefront, buyer side. Signed-in only — an order names a
|
||||||
|
// buyer to gogobee's ledger — so like the casino it hangs off the auth
|
||||||
|
// block. Only registered when the adventure seam is on; otherwise there is
|
||||||
|
// no board to order a hit from.
|
||||||
|
if s.adv.Enabled {
|
||||||
|
mux.HandleFunc("GET /api/mischief/catalog", s.handleMischiefCatalog)
|
||||||
|
mux.HandleFunc("POST /api/mischief/order", s.handleMischiefOrder)
|
||||||
|
mux.HandleFunc("GET /api/mischief/orders", s.handleMischiefOrders)
|
||||||
|
}
|
||||||
if s.cfg.Push.Enabled {
|
if s.cfg.Push.Enabled {
|
||||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||||
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -2047,22 +2096,30 @@ html[data-room] .cs-room {
|
|||||||
radial-gradient(120% 90% at 50% 120%, rgba(0,0,0,0.45), transparent 55%);
|
radial-gradient(120% 90% at 50% 120%, rgba(0,0,0,0.45), transparent 55%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Casino Night is lit by bulbs, so it gets a few of them: a slow chase across
|
/* Casino Night is lit by bulbs, so it gets a row of them: a slow chase across
|
||||||
the top of the room. Cheap (one gradient, one transform) and it makes the
|
the top of the room. One radial tile repeated, one transform — each bulb is a
|
||||||
place feel plugged in rather than painted. */
|
hot white core in a yellow body, and the glow is a pair of drop-shadows so it
|
||||||
|
actually throws light rather than being a painted dot. */
|
||||||
html[data-room="casino-night"] .cs-room::before {
|
html[data-room="casino-night"] .cs-room::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0 -50% auto -50%;
|
inset: 0.4rem -50% auto -50%;
|
||||||
height: 0.5rem;
|
height: 0.7rem;
|
||||||
background: repeating-linear-gradient(90deg,
|
background:
|
||||||
rgba(255,204,47,0.55) 0 0.5rem, transparent 0.5rem 2.25rem);
|
radial-gradient(circle at center,
|
||||||
filter: blur(1px);
|
#fffbe6 0 0.14rem,
|
||||||
animation: cs-bulbs 2.4s linear infinite;
|
#ffcc2f 0.14rem 0.26rem,
|
||||||
|
rgba(255,204,47,0.45) 0.26rem 0.42rem,
|
||||||
|
transparent 0.44rem)
|
||||||
|
0.3rem 50% / 1.9rem 100% repeat-x;
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 0 3px rgba(255,214,90,0.95))
|
||||||
|
drop-shadow(0 0 10px rgba(255,204,47,0.6));
|
||||||
|
animation: cs-bulbs 1.8s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes cs-bulbs {
|
@keyframes cs-bulbs {
|
||||||
from { transform: translateX(0); }
|
from { transform: translateX(0); }
|
||||||
to { transform: translateX(2.75rem); }
|
to { transform: translateX(1.9rem); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The house shadow, restruck for a dark room: the news app's soft brown lift is
|
/* The house shadow, restruck for a dark room: the news app's soft brown lift is
|
||||||
@@ -2320,6 +2377,27 @@ html[data-room] .pete-felt {
|
|||||||
.pete-poker-verdict[data-tone="win"] { background: #4caf7d; color: #fff; }
|
.pete-poker-verdict[data-tone="win"] { background: #4caf7d; color: #fff; }
|
||||||
.pete-poker-verdict[data-tone="lose"] { background: rgba(255,255,255,0.9); color: #7a5c50; }
|
.pete-poker-verdict[data-tone="lose"] { background: rgba(255,255,255,0.9); color: #7a5c50; }
|
||||||
|
|
||||||
|
/* The rail. Chat runs along the felt — messages only, no typing indicators, and
|
||||||
|
it never leaves for Matrix. Your own lines lean the other way and take the
|
||||||
|
accent, so a glance tells you who is talking. */
|
||||||
|
.pete-chat-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 0.15rem 0.1rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.pete-chat-who {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.pete-chat-body {
|
||||||
|
color: color-mix(in srgb, var(--ink) 85%, transparent);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.pete-chat-mine .pete-chat-who { color: color-mix(in srgb, var(--ink) 55%, transparent); }
|
||||||
|
|
||||||
/* The action bar. Raise is a slider, because a raise is a *size* and a text box
|
/* The action bar. Raise is a slider, because a raise is a *size* and a text box
|
||||||
makes you type a number you have not thought about. */
|
makes you type a number you have not thought about. */
|
||||||
.pete-raise {
|
.pete-raise {
|
||||||
|
|||||||
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 money on the felt -------------------------------------------------
|
// ---- 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.
|
||||||
|
|
||||||
// stake moves chips from your pile onto the spot: the bet you build before a
|
var hands = [];
|
||||||
// deal, and the second bet a double puts down beside it.
|
|
||||||
function stake(amount, from) {
|
function makeHand(at) {
|
||||||
return spot.pour(from || purseEl, amount);
|
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 -------------------------------------------------
|
||||||
|
|
||||||
// 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.
|
||||||
return spot
|
var back = payout - h.bet;
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -60,6 +60,14 @@
|
|||||||
var boughtEl = root.querySelector("[data-bought-in]");
|
var boughtEl = root.querySelector("[data-bought-in]");
|
||||||
var rakeEl = root.querySelector("[data-session-rake]");
|
var rakeEl = root.querySelector("[data-session-rake]");
|
||||||
|
|
||||||
|
var chatSection = root.querySelector("[data-chat]");
|
||||||
|
var chatLog = root.querySelector("[data-chat-log]");
|
||||||
|
var chatSeen = {}; // chat line ids already on the rail, to drop echoed duplicates
|
||||||
|
var chatForm = root.querySelector("[data-chat-form]");
|
||||||
|
var chatInput = root.querySelector("[data-chat-input]");
|
||||||
|
var lobbyWrap = root.querySelector("[data-lobby-wrap]");
|
||||||
|
var lobbyEl = root.querySelector("[data-lobby]");
|
||||||
|
|
||||||
var sitBtn = root.querySelector("[data-sit]");
|
var sitBtn = root.querySelector("[data-sit]");
|
||||||
var buySlider = root.querySelector("[data-buyin-slider]");
|
var buySlider = root.querySelector("[data-buyin-slider]");
|
||||||
var buyLabel = root.querySelector("[data-buyin]");
|
var buyLabel = root.querySelector("[data-buyin]");
|
||||||
@@ -70,7 +78,10 @@
|
|||||||
var tableMsg = root.querySelector("[data-table-msg]");
|
var tableMsg = root.querySelector("[data-table-msg]");
|
||||||
|
|
||||||
var view = null; // the table, as the server last described it
|
var view = null; // the table, as the server last described it
|
||||||
|
var me = 0; // which seat is yours — no longer always zero, at a shared table
|
||||||
var busy = false;
|
var busy = false;
|
||||||
|
var pendingSync = false; // a pushed frame arrived mid-animation; re-render when free
|
||||||
|
var stream = null; // the open EventSource, when seated
|
||||||
var seatEls = []; // one per seat: { root, cards, plate, stack, spot }
|
var seatEls = []; // one per seat: { root, cards, plate, stack, spot }
|
||||||
var shown = []; // what each seat's stack label currently reads
|
var shown = []; // what each seat's stack label currently reads
|
||||||
var pot = null; // the middle pile, a PeteFX.spot
|
var pot = null; // the middle pile, a PeteFX.spot
|
||||||
@@ -171,6 +182,7 @@
|
|||||||
|
|
||||||
function render(v) {
|
function render(v) {
|
||||||
view = v;
|
view = v;
|
||||||
|
me = v.your_seat || 0;
|
||||||
|
|
||||||
// The seats along the top, and you underneath.
|
// The seats along the top, and you underneath.
|
||||||
seatsEl.innerHTML = "";
|
seatsEl.innerHTML = "";
|
||||||
@@ -178,7 +190,7 @@
|
|||||||
seatEls = [];
|
seatEls = [];
|
||||||
shown = [];
|
shown = [];
|
||||||
v.seats.forEach(function (s, i) {
|
v.seats.forEach(function (s, i) {
|
||||||
var mine = i === 0;
|
var mine = i === me;
|
||||||
var built = seat(s, i, mine);
|
var built = seat(s, i, mine);
|
||||||
seatEls[i] = built;
|
seatEls[i] = built;
|
||||||
shown[i] = s.stack;
|
shown[i] = s.stack;
|
||||||
@@ -222,11 +234,12 @@
|
|||||||
// last hand, but the table you sit down at is the one that's open to you.
|
// last hand, but the table you sit down at is the one that's open to you.
|
||||||
var live = !!view && view.phase !== "done";
|
var live = !!view && view.phase !== "done";
|
||||||
sitting.classList.toggle("hidden", live);
|
sitting.classList.toggle("hidden", live);
|
||||||
acting.classList.toggle("hidden", !live || view.phase !== "betting" || view.to_act !== 0);
|
acting.classList.toggle("hidden", !live || view.phase !== "betting" || view.to_act !== me);
|
||||||
between.classList.toggle("hidden", !live || view.phase !== "handover");
|
between.classList.toggle("hidden", !live || view.phase !== "handover");
|
||||||
|
if (chatSection) chatSection.classList.toggle("hidden", !live);
|
||||||
if (!live) return;
|
if (!live) return;
|
||||||
|
|
||||||
if (view.phase === "betting" && view.to_act === 0) {
|
if (view.phase === "betting" && view.to_act === me) {
|
||||||
checkBtn.classList.toggle("hidden", !view.can_check);
|
checkBtn.classList.toggle("hidden", !view.can_check);
|
||||||
callBtn.classList.toggle("hidden", view.can_check);
|
callBtn.classList.toggle("hidden", view.can_check);
|
||||||
callAmt.textContent = money(view.owed);
|
callAmt.textContent = money(view.owed);
|
||||||
@@ -274,18 +287,22 @@
|
|||||||
// happening happens here; render() is the state it settles into.
|
// happening happens here; render() is the state it settles into.
|
||||||
function play(events, final) {
|
function play(events, final) {
|
||||||
var chain = Promise.resolve();
|
var chain = Promise.resolve();
|
||||||
// No table to settle into — the session closed and storage has already cleared
|
if (!events || !events.length) {
|
||||||
// it. There is nothing to animate onto, and render() would walk seats that
|
// Nothing to animate. Either settle into the table we ended on, or — if the
|
||||||
// aren't there.
|
// session closed and storage cleared it — clear the felt.
|
||||||
if (!final) { render0(); return Promise.resolve(); }
|
if (final) render(final); else render0();
|
||||||
if (!events || !events.length) { render(final); return chain; }
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
events.forEach(function (e) {
|
events.forEach(function (e) {
|
||||||
chain = chain.then(function () { return beat(e, final); });
|
chain = chain.then(function () { return beat(e, final); });
|
||||||
});
|
});
|
||||||
return chain.then(function () {
|
return chain.then(function () {
|
||||||
render(final);
|
// A hand can be the last one — a bust closes the table, so there is no state
|
||||||
|
// to settle into. Play it out and say what it did anyway (the seats are still
|
||||||
|
// on the felt from the previous render), then clear.
|
||||||
verdict(events, final);
|
verdict(events, final);
|
||||||
|
if (final) render(final); else render0();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +353,7 @@
|
|||||||
|
|
||||||
case "show":
|
case "show":
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
paintCards(s.cards, { state: "active", cards: e.cards }, e.seat === 0);
|
paintCards(s.cards, { state: "active", cards: e.cards }, e.seat === me);
|
||||||
flash(s.root);
|
flash(s.root);
|
||||||
return wait(420);
|
return wait(420);
|
||||||
|
|
||||||
@@ -353,7 +370,7 @@
|
|||||||
return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () {
|
return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () {
|
||||||
potTotal.textContent = money(pot.amount);
|
potTotal.textContent = money(pot.amount);
|
||||||
moveStack(e.seat, e.amount);
|
moveStack(e.seat, e.amount);
|
||||||
if (e.seat === 0 && e.amount > 0) FX.burst(s.plate, { count: 18 });
|
if (e.seat === me && e.amount > 0) FX.burst(s.plate, { count: 18 });
|
||||||
return wait(260);
|
return wait(260);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -375,8 +392,8 @@
|
|||||||
if (s.state === "out") return;
|
if (s.state === "out") return;
|
||||||
var built = seatEls[i];
|
var built = seatEls[i];
|
||||||
if (!built) return;
|
if (!built) return;
|
||||||
var face = (i === 0 && s.cards) ? s.cards[round] : null;
|
var face = (i === me && s.cards) ? s.cards[round] : null;
|
||||||
var card = Cards.el(face, { deal: true, tilt: i !== 0 });
|
var card = Cards.el(face, { deal: true, tilt: i !== me });
|
||||||
built.cards.appendChild(card);
|
built.cards.appendChild(card);
|
||||||
FX.sfx("deal", { delay: 0.07 * i, v: i });
|
FX.sfx("deal", { delay: 0.07 * i, v: i });
|
||||||
beats.push(wait(70 * i));
|
beats.push(wait(70 * i));
|
||||||
@@ -477,25 +494,25 @@
|
|||||||
function verdict(events, final) {
|
function verdict(events, final) {
|
||||||
var won = 0, showed = false, busted = false;
|
var won = 0, showed = false, busted = false;
|
||||||
events.forEach(function (e) {
|
events.forEach(function (e) {
|
||||||
if (e.kind === "win" && e.seat === 0) won += e.amount;
|
if (e.kind === "win" && e.seat === me) won += e.amount;
|
||||||
if (e.kind === "show") showed = true;
|
if (e.kind === "show") showed = true;
|
||||||
if (e.kind === "bust") busted = true;
|
if (e.kind === "bust" && e.seat === me) busted = true;
|
||||||
});
|
});
|
||||||
if (busted) {
|
if (busted) {
|
||||||
show("You're out of chips. Sit down again when you're ready.", "lose");
|
show("You're out of chips. Sit down again when you're ready.", "lose");
|
||||||
FX.sfx("lose");
|
FX.sfx("lose");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!events.some(function (e) { return e.kind === "end"; })) return;
|
if (!final || !events.some(function (e) { return e.kind === "end"; })) return;
|
||||||
|
|
||||||
var me = final.seats[0];
|
var mine = final.seats[me];
|
||||||
if (won > 0) {
|
if (won > 0) {
|
||||||
// The pot coming your way already burst (and so already cheered) back in
|
// The pot coming your way already burst (and so already cheered) back in
|
||||||
// the "win" event. This is only the words.
|
// the "win" event. This is only the words.
|
||||||
show(showed
|
show(showed
|
||||||
? "You win " + money(won) + " with " + article(handName(events)) + "."
|
? "You win " + money(won) + " with " + article(handName(events)) + "."
|
||||||
: "They folded. You take " + money(won) + ".", "win");
|
: "They folded. You take " + money(won) + ".", "win");
|
||||||
} else if (me.state === "folded") {
|
} else if (mine.state === "folded") {
|
||||||
show("Folded.", "lose");
|
show("Folded.", "lose");
|
||||||
} else {
|
} else {
|
||||||
show("No good this time.", "lose");
|
show("No good this time.", "lose");
|
||||||
@@ -510,7 +527,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handName(events) {
|
function handName(events) {
|
||||||
var mine = events.filter(function (e) { return e.kind === "show" && e.seat === 0; })[0];
|
var mine = events.filter(function (e) { return e.kind === "show" && e.seat === me; })[0];
|
||||||
return mine && mine.text ? mine.text : "the best hand";
|
return mine && mine.text ? mine.text : "the best hand";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,26 +573,48 @@
|
|||||||
.post("/api/games/holdem/move", body)
|
.post("/api/games/holdem/move", body)
|
||||||
.then(function (v) {
|
.then(function (v) {
|
||||||
window.PeteGames.apply(v);
|
window.PeteGames.apply(v);
|
||||||
return play(v.holdem_events, v.holdem || null).then(function () {
|
// No table came back: the session ended inside this move — a bust closes a
|
||||||
if (!v.holdem) { render0(); return; }
|
// solo table. play() animates the last hand (its "bust" beat says the words)
|
||||||
if (v.holdem.phase === "done") {
|
// and clears the felt.
|
||||||
var got = v.holdem.payout, put = v.holdem.bought_in;
|
return play(v.holdem_events, v.holdem || null);
|
||||||
say(tableMsg, got > put
|
|
||||||
? "You got up " + money(got - put) + " ahead. " + money(got) + " back on your stack."
|
|
||||||
: got === 0
|
|
||||||
? "Cleaned out. Better luck at the next table."
|
|
||||||
: "You got up " + money(put - got) + " down. " + money(got) + " back on your stack.");
|
|
||||||
setTimeout(render0, 2600);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch(function (err) { say(msgEl, err.message, "bad"); })
|
.catch(function (err) { say(msgEl, err.message, "bad"); })
|
||||||
.then(function () { busy = false; lock(false); });
|
.then(function () { busy = false; lock(false); flushSync(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushSync applies a table frame that arrived while a hand was animating. The
|
||||||
|
// felt is now settled, so the held re-render will not clobber a script.
|
||||||
|
function flushSync() {
|
||||||
|
if (pendingSync) { pendingSync = false; sync(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// leave gets you up from the table. It is its own endpoint, not a move: the
|
||||||
|
// chips cross back and the felt may close behind you, neither of which is a play
|
||||||
|
// in a hand. The felt clears and the stack you took is reported from what was in
|
||||||
|
// front of you a moment ago.
|
||||||
|
function leave() {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
lock(true);
|
||||||
|
var stack = view ? view.stack : 0;
|
||||||
|
verdictEl.classList.add("hidden");
|
||||||
|
window.PeteGames
|
||||||
|
.post("/api/games/holdem/leave", {})
|
||||||
|
.then(function (v) {
|
||||||
|
window.PeteGames.apply(v);
|
||||||
|
render0();
|
||||||
|
say(tableMsg, stack > 0
|
||||||
|
? money(stack) + " back on your stack. Sit down again when you're ready."
|
||||||
|
: "");
|
||||||
|
})
|
||||||
|
.catch(function (err) { say(betweenMsg, err.message, "bad"); })
|
||||||
|
.then(function () { busy = false; lock(false); flushSync(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// render0 is the table with nobody at it.
|
// render0 is the table with nobody at it.
|
||||||
function render0() {
|
function render0() {
|
||||||
view = null;
|
view = null;
|
||||||
|
unseated();
|
||||||
seatsEl.innerHTML = "";
|
seatsEl.innerHTML = "";
|
||||||
youEl.innerHTML = "";
|
youEl.innerHTML = "";
|
||||||
boardEl.innerHTML = "";
|
boardEl.innerHTML = "";
|
||||||
@@ -584,6 +623,7 @@
|
|||||||
sideEl.classList.add("hidden");
|
sideEl.classList.add("hidden");
|
||||||
panels();
|
panels();
|
||||||
syncSit();
|
syncSit();
|
||||||
|
loadLobby();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (foldBtn) foldBtn.addEventListener("click", function () { send({ move: "fold" }, gameMsg); });
|
if (foldBtn) foldBtn.addEventListener("click", function () { send({ move: "fold" }, gameMsg); });
|
||||||
@@ -612,7 +652,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (dealBtn) dealBtn.addEventListener("click", function () { send({ move: "deal" }, betweenMsg); });
|
if (dealBtn) dealBtn.addEventListener("click", function () { send({ move: "deal" }, betweenMsg); });
|
||||||
if (leaveBtn) leaveBtn.addEventListener("click", function () { send({ move: "leave" }, betweenMsg); });
|
if (leaveBtn) leaveBtn.addEventListener("click", function () { leave(); });
|
||||||
if (topupBtn) topupBtn.addEventListener("click", function () {
|
if (topupBtn) topupBtn.addEventListener("click", function () {
|
||||||
send({ move: "topup", amount: Number(topupBtn.dataset.amount || 0) }, betweenMsg);
|
send({ move: "topup", amount: Number(topupBtn.dataset.amount || 0) }, betweenMsg);
|
||||||
});
|
});
|
||||||
@@ -668,6 +708,7 @@
|
|||||||
.then(function (v) {
|
.then(function (v) {
|
||||||
window.PeteGames.apply(v);
|
window.PeteGames.apply(v);
|
||||||
render(v.holdem);
|
render(v.holdem);
|
||||||
|
seated();
|
||||||
// A table with nobody dealt in yet is a table waiting for you to say go.
|
// A table with nobody dealt in yet is a table waiting for you to say go.
|
||||||
say(betweenMsg, "You're in. Deal when you're ready.");
|
say(betweenMsg, "You're in. Deal when you're ready.");
|
||||||
})
|
})
|
||||||
@@ -675,6 +716,171 @@
|
|||||||
.then(function () { busy = false; });
|
.then(function () { busy = false; });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- the live table --------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Poker is where the room stops being just you and the house: other people are
|
||||||
|
// at the felt, and what they do has to reach you without your asking. That is
|
||||||
|
// one EventSource. The server pushes a nudge when the table changes and a line
|
||||||
|
// when somebody speaks; the nudge is not the state (a hole card must never ride
|
||||||
|
// a frame that fans to the whole table), so on a nudge we refetch our own
|
||||||
|
// seat's view, which is authoritative and already redacted.
|
||||||
|
|
||||||
|
// seated opens the stream and loads the rail. Called the moment you sit down.
|
||||||
|
function seated() {
|
||||||
|
loadChat();
|
||||||
|
connectLive();
|
||||||
|
}
|
||||||
|
|
||||||
|
// unseated tears it down. Called when the felt clears — you got up, or busted.
|
||||||
|
function unseated() {
|
||||||
|
disconnectLive();
|
||||||
|
if (chatLog) chatLog.innerHTML = "";
|
||||||
|
chatSeen = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectLive() {
|
||||||
|
if (stream || !window.EventSource) return;
|
||||||
|
stream = new EventSource("/api/games/holdem/stream");
|
||||||
|
stream.onmessage = function (ev) {
|
||||||
|
var msg;
|
||||||
|
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
||||||
|
if (msg.type === "chat") addChat(msg.line);
|
||||||
|
else if (msg.type === "table") sync();
|
||||||
|
};
|
||||||
|
// The opening nudge (event: sync) just says "come and look".
|
||||||
|
stream.addEventListener("sync", function () { sync(); });
|
||||||
|
// EventSource reconnects itself on a dropped connection. The one case we don't
|
||||||
|
// want it retrying is a table that has closed (the stream 409s) — but by then
|
||||||
|
// we have called unseated() and closed it ourselves.
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectLive() {
|
||||||
|
if (stream) { stream.close(); stream = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// sync re-renders the felt from the authoritative table, but never mid-animation
|
||||||
|
// — a frame that lands while a hand is playing is held and applied once the
|
||||||
|
// script finishes, or it would repaint the table out from under it.
|
||||||
|
function sync() {
|
||||||
|
if (busy) { pendingSync = true; return; }
|
||||||
|
window.PeteGames.refresh().then(function (v) {
|
||||||
|
if (!v) return;
|
||||||
|
if (v.holdem) render(v.holdem); else { render0(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the rail --------------------------------------------------------------
|
||||||
|
|
||||||
|
function loadChat() {
|
||||||
|
fetch("/api/games/holdem/chat", { headers: { "Accept": "application/json" } })
|
||||||
|
.then(function (res) { return res.ok ? res.json() : null; })
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data || !chatLog) return;
|
||||||
|
chatLog.innerHTML = "";
|
||||||
|
chatSeen = {};
|
||||||
|
(data.chat || []).forEach(addChat);
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addChat(line) {
|
||||||
|
if (!chatLog || !line) return;
|
||||||
|
// Your own line comes back twice — once from the POST that sent it, once
|
||||||
|
// echoed over your own stream — so drop any id already on the rail.
|
||||||
|
if (line.id) {
|
||||||
|
if (chatSeen[line.id]) return;
|
||||||
|
chatSeen[line.id] = true;
|
||||||
|
}
|
||||||
|
var row = document.createElement("div");
|
||||||
|
row.className = "pete-chat-line" + (line.mine ? " pete-chat-mine" : "");
|
||||||
|
var who = document.createElement("span");
|
||||||
|
who.className = "pete-chat-who";
|
||||||
|
who.textContent = line.name;
|
||||||
|
var body = document.createElement("span");
|
||||||
|
body.className = "pete-chat-body";
|
||||||
|
body.textContent = line.body;
|
||||||
|
row.appendChild(who);
|
||||||
|
row.appendChild(body);
|
||||||
|
chatLog.appendChild(row);
|
||||||
|
chatLog.scrollTop = chatLog.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chatForm) chatForm.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var body = (chatInput.value || "").trim();
|
||||||
|
if (!body) return;
|
||||||
|
chatInput.value = "";
|
||||||
|
window.PeteGames
|
||||||
|
.post("/api/games/holdem/say", { body: body })
|
||||||
|
.then(function (line) { addChat(line); })
|
||||||
|
.catch(function () { chatInput.value = body; });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the lobby -------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The tables other people have open, each with a seat going spare. Joining
|
||||||
|
// takes the buy-in the slider is set to, clamped to what that table allows.
|
||||||
|
|
||||||
|
function loadLobby() {
|
||||||
|
if (!lobbyEl) return;
|
||||||
|
fetch("/api/games/holdem/tables", { headers: { "Accept": "application/json" } })
|
||||||
|
.then(function (res) { return res.ok ? res.json() : null; })
|
||||||
|
.then(function (data) {
|
||||||
|
var tables = (data && data.tables) || [];
|
||||||
|
renderLobby(tables);
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLobby(tables) {
|
||||||
|
lobbyEl.innerHTML = "";
|
||||||
|
lobbyWrap.classList.toggle("hidden", tables.length === 0);
|
||||||
|
tables.forEach(function (t) {
|
||||||
|
var stakes = tierBtns.filter(function (b) { return b.dataset.tier === t.tier; })[0];
|
||||||
|
var btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "flex items-center justify-between gap-3 rounded-2xl border-2 border-[color:var(--ink)]/10 p-3 text-left hover:bg-[color:var(--ink)]/5 transition";
|
||||||
|
var label = document.createElement("span");
|
||||||
|
label.className = "font-display font-bold";
|
||||||
|
label.textContent = stakes ? stakes.querySelector(".font-display").textContent : t.tier;
|
||||||
|
var seats = document.createElement("span");
|
||||||
|
seats.className = "text-xs font-semibold text-[color:var(--ink)]/50 tabular-nums";
|
||||||
|
seats.textContent = t.humans + "/" + t.seats + " seated";
|
||||||
|
btn.appendChild(label);
|
||||||
|
btn.appendChild(seats);
|
||||||
|
btn.addEventListener("click", function () { join(t, stakes); });
|
||||||
|
lobbyEl.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function join(t, stakes) {
|
||||||
|
if (busy) return;
|
||||||
|
// Buy in for what the slider says, but only what this table allows.
|
||||||
|
var buyIn = buyIn0(stakes);
|
||||||
|
busy = true;
|
||||||
|
say(tableMsg, "");
|
||||||
|
window.PeteGames
|
||||||
|
.post("/api/games/holdem/sit", { table: t.id, buyin: buyIn })
|
||||||
|
.then(function (v) {
|
||||||
|
window.PeteGames.apply(v);
|
||||||
|
render(v.holdem);
|
||||||
|
seated();
|
||||||
|
say(betweenMsg, "You're in. The next hand deals when the table is ready.");
|
||||||
|
})
|
||||||
|
.catch(function (err) { say(tableMsg, err.message, "bad"); })
|
||||||
|
.then(function () { busy = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// buyIn0 is a legal buy-in for a table you are joining: fifty big blinds, or as
|
||||||
|
// close as the table's range allows.
|
||||||
|
function buyIn0(stakes) {
|
||||||
|
if (!stakes) return Number(buySlider.value);
|
||||||
|
var min = Number(stakes.dataset.min), max = Number(stakes.dataset.max), bb = Number(stakes.dataset.bb);
|
||||||
|
var want = Number(buySlider.value);
|
||||||
|
if (stakes === tier) return Math.min(max, Math.max(min, want)); // same table: honour the slider
|
||||||
|
return Math.min(max, Math.max(min, 50 * bb));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- boot ------------------------------------------------------------------
|
// ---- boot ------------------------------------------------------------------
|
||||||
|
|
||||||
window.PeteGames.onUpdate(function () { if (!view) syncSit(); });
|
window.PeteGames.onUpdate(function () { if (!view) syncSit(); });
|
||||||
@@ -683,7 +889,7 @@
|
|||||||
pickBots(botBtns[1]);
|
pickBots(botBtns[1]);
|
||||||
|
|
||||||
window.PeteGames.refresh().then(function (v) {
|
window.PeteGames.refresh().then(function (v) {
|
||||||
if (v && v.holdem) render(v.holdem);
|
if (v && v.holdem) { render(v.holdem); seated(); }
|
||||||
else render0();
|
else { render0(); loadLobby(); }
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -25,19 +25,62 @@
|
|||||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
|
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
|
||||||
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
|
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
|
||||||
{{range .Roster}}
|
{{range .Roster}}
|
||||||
<li class="flex items-center gap-4 px-5 py-3">
|
<li class="flex items-center gap-4 px-5 py-3" data-token="{{.Token}}" data-name="{{.Name}}">
|
||||||
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
|
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
|
||||||
<span class="font-semibold">{{.Name}}</span>
|
<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure hover:underline">{{.Name}}</a>
|
||||||
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
|
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
|
||||||
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
|
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
|
||||||
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
|
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
|
||||||
</span>
|
</span>
|
||||||
|
{{if and $.User .OnRun}}
|
||||||
|
<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="{{.Token}}" data-name="{{.Name}}">send trouble</button>
|
||||||
|
{{end}}
|
||||||
</li>
|
</li>
|
||||||
{{else}}
|
{{else}}
|
||||||
<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody's out at the moment. Quiet week in the realm.</li>
|
<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody's out at the moment. Quiet week in the realm.</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if not .User}}
|
||||||
|
<p class="mt-3 text-xs text-[color:var(--ink)]/50">
|
||||||
|
<a href="/auth/login?next={{.Path}}" class="underline hover:text-red-500">Sign in</a> to send something unpleasant their way.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .User}}
|
||||||
|
<!-- Your open contracts. Rendered from filed facts, never from live game state:
|
||||||
|
Pete only knows what gogobee told it happened. -->
|
||||||
|
<div id="mischief-orders" class="mt-4 hidden">
|
||||||
|
<h3 class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50 mb-2">Trouble you've paid for</h3>
|
||||||
|
<ul id="mischief-orders-list" class="space-y-2 text-sm"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The buy dialog. Prices are gogobee's, pulled live from /api/mischief/catalog;
|
||||||
|
nothing here asserts a number of its own. -->
|
||||||
|
<div id="mischief-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/50 p-4">
|
||||||
|
<div class="w-full max-w-md rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-5 py-4 border-b border-[color:var(--ink)]/10">
|
||||||
|
<h3 class="font-display text-xl font-bold">Send trouble to <span id="mischief-target">—</span></h3>
|
||||||
|
<button type="button" id="mischief-close" class="text-2xl leading-none text-[color:var(--ink)]/50 hover:text-[color:var(--ink)]" aria-label="close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="px-5 py-4 space-y-3">
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60">Pick how bad. They won't know who sent it — unless you sign it, or they live to read the receipt.</p>
|
||||||
|
<div id="mischief-tiers" class="space-y-2"></div>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" id="mischief-signed" class="rounded border-[color:var(--ink)]/30">
|
||||||
|
<span>Sign my name to it <span class="text-[color:var(--ink)]/45">(+25%, and they'll know)</span></span>
|
||||||
|
</label>
|
||||||
|
<p id="mischief-balance" class="text-xs text-[color:var(--ink)]/50"></p>
|
||||||
|
<p id="mischief-result" class="text-sm hidden"></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-[color:var(--ink)]/10">
|
||||||
|
<button type="button" id="mischief-cancel" class="rounded-full px-4 py-2 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)]">Never mind</button>
|
||||||
|
<button type="button" id="mischief-confirm" class="rounded-full bg-red-500 text-white px-5 py-2 text-sm font-semibold shadow-pete hover:-translate-y-0.5 transition disabled:opacity-40 disabled:translate-y-0" disabled>Put the word out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -49,6 +92,8 @@
|
|||||||
var card = document.getElementById('roster-card');
|
var card = document.getElementById('roster-card');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
|
var signedIn = {{if .User}}true{{else}}false{{end}};
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
var d = document.createElement('div');
|
var d = document.createElement('div');
|
||||||
d.textContent = s == null ? '' : String(s);
|
d.textContent = s == null ? '' : String(s);
|
||||||
@@ -57,12 +102,15 @@
|
|||||||
|
|
||||||
function row(a) {
|
function row(a) {
|
||||||
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
|
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
|
||||||
return '<li class="flex items-center gap-4 px-5 py-3">' +
|
var button = (signedIn && a.OnRun)
|
||||||
|
? '<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">send trouble</button>'
|
||||||
|
: '';
|
||||||
|
return '<li class="flex items-center gap-4 px-5 py-3" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">' +
|
||||||
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
|
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
|
||||||
'<span class="font-semibold">' + esc(a.Name) + '</span>' +
|
'<a href="/adventure/who/' + esc(a.Token) + '" class="font-semibold hover:text-theme-adventure hover:underline">' + esc(a.Name) + '</a>' +
|
||||||
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
|
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
|
||||||
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
|
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
|
||||||
esc(a.Where) + idle + '</span></li>';
|
esc(a.Where) + idle + '</span>' + button + '</li>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
@@ -84,7 +132,157 @@
|
|||||||
document.addEventListener('visibilitychange', function () {
|
document.addEventListener('visibilitychange', function () {
|
||||||
if (!document.hidden) refresh();
|
if (!document.hidden) refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (signedIn) initMischief(list, esc);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// The storefront: pick a mark, pick how bad, pay. The only thing this code is
|
||||||
|
// authoritative about is the buyer's intent — every price, every yes/no, and the
|
||||||
|
// buyer's actual balance are gogobee's, reached through the API.
|
||||||
|
function initMischief(list, esc) {
|
||||||
|
var modal = document.getElementById('mischief-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
var targetEl = document.getElementById('mischief-target');
|
||||||
|
var tiersEl = document.getElementById('mischief-tiers');
|
||||||
|
var signedEl = document.getElementById('mischief-signed');
|
||||||
|
var balanceEl = document.getElementById('mischief-balance');
|
||||||
|
var resultEl = document.getElementById('mischief-result');
|
||||||
|
var confirmEl = document.getElementById('mischief-confirm');
|
||||||
|
var ordersWrap = document.getElementById('mischief-orders');
|
||||||
|
var ordersList = document.getElementById('mischief-orders-list');
|
||||||
|
|
||||||
|
var catalog = null; // {tiers, euro, has_balance}
|
||||||
|
var chosen = null; // selected tier key
|
||||||
|
var current = null; // {token, name}
|
||||||
|
|
||||||
|
function euros(n) { return '€' + Number(n).toLocaleString(); }
|
||||||
|
|
||||||
|
function loadCatalog() {
|
||||||
|
return fetch('/api/mischief/catalog', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (c) { catalog = c; return c; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTiers() {
|
||||||
|
if (!catalog || !catalog.tiers || !catalog.tiers.length) {
|
||||||
|
tiersEl.innerHTML = '<p class="text-sm text-[color:var(--ink)]/50">The catalogue is between snapshots. Try again in a moment.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var signed = signedEl.checked;
|
||||||
|
var known = catalog.has_balance;
|
||||||
|
var euro = catalog.euro;
|
||||||
|
tiersEl.innerHTML = catalog.tiers.map(function (t) {
|
||||||
|
var price = signed ? t.signed_fee : t.fee;
|
||||||
|
var tooDear = known && euro < price;
|
||||||
|
return '<button type="button" class="mischief-tier w-full text-left rounded-2xl border-2 px-4 py-2 transition ' +
|
||||||
|
(chosen === t.key ? 'border-red-500 bg-red-500/10' : 'border-[color:var(--ink)]/10 hover:border-[color:var(--ink)]/25') +
|
||||||
|
(tooDear ? ' opacity-40 cursor-not-allowed' : '') + '" data-key="' + esc(t.key) + '"' + (tooDear ? ' disabled' : '') + '>' +
|
||||||
|
'<span class="flex items-baseline justify-between"><span class="font-semibold">' + esc(t.display) + '</span>' +
|
||||||
|
'<span class="font-semibold">' + euros(price) + '</span></span>' +
|
||||||
|
(t.blurb ? '<span class="block text-xs text-[color:var(--ink)]/55 mt-0.5">' + esc(t.blurb) + '</span>' : '') +
|
||||||
|
'</button>';
|
||||||
|
}).join('');
|
||||||
|
balanceEl.textContent = known ? 'You have about ' + euros(euro) + ' to spend.' : '';
|
||||||
|
Array.prototype.forEach.call(tiersEl.querySelectorAll('.mischief-tier'), function (b) {
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
chosen = b.getAttribute('data-key');
|
||||||
|
confirmEl.disabled = false;
|
||||||
|
drawTiers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(token, name) {
|
||||||
|
current = { token: token, name: name };
|
||||||
|
chosen = null;
|
||||||
|
confirmEl.disabled = true;
|
||||||
|
resultEl.classList.add('hidden');
|
||||||
|
resultEl.textContent = '';
|
||||||
|
signedEl.checked = false;
|
||||||
|
targetEl.textContent = name;
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
(catalog ? Promise.resolve() : loadCatalog()).then(drawTiers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function badge(statusText) {
|
||||||
|
var map = {
|
||||||
|
pending: ['on its way', 'bg-amber-500/15 text-amber-700'],
|
||||||
|
placed: ['out for delivery', 'bg-red-500/15 text-red-600'],
|
||||||
|
bounced_funds: ["couldn't afford it", 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'],
|
||||||
|
bounced_ineligible: ['no longer a mark', 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60']
|
||||||
|
};
|
||||||
|
var m = map[statusText] || [statusText, 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'];
|
||||||
|
return '<span class="rounded-full px-2 py-0.5 text-xs font-semibold ' + m[1] + '">' + esc(m[0]) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOrders() {
|
||||||
|
fetch('/api/mischief/orders', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (orders) {
|
||||||
|
if (!orders || !orders.length) { ordersWrap.classList.add('hidden'); return; }
|
||||||
|
ordersWrap.classList.remove('hidden');
|
||||||
|
ordersList.innerHTML = orders.map(function (o) {
|
||||||
|
var detail = o.detail ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(o.detail) + '</span>' : '';
|
||||||
|
return '<li class="flex items-center gap-3">' +
|
||||||
|
'<span class="font-semibold">' + esc(o.target_name) + '</span>' +
|
||||||
|
'<span class="text-[color:var(--ink)]/55">' + esc(o.tier) + (o.signed ? ', signed' : '') + '</span>' +
|
||||||
|
'<span class="ml-auto">' + badge(o.status) + '</span>' + detail + '</li>';
|
||||||
|
}).join('');
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
list.addEventListener('click', function (e) {
|
||||||
|
var b = e.target.closest('.mischief-send');
|
||||||
|
if (!b) return;
|
||||||
|
open(b.getAttribute('data-token'), b.getAttribute('data-name'));
|
||||||
|
});
|
||||||
|
signedEl.addEventListener('change', drawTiers);
|
||||||
|
document.getElementById('mischief-close').addEventListener('click', close);
|
||||||
|
document.getElementById('mischief-cancel').addEventListener('click', close);
|
||||||
|
modal.addEventListener('click', function (e) { if (e.target === modal) close(); });
|
||||||
|
|
||||||
|
confirmEl.addEventListener('click', function () {
|
||||||
|
if (!current || !chosen) return;
|
||||||
|
confirmEl.disabled = true;
|
||||||
|
resultEl.classList.add('hidden');
|
||||||
|
fetch('/api/mischief/order', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target_token: current.token, tier: chosen, signed: signedEl.checked })
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
resultEl.classList.remove('hidden');
|
||||||
|
if (res.ok) {
|
||||||
|
resultEl.className = 'text-sm text-red-600 font-semibold';
|
||||||
|
resultEl.textContent = 'The word is out. Watch the board.';
|
||||||
|
catalog = null; // balance moved; refetch next open
|
||||||
|
loadOrders();
|
||||||
|
setTimeout(close, 1200);
|
||||||
|
} else {
|
||||||
|
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
|
||||||
|
resultEl.textContent = (res.body && res.body.error) || 'That didn\'t take. Try again.';
|
||||||
|
confirmEl.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
resultEl.classList.remove('hidden');
|
||||||
|
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
|
||||||
|
resultEl.textContent = 'The line dropped. Try again.';
|
||||||
|
confirmEl.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
loadOrders();
|
||||||
|
setInterval(loadOrders, 60000);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
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">
|
||||||
@@ -51,7 +68,6 @@
|
|||||||
{{template "_comb" .}}
|
{{template "_comb" .}}
|
||||||
<span class="min-w-0">
|
<span class="min-w-0">
|
||||||
<span data-room-name class="block font-display text-2xl sm:text-3xl font-bold leading-none tracking-tight">{{.Room.Name}}</span>
|
<span data-room-name class="block font-display text-2xl sm:text-3xl font-bold leading-none tracking-tight">{{.Room.Name}}</span>
|
||||||
<span class="mt-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[color:var(--ink)]/45">Chips are euros</span>
|
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -61,6 +77,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>
|
||||||
|
|||||||
@@ -145,6 +145,21 @@
|
|||||||
<p data-between-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
<p data-between-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- The rail talk. Chat lives at the felt and does not leave it for Matrix;
|
||||||
|
every line is stamped with the hand it was said during. Shown while you're
|
||||||
|
seated. -->
|
||||||
|
<section data-chat class="hidden rounded-3xl bg-[color:var(--card)] p-4 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
<div data-chat-log class="flex flex-col gap-1 max-h-40 overflow-y-auto pr-1 text-sm" aria-live="polite" aria-label="Table chat"></div>
|
||||||
|
<form data-chat-form class="mt-3 flex items-center gap-2">
|
||||||
|
<input data-chat-input type="text" maxlength="240" autocomplete="off" placeholder="Say something to the table…"
|
||||||
|
class="flex-1 min-w-0 rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--bg)] px-4 py-2 text-sm focus:outline-none focus:border-[color:var(--accent)]">
|
||||||
|
<button type="submit"
|
||||||
|
class="rounded-full bg-[color:var(--accent)] px-5 py-2 font-display text-sm font-bold text-white shadow-pete hover:brightness-105 active:translate-y-px transition">
|
||||||
|
Say
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Sitting down: shown when you aren't at a table. -->
|
<!-- Sitting down: shown when you aren't at a table. -->
|
||||||
<section data-sitting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
<section data-sitting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
|
||||||
@@ -195,6 +210,13 @@
|
|||||||
Sit down
|
Sit down
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Or pull up a chair at a table someone else has already opened. Bots keep
|
||||||
|
every open table full, so there is always a seat to take. -->
|
||||||
|
<div data-lobby-wrap class="mt-6 hidden">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Or join a table in progress</div>
|
||||||
|
<div data-lobby class="mt-2 grid gap-2"></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">
|
||||||
The bots are trained, not scripted. The house takes {{.RakePct}}% of a pot that sees a flop, capped at three big blinds, and nothing at all from a hand that doesn't.
|
The bots are trained, not scripted. The house takes {{.RakePct}}% of a pot that sees a flop, capped at three big blinds, and nothing at all from a hand that doesn't.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -13,23 +13,24 @@
|
|||||||
</a>
|
</a>
|
||||||
<h1 class="font-display text-3xl font-bold">UNO</h1>
|
<h1 class="font-display text-3xl font-bold">UNO</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-[color:var(--ink)]/50">Go out first, or it was somebody else's night</p>
|
<p class="text-sm text-[color:var(--ink)]/50">Sit down, ante up, and go out first — the pot's yours</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{template "_chipbar" .}}
|
{{template "_chipbar" .}}
|
||||||
|
|
||||||
<!-- The felt. The board takes the room and the money lives in a rail beside it:
|
<!-- The felt. Other seats along the top, the deck and the pile in the middle
|
||||||
there is no corner free on this table for the house rack to sit in. -->
|
with the pot beside them, your hand at the bottom. Every ante on this table
|
||||||
|
is riding in the pot until somebody goes out and takes it. -->
|
||||||
<section class="pete-felt pete-uno relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
|
<section class="pete-felt pete-uno relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
|
||||||
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
|
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
|
||||||
|
|
||||||
<div class="min-w-0 space-y-5">
|
<div class="min-w-0 space-y-5">
|
||||||
|
|
||||||
<!-- The bots. Each one is a name, a fan of backs, and a count — which is
|
<!-- The other seats. Each one is a name, a stack, a fan of backs and a
|
||||||
all you are ever told about them, and all a real opponent shows you. -->
|
count — all a real opponent shows you. -->
|
||||||
<div data-seats class="flex flex-wrap items-start justify-center gap-3 sm:gap-5" aria-label="The other players"></div>
|
<div data-seats class="flex flex-wrap items-start justify-center gap-3 sm:gap-5" aria-label="The other players"></div>
|
||||||
|
|
||||||
<!-- The middle: what you draw from, and what you play onto. -->
|
<!-- The middle: what you draw from, what you play onto, and the pot. -->
|
||||||
<div class="flex items-center justify-center gap-5 sm:gap-8">
|
<div class="flex items-center justify-center gap-5 sm:gap-8">
|
||||||
<button type="button" data-deck class="pete-uno-deck" aria-label="Draw a card">
|
<button type="button" data-deck class="pete-uno-deck" aria-label="Draw a card">
|
||||||
<span class="pete-uno-back" aria-hidden="true"></span>
|
<span class="pete-uno-back" aria-hidden="true"></span>
|
||||||
@@ -39,16 +40,14 @@
|
|||||||
<div class="flex flex-col items-center gap-2">
|
<div class="flex flex-col items-center gap-2">
|
||||||
<div data-discard class="pete-uno-discard" aria-label="The card in play"></div>
|
<div data-discard class="pete-uno-discard" aria-label="The card in play"></div>
|
||||||
<p data-colour class="pete-uno-colour" aria-live="polite"></p>
|
<p data-colour class="pete-uno-colour" aria-live="polite"></p>
|
||||||
<!-- The bill. While a stack stands it is the only thing on the table
|
|
||||||
that matters, so it is said on the felt and not only on a button.
|
|
||||||
|
|
||||||
It is data-*bill*, not data-pending: the chip bar already owns
|
|
||||||
data-pending (chips still in flight from a buy-in), the chip bar is
|
|
||||||
inside this root, and it comes first in the document — so a stack
|
|
||||||
was quietly writing "+10 coming your way" over the escrow readout
|
|
||||||
and never showing up on the felt at all. -->
|
|
||||||
<p data-bill class="pete-uno-pending hidden" aria-live="assertive"></p>
|
<p data-bill class="pete-uno-pending hidden" aria-live="assertive"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- The pot. Every seat's ante is in here until the hand is won. -->
|
||||||
|
<div class="pete-uno-pot text-center">
|
||||||
|
<span class="block text-xs font-bold uppercase tracking-wider text-white/50">Pot</span>
|
||||||
|
<span data-pot class="block font-display text-2xl font-bold tabular-nums text-white">0</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Your hand. -->
|
<!-- Your hand. -->
|
||||||
@@ -74,24 +73,17 @@
|
|||||||
<span data-chip="5" style="--stack: 6"></span>
|
<span data-chip="5" style="--stack: 6"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pete-spot" data-spot>
|
|
||||||
<span class="pete-spot-label">Bet</span>
|
|
||||||
<div class="pete-stack" data-stack></div>
|
|
||||||
<span data-spot-total class="pete-spot-total hidden"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="pete-meter" data-meter>
|
<div class="pete-meter" data-meter>
|
||||||
<span class="pete-meter-label">Pays</span>
|
<span class="pete-meter-label">In front of you</span>
|
||||||
<span data-pays class="pete-meter-value">—</span>
|
<span data-stack class="pete-meter-value">—</span>
|
||||||
</div>
|
</div>
|
||||||
<p data-table-name class="mt-1.5 text-xs font-bold uppercase tracking-wider text-white/40"></p>
|
<p data-table-name class="mt-1.5 text-xs font-bold uppercase tracking-wider text-white/40"></p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Naming a colour for a wild. It sits over the felt because until it is
|
<!-- Naming a colour for a wild. -->
|
||||||
answered there is no legal move on the table underneath it. -->
|
|
||||||
<div data-wild class="pete-uno-wild hidden" role="dialog" aria-label="Pick a colour">
|
<div data-wild class="pete-uno-wild hidden" role="dialog" aria-label="Pick a colour">
|
||||||
<div class="pete-uno-wild-box">
|
<div class="pete-uno-wild-box">
|
||||||
<p class="font-display text-lg font-bold text-white">Pick a colour</p>
|
<p class="font-display text-lg font-bold text-white">Pick a colour</p>
|
||||||
@@ -105,13 +97,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Calling UNO. The card is already on its way down — this is the word you
|
<!-- Calling UNO. -->
|
||||||
owe the table for it, and the bar underneath is how long you've got.
|
|
||||||
Let it run out and the bots take you for two.
|
|
||||||
|
|
||||||
There is no cancel here, and there doesn't need to be: clicking a card
|
|
||||||
has always played it on this table, so a gate that makes you press one
|
|
||||||
more thing is strictly more warning than you used to get. -->
|
|
||||||
<div data-unogate class="pete-uno-wild hidden" role="dialog" aria-label="Call UNO">
|
<div data-unogate class="pete-uno-wild hidden" role="dialog" aria-label="Call UNO">
|
||||||
<div class="pete-uno-wild-box">
|
<div class="pete-uno-wild-box">
|
||||||
<p class="font-display text-lg font-bold text-white">Down to one card</p>
|
<p class="font-display text-lg font-bold text-white">Down to one card</p>
|
||||||
@@ -122,8 +108,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Playing: shown while a game is live. -->
|
<!-- Acting: shown while it's your turn. -->
|
||||||
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
<section data-acting class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<p data-hint class="text-sm text-[color:var(--ink)]/50">
|
<p data-hint class="text-sm text-[color:var(--ink)]/50">
|
||||||
Click a card that lights up. Nothing lights up? Draw one.
|
Click a card that lights up. Nothing lights up? Draw one.
|
||||||
@@ -134,9 +120,6 @@
|
|||||||
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
Draw
|
Draw
|
||||||
</button>
|
</button>
|
||||||
<!-- Giving in to a stack. It is a decision, not a draw — you are paying a
|
|
||||||
bill somebody ran up and pointing at you — so it gets a button of its
|
|
||||||
own, and the button says what it costs. -->
|
|
||||||
<button type="button" data-take
|
<button type="button" data-take
|
||||||
class="hidden rounded-full bg-[#cc3d4a] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
|
class="hidden rounded-full bg-[#cc3d4a] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||||
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
@@ -152,15 +135,51 @@
|
|||||||
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Betting: shown between games. -->
|
<!-- Between hands: deal the next one, or get up. -->
|
||||||
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
<section data-between class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">In front of you</div>
|
||||||
|
<div class="font-display text-3xl font-bold tabular-nums" data-table-stack>0</div>
|
||||||
|
<p class="mt-0.5 text-xs text-[color:var(--ink)]/40">
|
||||||
|
Bought in for <span data-bought-in class="tabular-nums">0</span> · <span data-ante-note class="tabular-nums">0</span> to ante
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||||
|
<button type="button" data-leave
|
||||||
|
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-5 py-3 font-display text-base font-bold shadow-pete
|
||||||
|
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Get up
|
||||||
|
</button>
|
||||||
|
<button type="button" data-deal
|
||||||
|
class="rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||||
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Next hand
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p data-between-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- The rail talk, shown while you're seated. -->
|
||||||
|
<section data-chat class="hidden rounded-3xl bg-[color:var(--card)] p-4 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
<div data-chat-log class="flex flex-col gap-1 max-h-40 overflow-y-auto pr-1 text-sm" aria-live="polite" aria-label="Table chat"></div>
|
||||||
|
<form data-chat-form class="mt-3 flex items-center gap-2">
|
||||||
|
<input data-chat-input type="text" maxlength="240" autocomplete="off" placeholder="Say something to the table…"
|
||||||
|
class="flex-1 min-w-0 rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--bg)] px-4 py-2 text-sm focus:outline-none focus:border-[color:var(--accent)]">
|
||||||
|
<button type="submit"
|
||||||
|
class="rounded-full bg-[color:var(--accent)] px-5 py-2 font-display text-sm font-bold text-white shadow-pete hover:brightness-105 active:translate-y-px transition">
|
||||||
|
Say
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Sitting down: shown when you aren't at a table. -->
|
||||||
|
<section data-sitting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
|
||||||
<!-- Two dials, and they are different dials. The table size is the tier — it is
|
|
||||||
what you're paid for. No Mercy is the *deck*, and it is a switch across all
|
|
||||||
three sizes, which is why it sits up here beside the question rather than
|
|
||||||
being a fourth table. -->
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Who are you playing?</div>
|
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Which table?</div>
|
||||||
<div class="pete-seg" role="group" aria-label="Which rules">
|
<div class="pete-seg" role="group" aria-label="Which rules">
|
||||||
<button type="button" data-rules="normal" class="pete-seg-btn">UNO</button>
|
<button type="button" data-rules="normal" class="pete-seg-btn">UNO</button>
|
||||||
<button type="button" data-rules="nomercy" class="pete-seg-btn">No Mercy</button>
|
<button type="button" data-rules="nomercy" class="pete-seg-btn">No Mercy</button>
|
||||||
@@ -170,15 +189,15 @@
|
|||||||
<div data-grid="normal">
|
<div data-grid="normal">
|
||||||
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
||||||
{{range .Tables}}
|
{{range .Tables}}
|
||||||
<button type="button" data-tier="{{.Slug}}"
|
<button type="button" data-tier="{{.Slug}}" data-min="{{.MinBuy}}" data-max="{{.MaxBuy}}" data-ante="{{.Ante}}"
|
||||||
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
|
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
|
||||||
<div class="flex items-baseline justify-between gap-2">
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
<span class="font-display text-lg font-bold">{{.Name}}</span>
|
<span class="font-display text-lg font-bold">{{.Name}}</span>
|
||||||
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
|
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{.Ante}} ante</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
|
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
|
||||||
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
|
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
|
||||||
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · seven cards each
|
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · buy in {{.MinBuy}}–{{.MaxBuy}}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -188,11 +207,11 @@
|
|||||||
<div data-grid="nomercy" class="hidden">
|
<div data-grid="nomercy" class="hidden">
|
||||||
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
||||||
{{range .NoMercy}}
|
{{range .NoMercy}}
|
||||||
<button type="button" data-tier="{{.Slug}}"
|
<button type="button" data-tier="{{.Slug}}" data-min="{{.MinBuy}}" data-max="{{.MaxBuy}}" data-ante="{{.Ante}}"
|
||||||
class="pete-tier pete-tier-nm rounded-2xl border-2 p-3 text-left transition">
|
class="pete-tier pete-tier-nm rounded-2xl border-2 p-3 text-left transition">
|
||||||
<div class="flex items-baseline justify-between gap-2">
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
<span class="font-display text-lg font-bold">{{.Name}}</span>
|
<span class="font-display text-lg font-bold">{{.Name}}</span>
|
||||||
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
|
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{.Ante}} ante</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
|
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
|
||||||
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
|
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
|
||||||
@@ -203,38 +222,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
|
<div class="mt-5">
|
||||||
<div>
|
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Buying in for</div>
|
||||||
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
|
<div class="mt-2 flex items-center gap-3">
|
||||||
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
|
<input type="range" data-buyin-slider class="flex-1 min-w-0" aria-label="How much to buy in for">
|
||||||
|
<span data-buyin class="font-display text-2xl font-bold tabular-nums">0</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1.5 text-xs text-[color:var(--ink)]/40" data-buyin-note></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<button type="button" data-sit
|
||||||
{{range .Denominations}}
|
class="mt-5 w-full rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||||
<button type="button" data-chip="{{.}}" aria-label="Bet {{.}} more"
|
|
||||||
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
|
|
||||||
<span>{{.}}</span>
|
|
||||||
</button>
|
|
||||||
{{end}}
|
|
||||||
<button type="button" data-bet-clear
|
|
||||||
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" data-start
|
|
||||||
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
|
|
||||||
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
Deal
|
Sit down
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div data-lobby-wrap class="mt-6 hidden">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Or join a table in progress</div>
|
||||||
|
<div data-lobby class="mt-2 grid gap-2"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- No Mercy pays *less*, which reads like a typo until you've played one: the
|
|
||||||
mercy rule buries bots too, and every bot it buries is one fewer seat that
|
|
||||||
can beat you to the last card. Say so, rather than let it look like a bug. -->
|
|
||||||
<p data-note="normal" class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
<p data-note="normal" class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
||||||
Normal rules: no stacking a +2 on a +2, and a reverse is a skip when it's just the two of you.
|
Everyone antes, and the first one out takes the pot less {{.RakePct}}% rake. Normal rules: no stacking a +2 on a +2, and a reverse is a skip when it's just the two of you.
|
||||||
</p>
|
</p>
|
||||||
<p data-note="nomercy" class="hidden mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
<p data-note="nomercy" class="hidden mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
||||||
168 cards, +6s and +10s. Draw cards stack, you draw until you can play, and {{.MercyLimit}} cards in
|
168 cards, +6s and +10s. Draw cards stack, you draw until you can play, and {{.MercyLimit}} cards in your hand puts you out of the hand. The pot still goes to whoever goes out first.
|
||||||
your hand puts you out of the game. It pays less than normal UNO because it buries the bots too.
|
|
||||||
</p>
|
</p>
|
||||||
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
207
internal/web/templates/who.html
Normal file
207
internal/web/templates/who.html
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
{{define "title"}}{{.Mark.Name}} — {{.SiteTitle}}{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="who" data-token="{{.Mark.Token}}">
|
||||||
|
<nav class="mb-4">
|
||||||
|
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
|
||||||
|
<span aria-hidden="true">←</span> The board
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||||
|
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{if .Mark.OnRun}}⚔{{else}}🏠{{end}}</div>
|
||||||
|
<div class="relative">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] opacity-80">adventurer</p>
|
||||||
|
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Mark.Name}}</h1>
|
||||||
|
<p class="mt-2 opacity-90">Level {{.Mark.Level}} {{.Mark.ClassRace}}</p>
|
||||||
|
<p class="mt-4 text-xs uppercase tracking-wider opacity-75" id="who-where">
|
||||||
|
{{if .Mark.OnRun}}{{.Mark.Where}}{{else}}In town{{if .Mark.Idle}} · {{.Mark.Idle}}{{end}}{{end}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if .HasDetail}}
|
||||||
|
<!-- Stats + gear: public, the same anonymity model as the board. -->
|
||||||
|
<section class="mt-8 grid gap-6 sm:grid-cols-2">
|
||||||
|
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
<h2 class="font-display text-xl font-bold mb-4">Vitals</h2>
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
|
<span class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50">Hit points</span>
|
||||||
|
<span class="font-semibold" id="who-hp">{{.Detail.HPCurrent}} / {{.Detail.HPMax}}{{if .Detail.TempHP}} <span class="text-theme-adventure">+{{.Detail.TempHP}}</span>{{end}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 h-2 rounded-full bg-[color:var(--ink)]/10 overflow-hidden">
|
||||||
|
<div class="h-full rounded-full bg-theme-adventure transition-all" id="who-hp-bar" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex items-baseline justify-between">
|
||||||
|
<span class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50">Armor class</span>
|
||||||
|
<span class="font-semibold" id="who-ac">{{.Detail.ArmorClass}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid grid-cols-3 gap-2">
|
||||||
|
{{range .Abilities}}
|
||||||
|
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50">{{.Label}}</div>
|
||||||
|
<div class="font-display text-lg font-bold leading-none mt-1">{{.Score}}</div>
|
||||||
|
<div class="text-xs text-[color:var(--ink)]/60">{{.ModStr}}</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Mark.OnRun}}
|
||||||
|
<div class="mt-5 pt-4 border-t border-[color:var(--ink)]/10 space-y-1.5 text-sm" id="who-expedition">
|
||||||
|
{{if .Detail.Room}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Room</span><span class="font-semibold" id="who-room">{{.Detail.Room}}</span></div>{{end}}
|
||||||
|
<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Supplies</span><span class="font-semibold" id="who-supplies">{{.Detail.Supplies}}</span></div>
|
||||||
|
{{if .Detail.ThreatLevel}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Threat</span><span class="font-semibold" id="who-threat">{{.Detail.ThreatLevel}}</span></div>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
<h2 class="font-display text-xl font-bold mb-4">Gear</h2>
|
||||||
|
{{if .Detail.Gear}}
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{{range .Detail.Gear}}
|
||||||
|
<li class="flex items-baseline justify-between gap-3">
|
||||||
|
<span class="text-xs uppercase tracking-wider text-[color:var(--ink)]/45 w-16 shrink-0">{{.Slot}}</span>
|
||||||
|
<span class="font-semibold flex-1">{{.Name}}{{if .Masterwork}} <span title="masterwork">★</span>{{end}}</span>
|
||||||
|
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Condition}} · {{.Condition}}%{{end}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/50">Traveling light — nothing equipped.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{else}}
|
||||||
|
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60">No detailed sheet on file for this adventurer yet — check back after the next snapshot.</p>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .HasSelf}}
|
||||||
|
<!-- Owner-only: this is you. Inventory, vault, house, pets — private, served
|
||||||
|
only because your signed-in localpart owns this page's token. -->
|
||||||
|
<section class="mt-8">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="rounded-full bg-theme-adventure text-white text-xs font-semibold px-3 py-1">Your adventurer</span>
|
||||||
|
<span class="text-xs text-[color:var(--ink)]/45">only you can see the panels below</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 sm:grid-cols-2">
|
||||||
|
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
<h2 class="font-display text-xl font-bold mb-4">Home</h2>
|
||||||
|
<div class="space-y-1.5 text-sm">
|
||||||
|
<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">House tier</span><span class="font-semibold">{{if .Self.House.Tier}}{{.Self.House.Tier}}{{else}}no house{{end}}</span></div>
|
||||||
|
{{if .Self.House.LoanBalance}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Loan balance</span><span class="font-semibold">{{.Self.House.LoanBalance}}</span></div>{{end}}
|
||||||
|
{{if .Self.House.Rate}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Rate</span><span class="font-semibold">{{.Self.House.Rate}}</span></div>{{end}}
|
||||||
|
{{if .Self.House.Autopay}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Autopay</span><span class="font-semibold">on</span></div>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="font-display text-lg font-bold mt-6 mb-3">Pets</h3>
|
||||||
|
{{if .Self.Pets}}
|
||||||
|
<ul class="space-y-2 text-sm">
|
||||||
|
{{range .Self.Pets}}
|
||||||
|
<li class="flex items-baseline justify-between gap-3">
|
||||||
|
<span class="font-semibold flex-1">{{if .Name}}{{.Name}}{{else}}your {{.Type}}{{end}} <span class="text-[color:var(--ink)]/45 font-normal">{{.Type}}</span></span>
|
||||||
|
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">lv {{.Level}}{{if .ArmorTier}} · barding T{{.ArmorTier}}{{end}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/50">No pets right now.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
<h2 class="font-display text-xl font-bold mb-4">Backpack</h2>
|
||||||
|
{{if .Self.Inventory}}
|
||||||
|
<ul class="space-y-1.5 text-sm max-h-72 overflow-y-auto pr-1">
|
||||||
|
{{range .Self.Inventory}}
|
||||||
|
<li class="flex items-baseline justify-between gap-3">
|
||||||
|
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
|
||||||
|
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/50">Backpack's empty.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Self.Vault}}
|
||||||
|
<h3 class="font-display text-lg font-bold mt-6 mb-3">Vault</h3>
|
||||||
|
<ul class="space-y-1.5 text-sm max-h-52 overflow-y-auto pr-1">
|
||||||
|
{{range .Self.Vault}}
|
||||||
|
<li class="flex items-baseline justify-between gap-3">
|
||||||
|
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
|
||||||
|
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// The sheet is state, like the board: keep an open tab honest without a reload.
|
||||||
|
// Public detail only (HP, room, supplies) — the private panels stay as rendered.
|
||||||
|
(function () {
|
||||||
|
var root = document.getElementById('who');
|
||||||
|
if (!root) return;
|
||||||
|
var token = root.getAttribute('data-token');
|
||||||
|
|
||||||
|
function setBar() {
|
||||||
|
var el = document.getElementById('who-hp-bar');
|
||||||
|
var hp = document.getElementById('who-hp');
|
||||||
|
if (!el || !hp) return;
|
||||||
|
var m = hp.textContent.match(/(-?\d+)\s*\/\s*(\d+)/);
|
||||||
|
if (!m) return;
|
||||||
|
var cur = parseInt(m[1], 10), max = parseInt(m[2], 10);
|
||||||
|
var pct = max > 0 ? Math.max(0, Math.min(100, Math.round(cur / max * 100))) : 0;
|
||||||
|
el.style.width = pct + '%';
|
||||||
|
}
|
||||||
|
setBar();
|
||||||
|
|
||||||
|
function txt(id, v) { var el = document.getElementById(id); if (el && v != null) el.textContent = v; }
|
||||||
|
|
||||||
|
var timer = null;
|
||||||
|
function refresh() {
|
||||||
|
fetch('/api/adventure/who/' + encodeURIComponent(token), { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data) return;
|
||||||
|
if (!data.live) {
|
||||||
|
// Off the board entirely (expedition ended, opted out): nothing more to
|
||||||
|
// poll for, so stop the timer rather than hammer a token that's gone.
|
||||||
|
txt('who-where', 'Back in town');
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var d = data.detail || {};
|
||||||
|
var mark = data.mark || {};
|
||||||
|
if (data.has_detail) {
|
||||||
|
var temp = d.temp_hp ? ' +' + d.temp_hp : '';
|
||||||
|
txt('who-hp', d.hp_current + ' / ' + d.hp_max + temp);
|
||||||
|
txt('who-ac', d.armor_class);
|
||||||
|
txt('who-room', d.room);
|
||||||
|
txt('who-supplies', d.supplies);
|
||||||
|
txt('who-threat', d.threat_level);
|
||||||
|
setBar();
|
||||||
|
}
|
||||||
|
// Keep the location line honest in both directions: a mark that came back
|
||||||
|
// to town while still on the board must stop showing its old expedition.
|
||||||
|
if (mark.OnRun) {
|
||||||
|
txt('who-where', mark.Where);
|
||||||
|
} else {
|
||||||
|
txt('who-where', 'In town' + (mark.Idle ? ' · ' + mark.Idle : ''));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { /* transient — next tick will do */ });
|
||||||
|
}
|
||||||
|
timer = setInterval(refresh, 60000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
167
internal/web/who.go
Normal file
167
internal/web/who.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The adventurer detail page.
|
||||||
|
//
|
||||||
|
// A click-through from the live board: anyone may see a mark's current stats and
|
||||||
|
// equipped gear (the same anonymity model as the board — a character name and a
|
||||||
|
// sheet, never a Matrix handle). The signed-in owner sees the same page enriched
|
||||||
|
// with their private inventory, vault, house, and pets.
|
||||||
|
//
|
||||||
|
// Both halves are still gogobee → Pete: the public detail rides the roster
|
||||||
|
// snapshot, the private detail rides its own push. Pete renders what it was
|
||||||
|
// given; it never reaches back into the game box.
|
||||||
|
|
||||||
|
// whoDetail is the public sheet as gogobee pushed it (RosterEntry.Detail).
|
||||||
|
type whoDetail struct {
|
||||||
|
HPCurrent int `json:"hp_current"`
|
||||||
|
HPMax int `json:"hp_max"`
|
||||||
|
TempHP int `json:"temp_hp"`
|
||||||
|
ArmorClass int `json:"armor_class"`
|
||||||
|
Abilities [6]int `json:"abilities"`
|
||||||
|
Modifiers [6]int `json:"modifiers"`
|
||||||
|
Gear []whoGear `json:"gear"`
|
||||||
|
Supplies int `json:"supplies"`
|
||||||
|
ThreatLevel int `json:"threat_level"`
|
||||||
|
Room string `json:"room"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type whoGear struct {
|
||||||
|
Slot string `json:"slot"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Condition int `json:"condition"`
|
||||||
|
Masterwork bool `json:"masterwork"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// abilityRow is one ability line, pre-formatted for the template.
|
||||||
|
type abilityRow struct {
|
||||||
|
Label string
|
||||||
|
Score int
|
||||||
|
ModStr string // "+2", "-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
var abilityLabels = [6]string{"STR", "DEX", "CON", "INT", "WIS", "CHA"}
|
||||||
|
|
||||||
|
type whoPage struct {
|
||||||
|
pageData
|
||||||
|
Mark RosterView
|
||||||
|
HasDetail bool
|
||||||
|
Detail whoDetail
|
||||||
|
Abilities []abilityRow
|
||||||
|
HasSelf bool
|
||||||
|
Self storage.PlayerDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdventureWho serves one adventurer's detail page. Public; 404s when the
|
||||||
|
// token isn't on the current board — the same liveness gate the storefront uses,
|
||||||
|
// so a stale or guessed token never resolves to a page.
|
||||||
|
func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.adv.Enabled {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := r.PathValue("token")
|
||||||
|
entry, ok, err := storage.RosterEntryByToken(token)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("who: roster lookup failed", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.track(r, "adventure")
|
||||||
|
|
||||||
|
base := s.base(r)
|
||||||
|
base.Active = "adventure"
|
||||||
|
base.NoIndex = true // names a player character; keep out of search indexes
|
||||||
|
|
||||||
|
page := whoPage{
|
||||||
|
pageData: base,
|
||||||
|
Mark: toRosterView(entry),
|
||||||
|
}
|
||||||
|
if d, abil, ok := decodeWhoDetail(entry.Detail); ok {
|
||||||
|
page.HasDetail = true
|
||||||
|
page.Detail = d
|
||||||
|
page.Abilities = abil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner enrichment: only when the signed-in user's localpart owns this exact
|
||||||
|
// page token. The ownership is proven by a row gogobee pushed, never by
|
||||||
|
// reversing the token, so no visitor can unlock another player's self extras.
|
||||||
|
if s.auth != nil {
|
||||||
|
if u := s.auth.userFromRequest(r); u != nil {
|
||||||
|
if self, ok, err := storage.PlayerDetailByOwner(buyerLocalpart(u), token); err == nil && ok {
|
||||||
|
page.HasSelf = true
|
||||||
|
page.Self = self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.render(w, "who", page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdventureWhoAPI serves the public detail as JSON for the page's live
|
||||||
|
// re-poll, so an open tab tracks HP / room / supplies as they move. Public — the
|
||||||
|
// same exposure as the rendered page. The private self extras are not included:
|
||||||
|
// inventory changes rarely and stays server-rendered behind the ownership check.
|
||||||
|
func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.adv.Enabled {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := r.PathValue("token")
|
||||||
|
entry, ok, err := storage.RosterEntryByToken(token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
if !ok {
|
||||||
|
// Gone from the board (expedition ended, opted out): tell the poller so it
|
||||||
|
// can stop, rather than 404-ing an open tab into an error.
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"live": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, abil, hasDetail := decodeWhoDetail(entry.Detail)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"live": true,
|
||||||
|
"mark": toRosterView(entry),
|
||||||
|
"has_detail": hasDetail,
|
||||||
|
"detail": d,
|
||||||
|
"abilities": abil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeWhoDetail unpacks the public detail blob and builds the ability rows.
|
||||||
|
// Returns ok=false when there is no detail (a snapshot from before the detail
|
||||||
|
// push), so the page can fall back to the summary the board already has.
|
||||||
|
func decodeWhoDetail(raw json.RawMessage) (whoDetail, []abilityRow, bool) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return whoDetail{}, nil, false
|
||||||
|
}
|
||||||
|
var d whoDetail
|
||||||
|
if err := json.Unmarshal(raw, &d); err != nil {
|
||||||
|
return whoDetail{}, nil, false
|
||||||
|
}
|
||||||
|
rows := make([]abilityRow, 0, 6)
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
rows = append(rows, abilityRow{
|
||||||
|
Label: abilityLabels[i],
|
||||||
|
Score: d.Abilities[i],
|
||||||
|
ModStr: fmt.Sprintf("%+d", d.Modifiers[i]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return d, rows, true
|
||||||
|
}
|
||||||
199
internal/web/who_test.go
Normal file
199
internal/web/who_test.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The adventurer detail page. Two contracts under test: the public sheet reaches
|
||||||
|
// any visitor (stats + gear, never a handle), and the private self-view unlocks
|
||||||
|
// only for the signed-in owner of that exact page — anon and other users see the
|
||||||
|
// public page and nothing more.
|
||||||
|
|
||||||
|
func postDetail(t *testing.T, s *Server, token string, push detailPush) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(push)
|
||||||
|
req := httptest.NewRequest("POST", "/api/ingest/detail", bytes.NewReader(body))
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleDetailIngest(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicDetail is the sheet gogobee hangs on a roster entry, marshalled the way
|
||||||
|
// the roster push carries it.
|
||||||
|
func publicDetail(t *testing.T) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(map[string]any{
|
||||||
|
"hp_current": 30,
|
||||||
|
"hp_max": 42,
|
||||||
|
"temp_hp": 5,
|
||||||
|
"armor_class": 17,
|
||||||
|
"abilities": [6]int{16, 14, 15, 10, 12, 8},
|
||||||
|
"modifiers": [6]int{3, 2, 2, 0, 1, -1},
|
||||||
|
"gear": []map[string]any{
|
||||||
|
{"slot": "weapon", "name": "Rusty Sword", "tier": 0, "condition": 100},
|
||||||
|
{"slot": "armor", "name": "Padded Vest", "tier": 0, "condition": 100},
|
||||||
|
},
|
||||||
|
"supplies": 8,
|
||||||
|
"threat_level": 2,
|
||||||
|
"room": "3 / 7",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedWho puts one adventurer on the board with a public detail sheet, and
|
||||||
|
// pushes a private self-detail set owned by `owner` for that same token.
|
||||||
|
func seedWho(t *testing.T, owner string) *Server {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
e := entry("tok-josie", "Josie", "expedition", "holymachina")
|
||||||
|
e.Level = 14
|
||||||
|
e.ClassRace = "human cleric"
|
||||||
|
e.Detail = publicDetail(t)
|
||||||
|
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed roster = %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
||||||
|
Localpart: owner,
|
||||||
|
Token: "tok-josie",
|
||||||
|
Inventory: []storage.ItemView{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}},
|
||||||
|
Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
|
||||||
|
House: storage.HouseView{Tier: 2, LoanBalance: 1500},
|
||||||
|
Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}},
|
||||||
|
}}}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed detail = %d", w.Code)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWho(t *testing.T, s *Server, token, asUser string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var req = httptest.NewRequest("GET", "/adventure/who/"+token, nil)
|
||||||
|
req.SetPathValue("token", token)
|
||||||
|
if asUser != "" {
|
||||||
|
payload, _ := json.Marshal(SessionUser{Sub: "sub-1", Username: asUser, Exp: time.Now().Add(time.Hour).Unix()})
|
||||||
|
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleAdventureWho(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhoPublicSheet: any visitor sees the mark's stats and gear — driven
|
||||||
|
// through the real template, so a field slip 500s here rather than in prod.
|
||||||
|
func TestWhoPublicSheet(t *testing.T) {
|
||||||
|
s := seedWho(t, "josie")
|
||||||
|
|
||||||
|
w := getWho(t, s, "tok-josie", "") // anonymous
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("GET who = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{"Josie", "human cleric", "Rusty Sword", "Padded Vest"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("public page missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The private goods must NOT be on an anonymous render.
|
||||||
|
for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
|
||||||
|
if strings.Contains(body, leak) {
|
||||||
|
t.Errorf("anonymous page leaked private detail %q", leak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhoSelfUnlock: the owner, signed in on their own page, sees the private
|
||||||
|
// inventory/vault/house/pets inline.
|
||||||
|
func TestWhoSelfUnlock(t *testing.T) {
|
||||||
|
s := seedWho(t, "josie")
|
||||||
|
|
||||||
|
w := getWho(t, s, "tok-josie", "josie")
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("owner GET who = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("owner page missing private detail %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhoSelfUnlockCaseInsensitive: Authentik may hand back a mixed-case
|
||||||
|
// username; the localpart it maps to is lowercase. The owner must still unlock.
|
||||||
|
func TestWhoSelfUnlockCaseInsensitive(t *testing.T) {
|
||||||
|
s := seedWho(t, "josie")
|
||||||
|
|
||||||
|
w := getWho(t, s, "tok-josie", "Josie") // capital-J session
|
||||||
|
if !strings.Contains(w.Body.String(), "Iron Ore") {
|
||||||
|
t.Error("a mixed-case owner username failed to unlock their own self-view")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhoOtherUserNoUnlock: a different signed-in player sees only the public
|
||||||
|
// sheet on someone else's page — the ownership join must not leak across users.
|
||||||
|
func TestWhoOtherUserNoUnlock(t *testing.T) {
|
||||||
|
s := seedWho(t, "josie")
|
||||||
|
|
||||||
|
w := getWho(t, s, "tok-josie", "quack") // signed in, but not the owner
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("other-user GET who = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "Josie") {
|
||||||
|
t.Error("public sheet missing for a signed-in non-owner")
|
||||||
|
}
|
||||||
|
for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
|
||||||
|
if strings.Contains(body, leak) {
|
||||||
|
t.Errorf("a non-owner unlocked private detail %q", leak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhoOffBoardIs404: a token that isn't on the current board resolves to
|
||||||
|
// nothing — the same liveness gate the storefront uses, so a stale or guessed
|
||||||
|
// token never renders a page.
|
||||||
|
func TestWhoOffBoardIs404(t *testing.T) {
|
||||||
|
s := seedWho(t, "josie")
|
||||||
|
if w := getWho(t, s, "tok-ghost", ""); w.Code != 404 {
|
||||||
|
t.Errorf("off-board token = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetailIngestReplacesAndAuth: the detail push is bearer-gated and swaps the
|
||||||
|
// set whole, so a player dropped from a later push loses their self-view.
|
||||||
|
func TestDetailIngestReplacesAndAuth(t *testing.T) {
|
||||||
|
s := seedWho(t, "josie")
|
||||||
|
|
||||||
|
if w := postDetail(t, s, "wrong", detailPush{SnapshotAt: time.Now().Unix()}); w.Code != 401 {
|
||||||
|
t.Errorf("bad bearer = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A later push without Josie: her self-view must be gone, but the public
|
||||||
|
// board (a separate keyspace) is untouched, so her page still renders public.
|
||||||
|
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: time.Now().Unix() + 60}); w.Code != 200 {
|
||||||
|
t.Fatalf("empty detail push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := getWho(t, s, "tok-josie", "josie").Body.String()
|
||||||
|
if strings.Contains(body, "Iron Ore") {
|
||||||
|
t.Error("owner still unlocked after her detail was dropped from the push")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Josie") {
|
||||||
|
t.Error("public page vanished when only the private detail was dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
1
main.go
1
main.go
@@ -300,6 +300,7 @@ func main() {
|
|||||||
ws.StartPushSender(ctx)
|
ws.StartPushSender(ctx)
|
||||||
ws.StartAdventureDigest(ctx)
|
ws.StartAdventureDigest(ctx)
|
||||||
ws.StartTriviaBank(ctx)
|
ws.StartTriviaBank(ctx)
|
||||||
|
ws.StartTableClock(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,25 @@ A multi-session build. This section is the handover; read it before anything els
|
|||||||
|
|
||||||
### Start here (next session)
|
### Start here (next session)
|
||||||
|
|
||||||
**The whole casino is live.** *(2026-07-14.)* All six games (seven, counting the
|
**The casino is going multiplayer, and the settle path has been rewritten to
|
||||||
No Mercy dial) are deployed and playing on https://games.parodia.dev. Nothing is
|
survive it.** *(2026-07-14, later.)* The user asked for Blackjack, UNO and
|
||||||
queued — what is left is the open list at the bottom of "Next, in order".
|
Hold'em to be playable against real people. The design is settled (see "The
|
||||||
|
multiplayer build" below), and **Phase A — the atomic settle — is done, tested
|
||||||
|
and driven.** Nothing a player can see has changed. The next thing to build is
|
||||||
|
Phase B, the table runtime.
|
||||||
|
|
||||||
|
**And the deploy note was stale again — for the second time.** §0 said "a deploy
|
||||||
|
is owed, three commits behind". It wasn't: the box is on `a5b7e41`, its binary was
|
||||||
|
built three minutes after that commit landed, the front door serves a public 200
|
||||||
|
and `/og.png` renders. Everything is live. This is the *same note* that was wrong
|
||||||
|
two sessions ago, with the *same* lesson written directly underneath it, and it
|
||||||
|
went stale again anyway. Draw the obvious conclusion: **a hand-written record of
|
||||||
|
what is deployed will rot. Ask the box.** One `git log -1` in `/opt/pete`, one
|
||||||
|
`stat` on the binary, and one `curl` at a route only the new code serves — that is
|
||||||
|
thirty seconds and it is never wrong.
|
||||||
|
|
||||||
|
**The whole casino is live.** All six games (seven, counting the No Mercy dial)
|
||||||
|
are deployed and playing on https://games.parodia.dev.
|
||||||
|
|
||||||
**And the deploy note this plan had been carrying was wrong.** §0 said "only
|
**And the deploy note this plan had been carrying was wrong.** §0 said "only
|
||||||
blackjack is live" for two sessions running. It wasn't: hangman, solitaire, trivia
|
blackjack is live" for two sessions running. It wasn't: hangman, solitaire, trivia
|
||||||
@@ -69,8 +85,206 @@ 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
|
||||||
|
|
||||||
|
Everything through **`a5b7e41`** is on the box and running. *Verified by asking
|
||||||
|
the box, not by trusting this line — which is the only way this line is worth
|
||||||
|
anything. It has been wrong twice.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The multiplayer build
|
||||||
|
|
||||||
|
Blackjack, UNO and Hold'em, played against real people. This inverts the core
|
||||||
|
entity from **player** to **table**, and the whole difficulty is that two things
|
||||||
|
which were safe stop being safe: the settle path was written for one player and
|
||||||
|
one bet, and a table has two writers (an HTTP move, and a turn clock acting for
|
||||||
|
whoever walked away) where it used to have one.
|
||||||
|
|
||||||
|
### Decisions taken (from the user, 2026-07-14)
|
||||||
|
|
||||||
|
- **SSE, not WebSocket.** Latency was never the argument — a turn-based card game
|
||||||
|
does not care. The argument is that moves keep their existing POST endpoints and
|
||||||
|
the whole money path with them; a socket would mean rebuilding auth, ordering
|
||||||
|
and the double-click 409 on a message loop. EventSource also reconnects itself.
|
||||||
|
Chat is a POST up and an event down.
|
||||||
|
- The line where a socket *would* start paying for itself is **typing
|
||||||
|
indicators** — continuous upstream traffic. The user looked at that and said
|
||||||
|
messages only. If that changes, so does the transport.
|
||||||
|
- Plain polling was rejected for a specific reason: `MaxOpenConns(1)`. Every
|
||||||
|
idle player at a felt polling once a second is a serialized query against the
|
||||||
|
single connection the news app also shares.
|
||||||
|
- **Bots fill empty seats.** A human sitting down bumps a bot, so solo play is just
|
||||||
|
"a table nobody else joined yet" and there is no second mode to maintain.
|
||||||
|
- **UNO becomes a pot**, and the house multiple is deleted. Everyone antes, first
|
||||||
|
one out takes the pot less rake, bots ante from the house. The payout becomes
|
||||||
|
arithmetic rather than a measurement — which **kills the tier-drift problem and
|
||||||
|
`TestTheMultiplesAreStillPriced` with it**.
|
||||||
|
- The consequence was surfaced and accepted, and it is worth understanding
|
||||||
|
before anybody "fixes" it: against bots the pot is **harsher** than today.
|
||||||
|
Naive play loses ~20% a game rather than ~8%, because the bots beat a naive
|
||||||
|
player 60/40 heads-up and a pot does not compensate the way a 2.4× multiple
|
||||||
|
did. A pot is only fair between equals. **The casino already ships this exact
|
||||||
|
deal in hold'em** — you play bots for real chips and the house takes only rake
|
||||||
|
— so the outcome is one money model across the room instead of two.
|
||||||
|
- **Chat stays on the felt.** It does not mirror into Matrix.
|
||||||
|
- Tables are found through a **public lobby**. Room codes later, if ever; bots mean
|
||||||
|
a table is never empty.
|
||||||
|
|
||||||
|
### The four rules the runtime has to obey
|
||||||
|
|
||||||
|
Not style preferences. Breaking the first two hangs the process or mints money.
|
||||||
|
|
||||||
|
1. **Table lock first, DB second. Never begin a transaction before you hold the
|
||||||
|
lock; never take a lock while holding one.** SQLite is at `MaxOpenConns(1)`, so
|
||||||
|
the connection *is* a global mutex. A per-table mutex makes two locks, and two
|
||||||
|
locks in two orders is a deadlock that eats the only connection — taking the
|
||||||
|
news app down with the casino.
|
||||||
|
2. **The primary key decides, not a prior read.** Already this repo's idiom
|
||||||
|
(`StartLiveHand`'s `ON CONFLICT DO NOTHING`; `game_escrow.guid`). Every new
|
||||||
|
money path gets the same treatment.
|
||||||
|
3. **A settle is one transaction.** See Phase A.
|
||||||
|
4. **A `version` column is the concurrency authority; the mutex is only an
|
||||||
|
optimisation.** A mutex does not survive a redeploy — during a drain two
|
||||||
|
processes hold two different mutexes over the same row — and a mutex map you can
|
||||||
|
`delete()` from will hand two goroutines two different locks for one table.
|
||||||
|
|
||||||
|
### Phase A — the atomic settle. **Done.**
|
||||||
|
|
||||||
|
Shipped on its own, against the existing single-player games, changing nothing a
|
||||||
|
player can see. Every multiplayer money bug is downstream of it.
|
||||||
|
|
||||||
|
`commit()` was four separate autocommit statements: save → `Award` → `RecordHand`
|
||||||
|
→ `ClearLiveHand`, carefully sequenced so a crash between any two cost the player
|
||||||
|
as little as possible. That reasoning is sound for a game owned by one player, and
|
||||||
|
the old comment made it well. **It does not survive a pot.** Pay the winner, die
|
||||||
|
before the state write, and the hand still reads as live — so it settles again and
|
||||||
|
pays again. Chips minted from nothing, and gogobee turns them into euros.
|
||||||
|
|
||||||
|
Worse, the obvious fix is a trap. `storage.Award` is a bare `Get().Exec`, so
|
||||||
|
wrapping the settle in a transaction makes it wait for the connection *the
|
||||||
|
transaction is holding*. Not an error — **a hung process**, and the news app goes
|
||||||
|
with it.
|
||||||
|
|
||||||
|
So: `storage.CommitHand` does the lot — seat, pay, record, clear, touch — in one
|
||||||
|
`Begin`/`Commit`, with tx-taking `award`/`recordHand` helpers beside the public
|
||||||
|
ones. The model already existed: `addChips(tx, …)` has done it this way since the
|
||||||
|
escrow ledger was written.
|
||||||
|
|
||||||
|
Two things fell out that are worth keeping:
|
||||||
|
|
||||||
|
- **The refuse-and-refund is now atomic.** A deal landing on a taken seat used to
|
||||||
|
come back `ErrHandInProgress` and *then* refund in a separate statement — so a
|
||||||
|
crash in between took a player's stake for a game that existed nowhere: no felt,
|
||||||
|
no audit row, no way to find it. It is one transaction now.
|
||||||
|
- **The audit row moved inside the settle**, which means a failure to write it now
|
||||||
|
rolls the payout back rather than paying quietly and logging. Deliberate: the
|
||||||
|
payout and the audit row are the same fact, and a payout nobody can account for
|
||||||
|
is worse than one that didn't happen — the game stays live and settles again on
|
||||||
|
the next request.
|
||||||
|
|
||||||
|
**`TestTheSettleDoesNotDeadlockAgainstItsOwnConnection` is a canary, and it has
|
||||||
|
been made to sing.** Reintroduce the bug (call `Award` instead of `award` inside
|
||||||
|
`CommitHand`) and it does not fail with a nice message — it *hangs*, and the
|
||||||
|
timeout is the message, which is exactly the production failure. It was verified by
|
||||||
|
putting the bug back and watching it catch it. A canary that has never sung is
|
||||||
|
just a bird.
|
||||||
|
|
||||||
|
Driven end to end through the real HTTP path, not just unit tests: eight blackjack
|
||||||
|
hands conserving to the chip across win/lose/push (a natural is the sharp case —
|
||||||
|
it is `Fresh` *and* `Done` in one `CommitHand`), a double-deal 409 that refunds and
|
||||||
|
leaves the live game untouched, hangman, and a hold'em session (sit 200, fold the
|
||||||
|
blinds, get up with 197).
|
||||||
|
|
||||||
|
### Phase B — the table runtime. **Next.**
|
||||||
|
|
||||||
|
Full design in `~/.claude/plans/imperative-mixing-emerson.md`. The load-bearing
|
||||||
|
parts, which are the ones that are easy to get wrong:
|
||||||
|
|
||||||
|
- **Occupancy stays in `game_live_hands`** — add a nullable `table_id` rather than
|
||||||
|
making `game_seats.matrix_user` a second uniqueness domain. A split brain there
|
||||||
|
silently switches off three guards that already work, and the worst is the
|
||||||
|
cash-out check (`games_play.go`), which reads `LoadLiveHand`: a seated player
|
||||||
|
with no live-hand row could **cash out to zero while sitting at a poker table
|
||||||
|
with chips in the pot.**
|
||||||
|
- **The turn clock is the first goroutine in Pete that has ever mutated game
|
||||||
|
state.** Copy `StartTriviaBank`'s shape. It must collect table ids and *close the
|
||||||
|
rows* before taking any lock (rule 1), and act only if the `version` still
|
||||||
|
matches the one it saw at scan time. Without that check: Bob's raise lands in the
|
||||||
|
same second his clock expires, action passes to Cara, and the clock then **folds
|
||||||
|
Cara**, who had 25 seconds left. A one-second window that recurs on every turn of
|
||||||
|
every hand.
|
||||||
|
- **An absent human is not a bot**, and this is a product bug before it is a money
|
||||||
|
bug. The bot loop stops dead at a disconnected player's seat and waits out the
|
||||||
|
full clock; a table with three ghosts spends a minute an orbit folding air and
|
||||||
|
the one real player leaves. Seats go `away` after a timeout and get auto-acted.
|
||||||
|
- **SSE frames publish under the table lock** (which orders them correctly for
|
||||||
|
free) but with **non-blocking sends only** — one phone on a train must not block
|
||||||
|
a send, hold the lock, and stall the clock for the whole casino. And **the SSE
|
||||||
|
handler must never touch the DB after its first read**: holding rows open for the
|
||||||
|
life of a stream holds the only connection forever, and one subscriber bricks the
|
||||||
|
application.
|
||||||
|
- **`dropUnreadable` must never fire on a multi-seat table.** It discards the game
|
||||||
|
and keeps the stake — defensible for one player, but at a shared table it
|
||||||
|
silently incinerates four players' stacks.
|
||||||
|
- **`ReapIdleSessions` has never actually run.** Zero production callers; its doc
|
||||||
|
says "safe to run on a timer" and nothing ever put it on one, so chips in
|
||||||
|
abandoned sessions have been in limbo all along. Wire it while wiring the clock.
|
||||||
|
|
||||||
|
Then: **C — Hold'em** (its engine is already multiway: real seats, side pots,
|
||||||
|
`ToAct`. Mostly letting more than one seat be `Bot: false`. **Write the per-seat
|
||||||
|
redaction test first** — after SSE, one missed `i == You` fans every hole card to
|
||||||
|
every subscriber). **D — UNO** (`const You = 0` becomes a seat parameter, ~40
|
||||||
|
sites; the pot). **E — Blackjack** (seats invented from scratch, but the simplest
|
||||||
|
turn model. **Shuffle every round**: a persistent shoe broadcast over SSE is a
|
||||||
|
countable shoe with a free API, and counting is a real +EV attack on real euros).
|
||||||
|
|
||||||
|
Two that are easy to miss: **`RecordHand` takes one user, so `HouseTake` will
|
||||||
|
lie** — four rows each carrying the pot's full rake reports 4× the real take, and
|
||||||
|
that number is the inflation brake the economy was sized on. And **seeds stop
|
||||||
|
reproducing a hand**: at a shared table the cards depend on the order the others
|
||||||
|
acted, so the audit trail quietly stops being one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Decisions taken (these close §9's open questions)
|
### Decisions taken (these close §9's open questions)
|
||||||
|
|
||||||
@@ -603,18 +817,20 @@ 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. **Phase B — the table runtime** (see "The multiplayer build" above). Phase A is
|
||||||
deployed. What is left is the open list below — none of it blocking, none of it
|
done; nothing is half-built.
|
||||||
promised.
|
2. Then C (hold'em), D (UNO), E (blackjack).
|
||||||
2. The obvious candidates, in the order I'd take them: **blackjack has no split**;
|
3. 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** (and multiplayer makes that matter more, not less — a
|
||||||
(`games: trivia bank refill started target=400`), so a player trying a ladder in
|
six-handed table of humans is not the heads-up game the policy was trained on);
|
||||||
the first minute after a fresh deploy can still meet a 503.
|
the trivia bank refills on a 12h tick (`games: trivia bank refill started
|
||||||
|
target=400`), so a player trying a ladder in the first minute 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