Compare commits
16 Commits
adventure-
...
6961f90634
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6961f90634 | ||
|
|
b00da21a47 | ||
|
|
8ec13eab5b | ||
|
|
c69fbb63db | ||
|
|
cb84e1d549 | ||
|
|
44613c4760 | ||
|
|
a442cfccaa | ||
|
|
f9a98f72a6 | ||
|
|
8310b30439 | ||
|
|
6ccd18452c | ||
|
|
e85ebe56f7 | ||
|
|
99574db3e9 | ||
|
|
8cb5b38599 | ||
|
|
a614077cff | ||
|
|
82d1c6ebeb | ||
|
|
10bcc78c51 |
@@ -58,6 +58,12 @@ client_secret = "${PETE_OIDC_CLIENT_SECRET}"
|
|||||||
redirect_url = "https://news.parodia.dev/auth/callback"
|
redirect_url = "https://news.parodia.dev/auth/callback"
|
||||||
# HMAC key that signs the session cookie. Generate with: openssl rand -hex 32
|
# HMAC key that signs the session cookie. Generate with: openssl rand -hex 32
|
||||||
session_secret = "${PETE_SESSION_SECRET}"
|
session_secret = "${PETE_SESSION_SECRET}"
|
||||||
|
# Share the session across sibling hosts, so signing in on news.parodia.dev also
|
||||||
|
# signs you in on games.parodia.dev. This widens the cookie to every host under
|
||||||
|
# the domain, so leave it empty to keep the session host-only. Each host that
|
||||||
|
# starts a login also needs its own redirect URI registered in Authentik
|
||||||
|
# (<host>/auth/callback) — the login round-trip returns to the host it began on.
|
||||||
|
cookie_domain = ""
|
||||||
|
|
||||||
# Optional Web Push digests. When enabled, signed-in users can opt in (from the
|
# Optional Web Push digests. When enabled, signed-in users can opt in (from the
|
||||||
# feed-settings panel) to a periodic "N new stories" notification, delivered via
|
# feed-settings panel) to a periodic "N new stories" notification, delivered via
|
||||||
@@ -96,6 +102,16 @@ label = "Amy (US, female)"
|
|||||||
id = "en_US-ryan-high"
|
id = "en_US-ryan-high"
|
||||||
label = "Ryan (US, male, HQ)"
|
label = "Ryan (US, male, HQ)"
|
||||||
|
|
||||||
|
# The casino (games.parodia.dev). Signed-in only — there is real money in it — so
|
||||||
|
# it needs [web.auth] above, and web.auth.cookie_domain if the games host is a
|
||||||
|
# different subdomain from the news one. Chips are 1:1 with gogobee euros and
|
||||||
|
# cross the border through the escrow endpoints, which gogobee polls; matrix_server
|
||||||
|
# is how a player is named to that ledger (@<authentik username>:<matrix_server>).
|
||||||
|
[web.games]
|
||||||
|
enabled = false
|
||||||
|
host = "games.parodia.dev"
|
||||||
|
matrix_server = "parodia.dev"
|
||||||
|
|
||||||
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
|
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
|
||||||
# There is no automatic classification — Pete posts each story to its configured channel.
|
# There is no automatic classification — Pete posts each story to its configured channel.
|
||||||
# Optional: language = "en" drops feed items whose per-item <language> tag is
|
# Optional: language = "en" drops feed items whose per-item <language> tag is
|
||||||
|
|||||||
@@ -53,13 +53,14 @@ const defaultDigestHour = 17
|
|||||||
|
|
||||||
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
|
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
|
||||||
type WebConfig struct {
|
type WebConfig struct {
|
||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
ListenAddr string `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080"
|
ListenAddr string `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080"
|
||||||
SiteTitle string `toml:"site_title"` // display name in the header
|
SiteTitle string `toml:"site_title"` // display name in the header
|
||||||
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
||||||
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
||||||
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
|
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
|
||||||
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
|
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
|
||||||
|
Games GamesConfig `toml:"games"` // optional casino (games.parodia.dev)
|
||||||
// AdminSubs is the allowlist of OIDC subjects allowed to view the
|
// AdminSubs is the allowlist of OIDC subjects allowed to view the
|
||||||
// owner-facing source-health dashboard at /status. Empty means the page is
|
// owner-facing source-health dashboard at /status. Empty means the page is
|
||||||
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
||||||
@@ -108,6 +109,23 @@ type VoiceConfig struct {
|
|||||||
Label string `toml:"label"` // menu label, e.g. "Ryan — US, male"
|
Label string `toml:"label"` // menu label, e.g. "Ryan — US, male"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GamesConfig wires the casino. It is signed-in only — there is money in it —
|
||||||
|
// so it does nothing without web.auth, and it needs the Matrix server name
|
||||||
|
// because that is how a player's identity reaches gogobee's euro ledger: an
|
||||||
|
// Authentik username is the Matrix localpart, and the Matrix id is the account.
|
||||||
|
type GamesConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
// Host is the public hostname the casino answers on, e.g.
|
||||||
|
// "games.parodia.dev". Requests arriving on it are served the casino at "/";
|
||||||
|
// everywhere else the same pages live under /games. Empty means no host
|
||||||
|
// branching, which is the normal state of affairs in local development.
|
||||||
|
Host string `toml:"host"`
|
||||||
|
// MatrixServer is the server name half of a player's Matrix id
|
||||||
|
// ("parodia.dev" -> @reala:parodia.dev). Without it, no player can be
|
||||||
|
// identified in the economy and the casino stays shut.
|
||||||
|
MatrixServer string `toml:"matrix_server"`
|
||||||
|
}
|
||||||
|
|
||||||
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
|
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
|
||||||
// When enabled, signed-in users get their preferences stored server-side keyed
|
// When enabled, signed-in users get their preferences stored server-side keyed
|
||||||
// by the OIDC subject; anonymous visitors keep using browser localStorage.
|
// by the OIDC subject; anonymous visitors keep using browser localStorage.
|
||||||
@@ -118,6 +136,12 @@ type AuthConfig struct {
|
|||||||
ClientSecret string `toml:"client_secret"`
|
ClientSecret string `toml:"client_secret"`
|
||||||
RedirectURL string `toml:"redirect_url"` // e.g. https://news.parodia.dev/auth/callback
|
RedirectURL string `toml:"redirect_url"` // e.g. https://news.parodia.dev/auth/callback
|
||||||
SessionSecret string `toml:"session_secret"` // HMAC key for signing the session cookie
|
SessionSecret string `toml:"session_secret"` // HMAC key for signing the session cookie
|
||||||
|
// CookieDomain widens the session cookie beyond the host that set it, so a
|
||||||
|
// sign-in on news.parodia.dev is also a sign-in on games.parodia.dev. Set it
|
||||||
|
// to ".parodia.dev" to share the session across every parodia.dev host —
|
||||||
|
// which is every host, including the landing site, so it is opt-in rather
|
||||||
|
// than the default. Empty keeps the cookie host-only.
|
||||||
|
CookieDomain string `toml:"cookie_domain"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MatrixConfig struct {
|
type MatrixConfig struct {
|
||||||
|
|||||||
346
internal/games/blackjack/blackjack.go
Normal file
346
internal/games/blackjack/blackjack.go
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
// Package blackjack is a pure blackjack engine.
|
||||||
|
//
|
||||||
|
// It knows nothing about HTTP, sockets, timers, euros or players' names. You
|
||||||
|
// hand it a state and a move, it hands you back a new state and the list of
|
||||||
|
// things that just happened. Everything else — who is sitting there, what their
|
||||||
|
// chips are, when their clock runs out — belongs to the shell in internal/games/table.
|
||||||
|
//
|
||||||
|
// That seam is the one thing gogobee's blackjack never had: there, the engine
|
||||||
|
// *was* the message sender, so an "error" meant a Matrix send had failed rather
|
||||||
|
// than that a player had tried something illegal. Here an error means exactly
|
||||||
|
// one thing: the move was not legal in this state.
|
||||||
|
//
|
||||||
|
// The state is a plain value. It serializes, so a hand survives a redeploy, and
|
||||||
|
// it replays, so a disputed hand can be dealt again from its seed.
|
||||||
|
package blackjack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"math/rand/v2"
|
||||||
|
|
||||||
|
"pete/internal/games/cards"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Errors an illegal move can produce. Callers can match on these to tell a
|
||||||
|
// player "not now" rather than "something broke".
|
||||||
|
var (
|
||||||
|
ErrHandOver = errors.New("blackjack: the hand is already over")
|
||||||
|
ErrNotYourTurn = errors.New("blackjack: it is not the player's turn to act")
|
||||||
|
ErrUnknownMove = errors.New("blackjack: unknown move")
|
||||||
|
ErrCantDouble = errors.New("blackjack: double is only allowed on the opening two cards")
|
||||||
|
ErrDeckExhausted = errors.New("blackjack: the shoe is empty")
|
||||||
|
ErrBadBet = errors.New("blackjack: bet must be positive")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase is whose turn it is.
|
||||||
|
type Phase string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PhasePlayer Phase = "player" // the player is acting
|
||||||
|
PhaseDealer Phase = "dealer" // transient: the dealer is drawing out
|
||||||
|
PhaseDone Phase = "done" // settled, Outcome and Payout are final
|
||||||
|
)
|
||||||
|
|
||||||
|
// Outcome is how a finished hand finished, from the player's point of view.
|
||||||
|
type Outcome string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutcomeNone Outcome = ""
|
||||||
|
OutcomeBlackjack Outcome = "blackjack" // natural 21, paid 3:2
|
||||||
|
OutcomeWin Outcome = "win"
|
||||||
|
OutcomeLose Outcome = "lose"
|
||||||
|
OutcomePush Outcome = "push" // tie, stake returned
|
||||||
|
OutcomeBust Outcome = "bust" // player went over 21
|
||||||
|
OutcomeDealerBust Outcome = "dealer_bust"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Won reports whether this outcome pays the player more than their stake back.
|
||||||
|
func (o Outcome) Won() bool {
|
||||||
|
return o == OutcomeWin || o == OutcomeBlackjack || o == OutcomeDealerBust
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules are the table's terms. They're part of the state rather than a global,
|
||||||
|
// so a hand always settles under the rules it was dealt under — even if the
|
||||||
|
// house changes them mid-session.
|
||||||
|
type Rules struct {
|
||||||
|
Decks int `json:"decks"` // shoe size
|
||||||
|
BlackjackPays float64 `json:"blackjack_pays"` // 1.5 = the honest 3:2
|
||||||
|
DealerHitsSoft17 bool `json:"dealer_hits_soft17"` // gogobee's dealer does
|
||||||
|
RakePct float64 `json:"rake_pct"` // house cut, taken from winnings only
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// — see Settle for exactly what it touches.
|
||||||
|
func DefaultRules() Rules {
|
||||||
|
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
|
||||||
|
// isn't in v1, so there's exactly one player hand.
|
||||||
|
type State struct {
|
||||||
|
Rules Rules `json:"rules"`
|
||||||
|
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"`
|
||||||
|
|
||||||
|
Bet int64 `json:"bet"` // chips at risk; doubles on a double-down
|
||||||
|
Doubled bool `json:"doubled"`
|
||||||
|
|
||||||
|
Phase Phase `json:"phase"`
|
||||||
|
Outcome Outcome `json:"outcome"`
|
||||||
|
|
||||||
|
// Payout is what returns to the player's chip stack when the hand is done:
|
||||||
|
// stake plus winnings, net of rake. Zero on a loss. Rake is the house's cut,
|
||||||
|
// recorded so the ledger can account for every chip that left the table.
|
||||||
|
Payout int64 `json:"payout"`
|
||||||
|
Rake int64 `json:"rake"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is something the table can narrate or animate. The engine emits them
|
||||||
|
// instead of drawing anything itself.
|
||||||
|
type Event struct {
|
||||||
|
Kind string `json:"kind"` // "deal" | "player_card" | "dealer_card" | "reveal" | "settle"
|
||||||
|
Card *cards.Card `json:"card,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move is a player action.
|
||||||
|
type Move string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Hit Move = "hit"
|
||||||
|
Stand Move = "stand"
|
||||||
|
Double Move = "double"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandValue totals a hand, counting each ace as 11 until that would bust, then
|
||||||
|
// demoting them one at a time. soft reports whether an ace is still counting as
|
||||||
|
// 11 — which is what makes "soft 17" a different thing from 17.
|
||||||
|
func HandValue(hand []cards.Card) (total int, soft bool) {
|
||||||
|
aces := 0
|
||||||
|
for _, c := range hand {
|
||||||
|
switch {
|
||||||
|
case c.Rank == cards.Ace:
|
||||||
|
aces++
|
||||||
|
total += 11
|
||||||
|
case c.Rank >= 10:
|
||||||
|
total += 10
|
||||||
|
default:
|
||||||
|
total += int(c.Rank)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for total > 21 && aces > 0 {
|
||||||
|
total -= 10 // demote an ace from 11 to 1
|
||||||
|
aces--
|
||||||
|
}
|
||||||
|
return total, aces > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBlackjack reports a natural: 21 on the opening two cards. A 21 assembled
|
||||||
|
// from three cards is not one, and does not get paid 3:2.
|
||||||
|
func IsBlackjack(hand []cards.Card) bool {
|
||||||
|
if len(hand) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, _ := HandValue(hand)
|
||||||
|
return v == 21
|
||||||
|
}
|
||||||
|
|
||||||
|
// New deals a fresh hand: two to the player, two to the dealer. If either side
|
||||||
|
// has a natural the hand is already over and the returned State is settled — a
|
||||||
|
// player with blackjack never gets asked whether they'd like to hit.
|
||||||
|
func New(bet int64, r Rules, rng *rand.Rand) (State, []Event, error) {
|
||||||
|
if bet <= 0 {
|
||||||
|
return State{}, nil, ErrBadBet
|
||||||
|
}
|
||||||
|
if r.Decks < 1 {
|
||||||
|
r.Decks = 1
|
||||||
|
}
|
||||||
|
deck := cards.NewDeck(r.Decks)
|
||||||
|
deck.Shuffle(rng)
|
||||||
|
|
||||||
|
s := State{Rules: r, Deck: deck, Bet: bet, Phase: PhasePlayer}
|
||||||
|
evs := []Event{{Kind: "deal"}}
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if err := s.draw(&s.Player, "player_card", &evs); err != nil {
|
||||||
|
return State{}, nil, err
|
||||||
|
}
|
||||||
|
if err := s.draw(&s.Dealer, "dealer_card", &evs); err != nil {
|
||||||
|
return State{}, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A natural on either side ends it before the player ever acts.
|
||||||
|
if IsBlackjack(s.Player) || IsBlackjack(s.Dealer) {
|
||||||
|
s.settle(&evs)
|
||||||
|
}
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// draw takes one card off the shoe onto the given hand and records the event.
|
||||||
|
// Pointer receiver: it mutates the deck and the hand together, and neither may
|
||||||
|
// end up applied to a stale copy of the state.
|
||||||
|
func (s *State) draw(hand *[]cards.Card, kind string, evs *[]Event) error {
|
||||||
|
c, ok := s.Deck.Draw()
|
||||||
|
if !ok {
|
||||||
|
return ErrDeckExhausted
|
||||||
|
}
|
||||||
|
*hand = append(*hand, c)
|
||||||
|
card := c
|
||||||
|
*evs = append(*evs, Event{Kind: kind, Card: &card})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyMove is the whole engine: a legal move in, a new state and the events it
|
||||||
|
// produced out. An error means the move was illegal and the state is unchanged.
|
||||||
|
//
|
||||||
|
// s is taken by value, so the caller's state is only replaced on success.
|
||||||
|
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||||
|
if s.Phase == PhaseDone {
|
||||||
|
return s, nil, ErrHandOver
|
||||||
|
}
|
||||||
|
// A copied State still shares its slices' backing arrays with the original.
|
||||||
|
// Two moves applied from the same starting state would then append cards over
|
||||||
|
// each other. Clone first: the caller's state is genuinely untouched, and a
|
||||||
|
// state can be replayed as many times as we like.
|
||||||
|
s = s.clone()
|
||||||
|
if s.Phase != PhasePlayer {
|
||||||
|
return s, nil, ErrNotYourTurn
|
||||||
|
}
|
||||||
|
if m == Double && len(s.Player) != 2 {
|
||||||
|
// 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.
|
||||||
|
return s, nil, ErrCantDouble
|
||||||
|
}
|
||||||
|
if m != Hit && m != Stand && m != Double {
|
||||||
|
return s, nil, ErrUnknownMove
|
||||||
|
}
|
||||||
|
|
||||||
|
evs := []Event{}
|
||||||
|
|
||||||
|
if m == Double {
|
||||||
|
s.Bet *= 2
|
||||||
|
s.Doubled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if m == Hit || m == Double {
|
||||||
|
if err := s.draw(&s.Player, "player_card", &evs); err != nil {
|
||||||
|
return s, nil, err
|
||||||
|
}
|
||||||
|
if v, _ := HandValue(s.Player); v > 21 {
|
||||||
|
s.settle(&evs) // bust; the dealer never has to play
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
if m == Hit {
|
||||||
|
return s, evs, nil // still the player's turn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stand, or a double that survived its card: the dealer draws out.
|
||||||
|
s.Phase = PhaseDealer
|
||||||
|
s.dealerPlay(&evs)
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dealerPlay draws the dealer out to the house rule, then settles. The dealer
|
||||||
|
// has no choices to make — that's the game — so this needs no move.
|
||||||
|
func (s *State) dealerPlay(evs *[]Event) {
|
||||||
|
*evs = append(*evs, Event{Kind: "reveal"}) // the hole card turns over
|
||||||
|
for {
|
||||||
|
v, soft := HandValue(s.Dealer)
|
||||||
|
hitSoft17 := s.Rules.DealerHitsSoft17 && v == 17 && soft
|
||||||
|
if v >= 17 && !hitSoft17 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := s.draw(&s.Dealer, "dealer_card", evs); err != nil {
|
||||||
|
break // shoe ran dry mid-draw; settle on what's on the table
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.settle(evs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// settle decides the outcome and the payout, and is the only place chips are
|
||||||
|
// computed.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// 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.
|
||||||
|
func (s *State) settle(evs *[]Event) {
|
||||||
|
playerVal, _ := HandValue(s.Player)
|
||||||
|
dealerVal, _ := HandValue(s.Dealer)
|
||||||
|
playerBJ := IsBlackjack(s.Player)
|
||||||
|
dealerBJ := IsBlackjack(s.Dealer)
|
||||||
|
|
||||||
|
// profit is what the player wins on top of their stake. Negative means the
|
||||||
|
// stake is gone.
|
||||||
|
var profit int64
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case playerVal > 21:
|
||||||
|
s.Outcome = OutcomeBust
|
||||||
|
profit = -s.Bet
|
||||||
|
case playerBJ && dealerBJ:
|
||||||
|
s.Outcome = OutcomePush
|
||||||
|
case playerBJ:
|
||||||
|
s.Outcome = OutcomeBlackjack
|
||||||
|
profit = int64(math.Floor(float64(s.Bet) * s.Rules.BlackjackPays))
|
||||||
|
case dealerBJ:
|
||||||
|
s.Outcome = OutcomeLose
|
||||||
|
profit = -s.Bet
|
||||||
|
case dealerVal > 21:
|
||||||
|
s.Outcome = OutcomeDealerBust
|
||||||
|
profit = s.Bet
|
||||||
|
case playerVal > dealerVal:
|
||||||
|
s.Outcome = OutcomeWin
|
||||||
|
profit = s.Bet
|
||||||
|
case playerVal == dealerVal:
|
||||||
|
s.Outcome = OutcomePush
|
||||||
|
default:
|
||||||
|
s.Outcome = OutcomeLose
|
||||||
|
profit = -s.Bet
|
||||||
|
}
|
||||||
|
|
||||||
|
if profit > 0 {
|
||||||
|
s.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct))
|
||||||
|
if s.Rake < 0 {
|
||||||
|
s.Rake = 0
|
||||||
|
}
|
||||||
|
profit -= s.Rake
|
||||||
|
}
|
||||||
|
if profit < 0 {
|
||||||
|
s.Payout = 0 // stake is lost; nothing comes back
|
||||||
|
} else {
|
||||||
|
s.Payout = s.Bet + profit
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Phase = PhaseDone
|
||||||
|
*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
|
||||||
|
// they put up. Negative on a loss, zero on a push.
|
||||||
|
func (s State) Net() int64 {
|
||||||
|
if s.Phase != PhaseDone {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return s.Payout - s.Bet
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanDouble reports whether Double is legal right now — the shell asks this to
|
||||||
|
// decide whether to light the button up.
|
||||||
|
func (s State) CanDouble() bool {
|
||||||
|
return s.Phase == PhasePlayer && len(s.Player) == 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// clone deep-copies the slices so a derived state shares no backing array with
|
||||||
|
// the one it came from.
|
||||||
|
func (s State) clone() State {
|
||||||
|
s.Deck = append(cards.Deck(nil), s.Deck...)
|
||||||
|
s.Player = append([]cards.Card(nil), s.Player...)
|
||||||
|
s.Dealer = append([]cards.Card(nil), s.Dealer...)
|
||||||
|
return s
|
||||||
|
}
|
||||||
415
internal/games/blackjack/blackjack_test.go
Normal file
415
internal/games/blackjack/blackjack_test.go
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
package blackjack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pete/internal/games/cards"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hand builds a hand from "A♠"-ish shorthand: rank letters/numbers only.
|
||||||
|
func hand(ranks ...cards.Rank) []cards.Card {
|
||||||
|
h := make([]cards.Card, len(ranks))
|
||||||
|
for i, r := range ranks {
|
||||||
|
h[i] = cards.Card{Rank: r, Suit: cards.Spades}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandValue(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hand []cards.Card
|
||||||
|
want int
|
||||||
|
soft bool
|
||||||
|
}{
|
||||||
|
{"two aces are 12, not 22", hand(cards.Ace, cards.Ace), 12, true},
|
||||||
|
{"ace plus king is a soft 21", hand(cards.Ace, cards.King), 21, true},
|
||||||
|
{"faces are all ten", hand(cards.Jack, cards.Queen), 20, false},
|
||||||
|
{"ace demotes to save the hand", hand(cards.Ace, 9, 5), 15, false},
|
||||||
|
{"three aces and an eight", hand(cards.Ace, cards.Ace, cards.Ace, 8), 21, true},
|
||||||
|
{"soft 17 is an ace and a six", hand(cards.Ace, 6), 17, true},
|
||||||
|
{"hard 17 has no ace", hand(cards.King, 7), 17, false},
|
||||||
|
{"a bust stays busted", hand(cards.King, cards.Queen, 5), 25, false},
|
||||||
|
{"empty hand", nil, 0, false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, soft := HandValue(tc.hand)
|
||||||
|
if got != tc.want || soft != tc.soft {
|
||||||
|
t.Fatalf("HandValue = (%d, soft=%v), want (%d, soft=%v)", got, soft, tc.want, tc.soft)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBlackjack_OnlyOnTwoCards(t *testing.T) {
|
||||||
|
if !IsBlackjack(hand(cards.Ace, cards.King)) {
|
||||||
|
t.Fatal("A+K is a natural")
|
||||||
|
}
|
||||||
|
// 21 built from three cards is not a natural and must not be paid 3:2.
|
||||||
|
if IsBlackjack(hand(7, 7, 7)) {
|
||||||
|
t.Fatal("7+7+7 is 21 but not a blackjack")
|
||||||
|
}
|
||||||
|
if IsBlackjack(hand(cards.Ace)) {
|
||||||
|
t.Fatal("one card is not a blackjack")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// settleWith forces a finished hand and reads back the money, bypassing the
|
||||||
|
// 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 {
|
||||||
|
t.Helper()
|
||||||
|
s := State{Rules: r, Bet: bet, Player: player, Dealer: dealer}
|
||||||
|
evs := []Event{}
|
||||||
|
s.settle(&evs)
|
||||||
|
if s.Phase != PhaseDone {
|
||||||
|
t.Fatal("settle left the hand unfinished")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettle_PayoutsAndRake(t *testing.T) {
|
||||||
|
r := DefaultRules() // 3:2, 5% rake
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
player []cards.Card
|
||||||
|
dealer []cards.Card
|
||||||
|
wantOutcome Outcome
|
||||||
|
wantPayout int64 // chips returned to the stack
|
||||||
|
wantRake int64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// 100 stake, 100 profit, 5 raked → 195 back, net +95.
|
||||||
|
name: "a plain win is raked on the profit only",
|
||||||
|
player: hand(cards.King, 9), dealer: hand(cards.King, 8),
|
||||||
|
wantOutcome: OutcomeWin, wantPayout: 195, wantRake: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 3:2 on 100 is 150 profit, 7 raked (floor of 7.5) → 243 back.
|
||||||
|
name: "a natural pays 3:2 less rake",
|
||||||
|
player: hand(cards.Ace, cards.King), dealer: hand(cards.King, 8),
|
||||||
|
wantOutcome: OutcomeBlackjack, wantPayout: 243, wantRake: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a push returns the stake untouched — the house takes nothing",
|
||||||
|
player: hand(cards.King, 9), dealer: hand(cards.Queen, 9),
|
||||||
|
wantOutcome: OutcomePush, wantPayout: 100, wantRake: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two naturals push",
|
||||||
|
player: hand(cards.Ace, cards.King), dealer: hand(cards.Ace, cards.Queen),
|
||||||
|
wantOutcome: OutcomePush, wantPayout: 100, wantRake: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a loss pays nothing and is not charged a rake",
|
||||||
|
player: hand(cards.King, 8), dealer: hand(cards.King, 9),
|
||||||
|
wantOutcome: OutcomeLose, wantPayout: 0, wantRake: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a bust pays nothing even if the dealer would have busted too",
|
||||||
|
player: hand(cards.King, 8, 9), dealer: hand(cards.King, 6, 9),
|
||||||
|
wantOutcome: OutcomeBust, wantPayout: 0, wantRake: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dealer blackjack beats the player's twenty",
|
||||||
|
player: hand(cards.King, cards.Queen), dealer: hand(cards.Ace, cards.Jack),
|
||||||
|
wantOutcome: OutcomeLose, wantPayout: 0, wantRake: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dealer bust pays even money less rake",
|
||||||
|
player: hand(cards.King, 5), dealer: hand(cards.King, 6, 9),
|
||||||
|
wantOutcome: OutcomeDealerBust, wantPayout: 195, wantRake: 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
s := settleWith(t, r, 100, tc.player, tc.dealer)
|
||||||
|
if s.Outcome != tc.wantOutcome {
|
||||||
|
t.Errorf("outcome = %q, want %q", s.Outcome, tc.wantOutcome)
|
||||||
|
}
|
||||||
|
if s.Payout != tc.wantPayout {
|
||||||
|
t.Errorf("payout = %d, want %d", s.Payout, tc.wantPayout)
|
||||||
|
}
|
||||||
|
if s.Rake != tc.wantRake {
|
||||||
|
t.Errorf("rake = %d, want %d", s.Rake, tc.wantRake)
|
||||||
|
}
|
||||||
|
// The invariant the ledger depends on: every chip the player staked
|
||||||
|
// either comes back, goes to the house as rake, or is lost to the table.
|
||||||
|
if s.Payout < 0 || s.Rake < 0 {
|
||||||
|
t.Errorf("negative chips: payout=%d rake=%d", s.Payout, s.Rake)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettle_RakeNeverTouchesTheStake(t *testing.T) {
|
||||||
|
// A 100% rake is absurd, but it must still never claw back a player's own
|
||||||
|
// stake: the worst a rake can do is take all the winnings.
|
||||||
|
r := Rules{Decks: 6, BlackjackPays: 1.5, RakePct: 1.0}
|
||||||
|
s := settleWith(t, r, 100, hand(cards.King, 9), hand(cards.King, 8))
|
||||||
|
if s.Payout != 100 {
|
||||||
|
t.Fatalf("payout = %d, want the stake back (100)", s.Payout)
|
||||||
|
}
|
||||||
|
if s.Net() != 0 {
|
||||||
|
t.Fatalf("net = %d, want 0", s.Net())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_DealsFourCardsAndAskThePlayer(t *testing.T) {
|
||||||
|
rng := cards.NewRNG(1, 2)
|
||||||
|
s, evs, err := New(50, DefaultRules(), rng)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(s.Player) != 2 || len(s.Dealer) != 2 {
|
||||||
|
t.Fatalf("dealt %d/%d cards, want 2/2", len(s.Player), len(s.Dealer))
|
||||||
|
}
|
||||||
|
if len(s.Deck) != 6*52-4 {
|
||||||
|
t.Fatalf("shoe has %d cards left, want %d", len(s.Deck), 6*52-4)
|
||||||
|
}
|
||||||
|
if len(evs) == 0 || evs[0].Kind != "deal" {
|
||||||
|
t.Fatal("no deal event")
|
||||||
|
}
|
||||||
|
// Unless somebody was dealt a natural, it's the player's move.
|
||||||
|
if !IsBlackjack(s.Player) && !IsBlackjack(s.Dealer) && s.Phase != PhasePlayer {
|
||||||
|
t.Fatalf("phase = %q, want %q", s.Phase, PhasePlayer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_RejectsNonPositiveBet(t *testing.T) {
|
||||||
|
for _, bet := range []int64{0, -100} {
|
||||||
|
if _, _, err := New(bet, DefaultRules(), cards.NewRNG(1, 2)); err == nil {
|
||||||
|
t.Fatalf("bet %d was accepted", bet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_NaturalSettlesImmediately(t *testing.T) {
|
||||||
|
// Search seeds for a deal that gives the player a natural, then assert the
|
||||||
|
// hand is already over — a player holding blackjack is never asked to hit.
|
||||||
|
for seed := uint64(1); seed < 200; seed++ {
|
||||||
|
s, _, err := New(100, DefaultRules(), cards.NewRNG(seed, seed))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if IsBlackjack(s.Player) {
|
||||||
|
if s.Phase != PhaseDone {
|
||||||
|
t.Fatalf("seed %d: player has a natural but phase = %q", seed, s.Phase)
|
||||||
|
}
|
||||||
|
if _, _, err := ApplyMove(s, Hit); err != ErrHandOver {
|
||||||
|
t.Fatalf("seed %d: hitting a settled natural gave %v, want ErrHandOver", seed, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Skip("no natural dealt in 200 seeds")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyMove_HitUntilBustSettles(t *testing.T) {
|
||||||
|
s, _, err := New(100, DefaultRules(), cards.NewRNG(7, 7))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Phase == PhaseDone {
|
||||||
|
t.Skip("dealt a natural; not the hand under test")
|
||||||
|
}
|
||||||
|
for i := 0; i < 12 && s.Phase == PhasePlayer; i++ {
|
||||||
|
s, _, err = ApplyMove(s, Hit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Phase != PhaseDone {
|
||||||
|
t.Fatal("hitting a dozen times never ended the hand")
|
||||||
|
}
|
||||||
|
if v, _ := HandValue(s.Player); v <= 21 {
|
||||||
|
t.Fatalf("player stopped at %d without busting — the loop should have gone over", v)
|
||||||
|
}
|
||||||
|
if s.Outcome != OutcomeBust || s.Payout != 0 {
|
||||||
|
t.Fatalf("outcome=%q payout=%d, want bust/0", s.Outcome, s.Payout)
|
||||||
|
}
|
||||||
|
// A busted player must not have made the dealer draw.
|
||||||
|
if len(s.Dealer) != 2 {
|
||||||
|
t.Fatalf("dealer drew %d cards against a busted player", len(s.Dealer)-2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyMove_StandRunsTheDealerOut(t *testing.T) {
|
||||||
|
s, _, err := New(100, DefaultRules(), cards.NewRNG(3, 9))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Phase == PhaseDone {
|
||||||
|
t.Skip("dealt a natural")
|
||||||
|
}
|
||||||
|
s, evs, err := ApplyMove(s, Stand)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Phase != PhaseDone {
|
||||||
|
t.Fatalf("phase = %q after stand, want done", s.Phase)
|
||||||
|
}
|
||||||
|
v, soft := HandValue(s.Dealer)
|
||||||
|
if v < 17 {
|
||||||
|
t.Fatalf("dealer stood on %d, must draw below 17", v)
|
||||||
|
}
|
||||||
|
if v == 17 && soft {
|
||||||
|
t.Fatal("dealer stood on soft 17; the house rule says hit")
|
||||||
|
}
|
||||||
|
var reveal bool
|
||||||
|
for _, e := range evs {
|
||||||
|
if e.Kind == "reveal" {
|
||||||
|
reveal = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reveal {
|
||||||
|
t.Fatal("dealer played without a reveal event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyMove_DoubleTakesOneCardThenStands(t *testing.T) {
|
||||||
|
s, _, err := New(100, DefaultRules(), cards.NewRNG(11, 4))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Phase == PhaseDone {
|
||||||
|
t.Skip("dealt a natural")
|
||||||
|
}
|
||||||
|
if !s.CanDouble() {
|
||||||
|
t.Fatal("double should be legal on the opening two cards")
|
||||||
|
}
|
||||||
|
s, _, err = ApplyMove(s, Double)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !s.Doubled || s.Bet != 200 {
|
||||||
|
t.Fatalf("bet = %d doubled = %v, want 200/true", s.Bet, s.Doubled)
|
||||||
|
}
|
||||||
|
if len(s.Player) != 3 {
|
||||||
|
t.Fatalf("player has %d cards after a double, want exactly 3", len(s.Player))
|
||||||
|
}
|
||||||
|
if s.Phase != PhaseDone {
|
||||||
|
t.Fatal("a double must end the player's turn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyMove_DoubleIsIllegalAfterHitting(t *testing.T) {
|
||||||
|
s, _, err := New(100, DefaultRules(), cards.NewRNG(5, 5))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Phase == PhaseDone {
|
||||||
|
t.Skip("dealt a natural")
|
||||||
|
}
|
||||||
|
s, _, err = ApplyMove(s, Hit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Phase != PhasePlayer {
|
||||||
|
t.Skip("busted on the hit; not the hand under test")
|
||||||
|
}
|
||||||
|
before := s.Bet
|
||||||
|
after, _, err := ApplyMove(s, Double)
|
||||||
|
if err != ErrCantDouble {
|
||||||
|
t.Fatalf("double after a hit gave %v, want ErrCantDouble", err)
|
||||||
|
}
|
||||||
|
if after.Bet != before {
|
||||||
|
t.Fatalf("a rejected double still moved the bet: %d -> %d", before, after.Bet)
|
||||||
|
}
|
||||||
|
if s.CanDouble() {
|
||||||
|
t.Fatal("CanDouble says yes on a three-card hand")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyMove_RejectsGarbage(t *testing.T) {
|
||||||
|
s, _, err := New(100, DefaultRules(), cards.NewRNG(2, 8))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := ApplyMove(s, Move("surrender")); err != ErrUnknownMove {
|
||||||
|
t.Fatalf("got %v, want ErrUnknownMove", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The engine's state has to survive a redeploy: no timers, no pointers, no
|
||||||
|
// unexported fields that JSON would quietly drop.
|
||||||
|
func TestState_RoundTripsThroughJSON(t *testing.T) {
|
||||||
|
s, _, err := New(100, DefaultRules(), cards.NewRNG(13, 21))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Phase == PhaseDone {
|
||||||
|
t.Skip("dealt a natural")
|
||||||
|
}
|
||||||
|
blob, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var back State
|
||||||
|
if err := json.Unmarshal(blob, &back); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play both forward identically; a state that survives the trip settles the same.
|
||||||
|
live, _, err := ApplyMove(s, Stand)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
revived, _, err := ApplyMove(back, Stand)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if live.Outcome != revived.Outcome || live.Payout != revived.Payout {
|
||||||
|
t.Fatalf("revived hand settled differently: %q/%d vs %q/%d",
|
||||||
|
revived.Outcome, revived.Payout, live.Outcome, live.Payout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same seed, same shoe — this is what lets a disputed hand be re-dealt.
|
||||||
|
func TestNew_IsReproducibleFromItsSeed(t *testing.T) {
|
||||||
|
a, _, err := New(100, DefaultRules(), cards.NewRNG(42, 42))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, _, err := New(100, DefaultRules(), cards.NewRNG(42, 42))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cards.Hand(a.Player) != cards.Hand(b.Player) || cards.Hand(a.Dealer) != cards.Hand(b.Dealer) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A State handed to ApplyMove twice must produce two independent hands. If the
|
||||||
|
// engine let derived states share a backing array, the second deal would scribble
|
||||||
|
// over the first one's cards — and a player could watch a card change under them.
|
||||||
|
func TestApplyMove_DerivedStatesDoNotShareCards(t *testing.T) {
|
||||||
|
s, _, err := New(100, DefaultRules(), cards.NewRNG(23, 5))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Phase == PhaseDone {
|
||||||
|
t.Skip("dealt a natural")
|
||||||
|
}
|
||||||
|
before := cards.Hand(s.Player)
|
||||||
|
|
||||||
|
a, _, err := ApplyMove(s, Hit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
aHand := cards.Hand(a.Player)
|
||||||
|
|
||||||
|
if _, _, err := ApplyMove(s, Hit); err != nil { // same start, applied again
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := cards.Hand(a.Player); got != aHand {
|
||||||
|
t.Fatalf("the first hand changed under us: %q became %q", aHand, got)
|
||||||
|
}
|
||||||
|
if got := cards.Hand(s.Player); got != before {
|
||||||
|
t.Fatalf("ApplyMove mutated the state it was given: %q became %q", before, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
126
internal/games/cards/cards.go
Normal file
126
internal/games/cards/cards.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// Package cards holds the deck primitives every card game on Pete shares.
|
||||||
|
//
|
||||||
|
// gogobee never had this: blackjack carried its own deck, UNO carried another,
|
||||||
|
// and hold'em leaned on a third-party one. Three shuffles, three bugs to fix
|
||||||
|
// three times. The games ported over here consolidate onto this instead.
|
||||||
|
//
|
||||||
|
// Two rules hold throughout:
|
||||||
|
//
|
||||||
|
// The RNG is threaded, never global. Every shuffle takes an explicit *rand.Rand,
|
||||||
|
// so a hand is reproducible from its seed — which is what makes the engines
|
||||||
|
// testable, and what lets us re-deal a disputed hand and show the player exactly
|
||||||
|
// what the shoe did.
|
||||||
|
//
|
||||||
|
// A Deck is a plain value. No pointers into it, no timers hanging off it, so a
|
||||||
|
// game in progress serializes to JSON and survives a redeploy.
|
||||||
|
package cards
|
||||||
|
|
||||||
|
import "math/rand/v2"
|
||||||
|
|
||||||
|
// Suit is one of the four French suits.
|
||||||
|
type Suit uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
Spades Suit = iota
|
||||||
|
Hearts
|
||||||
|
Diamonds
|
||||||
|
Clubs
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rank runs Ace(1) through King(13). Ace is low here; games that want it high
|
||||||
|
// (blackjack's soft 11, hold'em's wheel) say so themselves.
|
||||||
|
type Rank uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
Ace Rank = 1
|
||||||
|
Jack Rank = 11
|
||||||
|
Queen Rank = 12
|
||||||
|
King Rank = 13
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
suitGlyphs = [4]string{"♠", "♥", "♦", "♣"}
|
||||||
|
rankNames = [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Card is one playing card. The short JSON keys keep a serialized shoe small —
|
||||||
|
// a six-deck blackjack state is 312 of these.
|
||||||
|
type Card struct {
|
||||||
|
Rank Rank `json:"r"`
|
||||||
|
Suit Suit `json:"s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders the card the way a table shows it: "A♠", "10♥".
|
||||||
|
func (c Card) String() string {
|
||||||
|
if c.Rank < Ace || c.Rank > King || c.Suit > Clubs {
|
||||||
|
return "??"
|
||||||
|
}
|
||||||
|
return rankNames[c.Rank] + suitGlyphs[c.Suit]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Red reports whether the card is a red suit — the one thing every renderer
|
||||||
|
// needs and nobody should re-derive.
|
||||||
|
func (c Card) Red() bool { return c.Suit == Hearts || c.Suit == Diamonds }
|
||||||
|
|
||||||
|
// Deck is an ordered pile of cards. The next card to come off is at index 0.
|
||||||
|
type Deck []Card
|
||||||
|
|
||||||
|
// NewDeck builds n standard 52-card decks in fixed order. Shuffle before use:
|
||||||
|
// an unshuffled deck is a bug at a table, but it's exactly what a test wants.
|
||||||
|
func NewDeck(n int) Deck {
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
d := make(Deck, 0, 52*n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
for s := Spades; s <= Clubs; s++ {
|
||||||
|
for r := Ace; r <= King; r++ {
|
||||||
|
d = append(d, Card{Rank: r, Suit: s})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle permutes the deck in place using the supplied RNG. Passing a seeded
|
||||||
|
// *rand.Rand gives the same shuffle every time, which is the whole point.
|
||||||
|
func (d Deck) Shuffle(rng *rand.Rand) {
|
||||||
|
rng.Shuffle(len(d), func(i, j int) { d[i], d[j] = d[j], d[i] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw takes the top card. ok is false when the deck is spent; the caller
|
||||||
|
// decides whether that means reshuffle or fold, because the two games that hit
|
||||||
|
// it disagree.
|
||||||
|
func (d *Deck) Draw() (c Card, ok bool) {
|
||||||
|
if len(*d) == 0 {
|
||||||
|
return Card{}, false
|
||||||
|
}
|
||||||
|
c = (*d)[0]
|
||||||
|
*d = (*d)[1:]
|
||||||
|
return c, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand renders a run of cards for display: "A♠ 10♥".
|
||||||
|
func Hand(cs []Card) string {
|
||||||
|
s := ""
|
||||||
|
for i, c := range cs {
|
||||||
|
if i > 0 {
|
||||||
|
s += " "
|
||||||
|
}
|
||||||
|
s += c.String()
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRNG seeds a generator from two uint64s. Games store the seed alongside the
|
||||||
|
// hand so a finished hand can be replayed exactly as it was dealt.
|
||||||
|
func NewRNG(seed1, seed2 uint64) *rand.Rand {
|
||||||
|
return rand.New(rand.NewPCG(seed1, seed2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Suit) String() string {
|
||||||
|
if s > Clubs {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
return suitGlyphs[s]
|
||||||
|
}
|
||||||
102
internal/games/cards/cards_test.go
Normal file
102
internal/games/cards/cards_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package cards
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNewDeck_IsAFullShoe(t *testing.T) {
|
||||||
|
d := NewDeck(6)
|
||||||
|
if len(d) != 312 {
|
||||||
|
t.Fatalf("six decks hold %d cards, want 312", len(d))
|
||||||
|
}
|
||||||
|
seen := map[Card]int{}
|
||||||
|
for _, c := range d {
|
||||||
|
seen[c]++
|
||||||
|
}
|
||||||
|
if len(seen) != 52 {
|
||||||
|
t.Fatalf("%d distinct cards, want 52", len(seen))
|
||||||
|
}
|
||||||
|
for c, n := range seen {
|
||||||
|
if n != 6 {
|
||||||
|
t.Fatalf("%s appears %d times in a six-deck shoe, want 6", c, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewDeck_ClampsToAtLeastOne(t *testing.T) {
|
||||||
|
if len(NewDeck(0)) != 52 {
|
||||||
|
t.Fatal("a zero-deck shoe should still hold one deck")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShuffle_SameSeedSameOrder(t *testing.T) {
|
||||||
|
a, b := NewDeck(1), NewDeck(1)
|
||||||
|
a.Shuffle(NewRNG(99, 1))
|
||||||
|
b.Shuffle(NewRNG(99, 1))
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
t.Fatalf("same seed diverged at %d: %s vs %s", i, a[i], b[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And a different seed must not give the same order, or the RNG isn't wired up.
|
||||||
|
c := NewDeck(1)
|
||||||
|
c.Shuffle(NewRNG(100, 1))
|
||||||
|
same := true
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != c[i] {
|
||||||
|
same = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if same {
|
||||||
|
t.Fatal("a different seed produced an identical shuffle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShuffle_KeepsEveryCard(t *testing.T) {
|
||||||
|
d := NewDeck(1)
|
||||||
|
d.Shuffle(NewRNG(4, 4))
|
||||||
|
seen := map[Card]bool{}
|
||||||
|
for _, c := range d {
|
||||||
|
seen[c] = true
|
||||||
|
}
|
||||||
|
if len(d) != 52 || len(seen) != 52 {
|
||||||
|
t.Fatalf("shuffle lost cards: %d cards, %d distinct", len(d), len(seen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraw_TakesFromTheTopAndRunsOut(t *testing.T) {
|
||||||
|
d := NewDeck(1)
|
||||||
|
top := d[0]
|
||||||
|
c, ok := d.Draw()
|
||||||
|
if !ok || c != top {
|
||||||
|
t.Fatalf("drew %s (ok=%v), want the top card %s", c, ok, top)
|
||||||
|
}
|
||||||
|
if len(d) != 51 {
|
||||||
|
t.Fatalf("deck has %d cards after one draw, want 51", len(d))
|
||||||
|
}
|
||||||
|
for len(d) > 0 {
|
||||||
|
d.Draw()
|
||||||
|
}
|
||||||
|
if _, ok := d.Draw(); ok {
|
||||||
|
t.Fatal("an empty deck kept dealing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCard_String(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
card Card
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{Card{Ace, Spades}, "A♠"},
|
||||||
|
{Card{10, Hearts}, "10♥"},
|
||||||
|
{Card{King, Clubs}, "K♣"},
|
||||||
|
{Card{Rank: 99, Suit: Spades}, "??"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := tc.card.String(); got != tc.want {
|
||||||
|
t.Errorf("String() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !(Card{Ace, Hearts}).Red() || (Card{Ace, Spades}).Red() {
|
||||||
|
t.Error("Red() disagrees about which suits are red")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,9 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
|
|||||||
if s.Lede != "" {
|
if s.Lede != "" {
|
||||||
plainParts = append(plainParts, s.Lede)
|
plainParts = append(plainParts, s.Lede)
|
||||||
}
|
}
|
||||||
plainParts = append(plainParts, formatSourceTag(s.Source, s.Platforms, false))
|
if tag := formatSourceTag(s.Source, s.Platforms, false); tag != "" {
|
||||||
|
plainParts = append(plainParts, tag)
|
||||||
|
}
|
||||||
plain = strings.Join(plainParts, "\n")
|
plain = strings.Join(plainParts, "\n")
|
||||||
|
|
||||||
// HTML body
|
// HTML body
|
||||||
@@ -55,25 +57,32 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
|
|||||||
if s.Lede != "" {
|
if s.Lede != "" {
|
||||||
htmlParts = append(htmlParts, html.EscapeString(s.Lede))
|
htmlParts = append(htmlParts, html.EscapeString(s.Lede))
|
||||||
}
|
}
|
||||||
htmlParts = append(htmlParts, formatSourceTag(s.Source, s.Platforms, true))
|
if tag := formatSourceTag(s.Source, s.Platforms, true); tag != "" {
|
||||||
|
htmlParts = append(htmlParts, tag)
|
||||||
|
}
|
||||||
htmlBody = strings.Join(htmlParts, "<br/>")
|
htmlBody = strings.Join(htmlParts, "<br/>")
|
||||||
|
|
||||||
return plain, htmlBody
|
return plain, htmlBody
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatSourceTag builds the source + platform tags line.
|
// formatSourceTag builds the source + platform tags line. An empty source is
|
||||||
|
// omitted rather than tagged: Pete's own reporting has no outlet to credit, and
|
||||||
|
// an empty tag would read as him signing his own name.
|
||||||
func formatSourceTag(source string, platforms []string, isHTML bool) string {
|
func formatSourceTag(source string, platforms []string, isHTML bool) string {
|
||||||
if isHTML {
|
var parts []string
|
||||||
parts := []string{fmt.Sprintf("<code>%s</code>", html.EscapeString(strings.ToLower(source)))}
|
if source != "" {
|
||||||
for _, p := range platforms {
|
parts = append(parts, strings.ToLower(source))
|
||||||
parts = append(parts, fmt.Sprintf("<code>%s</code>", html.EscapeString(p)))
|
|
||||||
}
|
|
||||||
return strings.Join(parts, " \u00b7 ")
|
|
||||||
}
|
}
|
||||||
|
parts = append(parts, platforms...)
|
||||||
parts := []string{fmt.Sprintf("`%s`", strings.ToLower(source))}
|
if len(parts) == 0 {
|
||||||
for _, p := range platforms {
|
return ""
|
||||||
parts = append(parts, fmt.Sprintf("`%s`", p))
|
}
|
||||||
|
for i, p := range parts {
|
||||||
|
if isHTML {
|
||||||
|
parts[i] = fmt.Sprintf("<code>%s</code>", html.EscapeString(p))
|
||||||
|
} else {
|
||||||
|
parts[i] = fmt.Sprintf("`%s`", p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return strings.Join(parts, " \u00b7 ")
|
return strings.Join(parts, " \u00b7 ")
|
||||||
}
|
}
|
||||||
|
|||||||
601
internal/storage/games.go
Normal file
601
internal/storage/games.go
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The chip ledger and the euro/chip border.
|
||||||
|
//
|
||||||
|
// Chips are euros that have crossed into the casino. They are 1:1 with euros and
|
||||||
|
// they are not a second wallet: every chip that exists came from a euro gogobee
|
||||||
|
// debited, and every chip destroyed becomes a euro gogobee credits back. Pete
|
||||||
|
// never writes a euro balance. The border is crossed only by a game_escrow row,
|
||||||
|
// whose guid is the idempotency key gogobee hands to DebitIdem/CreditIdem — so a
|
||||||
|
// claim whose acknowledgement is lost on the wire can be retried without the
|
||||||
|
// player paying for it twice.
|
||||||
|
//
|
||||||
|
// The whole reason for the border is latency. gogobee has no inbound API and is
|
||||||
|
// not getting one, so it polls; a bet that round-tripped through a poll loop
|
||||||
|
// would take seconds to be dealt. Instead the poll loop runs twice per *session*
|
||||||
|
// — buy in, cash out — and every hand in between plays against chips held here,
|
||||||
|
// at full speed, with no economy call in the hot path.
|
||||||
|
|
||||||
|
// MaxChipsOnTable caps how many chips a player can hold at once. A buy-in that
|
||||||
|
// would push them over is refused before it ever reaches gogobee.
|
||||||
|
//
|
||||||
|
// This is the inflation brake. A web casino runs orders of magnitude more hands
|
||||||
|
// per hour than a Matrix-paced one ever did, so whatever the house edge is, it
|
||||||
|
// compounds far faster in both directions. The cap bounds the worst case for a
|
||||||
|
// single sitting; the rake (see the blackjack engine) bleeds the rest back out.
|
||||||
|
const MaxChipsOnTable int64 = 10_000
|
||||||
|
|
||||||
|
// EscrowStaleAfter is how long a claimed-but-unsettled escrow row waits before
|
||||||
|
// the poll endpoint offers it again. gogobee can die between claiming a row and
|
||||||
|
// pushing its result; without a re-offer, the player's money sits in limbo
|
||||||
|
// forever. Re-claiming is safe precisely because the guid makes it idempotent.
|
||||||
|
const EscrowStaleAfter = 90 * time.Second
|
||||||
|
|
||||||
|
// SessionIdleAfter is when the reaper decides a player has walked away and cashes
|
||||||
|
// their chips back to euros on their behalf. Chips in an abandoned session are
|
||||||
|
// euros in limbo, and limbo is not a state a player's money should be in.
|
||||||
|
const SessionIdleAfter = 30 * time.Minute
|
||||||
|
|
||||||
|
// Escrow kinds and states. These strings cross the wire to gogobee, so they are
|
||||||
|
// part of the contract — see §4 of pete_games_plan.md.
|
||||||
|
const (
|
||||||
|
KindBuyIn = "buyin"
|
||||||
|
KindCashOut = "cashout"
|
||||||
|
|
||||||
|
EscrowRequested = "requested" // the player asked; gogobee hasn't seen it yet
|
||||||
|
EscrowClaimed = "claimed" // gogobee has it and is moving the euros
|
||||||
|
EscrowFunded = "funded" // buy-in landed; the chips are spendable
|
||||||
|
EscrowRejected = "rejected" // buy-in refused; no chips, no euros moved
|
||||||
|
EscrowSettled = "settled" // cash-out landed; chips destroyed, euros credited
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInsufficientChips = errors.New("games: not enough chips")
|
||||||
|
ErrOverTableCap = errors.New("games: that would put more than the cap on the table")
|
||||||
|
ErrBadAmount = errors.New("games: amount must be positive")
|
||||||
|
ErrNoSuchEscrow = errors.New("games: no such escrow row")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Escrow is one crossing of the euro/chip border.
|
||||||
|
type Escrow struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
MatrixUser string `json:"matrix_user"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
BalanceAfter float64 `json:"balance_after,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChipStack is what a player has on the table right now.
|
||||||
|
type ChipStack struct {
|
||||||
|
Chips int64 // spendable
|
||||||
|
// Pending is chips asked for but not yet funded — a buy-in gogobee hasn't
|
||||||
|
// claimed or settled. Shown as "buying chips…", never spendable.
|
||||||
|
Pending int64
|
||||||
|
EuroBalance float64 // advisory, from the last gogobee push; may be minutes stale
|
||||||
|
LastPlayed int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// newGUID mints an escrow id. It's the idempotency key for a real money move, so
|
||||||
|
// it comes from crypto/rand rather than anything a caller could collide with.
|
||||||
|
func newGUID() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", fmt.Errorf("games: mint guid: %w", err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chips reports a player's stack. A player who has never played has no row and
|
||||||
|
// reads as an empty stack rather than an error.
|
||||||
|
func Chips(user string) (ChipStack, error) {
|
||||||
|
var st ChipStack
|
||||||
|
var euro sql.NullFloat64
|
||||||
|
err := Get().QueryRow(
|
||||||
|
`SELECT chips, euro_balance, last_played FROM game_chips WHERE matrix_user = ?`, user,
|
||||||
|
).Scan(&st.Chips, &euro, &st.LastPlayed)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ChipStack{}, fmt.Errorf("games: read chips: %w", err)
|
||||||
|
}
|
||||||
|
st.EuroBalance = euro.Float64
|
||||||
|
|
||||||
|
if err := Get().QueryRow(
|
||||||
|
`SELECT COALESCE(SUM(amount), 0) FROM game_escrow
|
||||||
|
WHERE matrix_user = ? AND kind = ? AND state IN (?, ?)`,
|
||||||
|
user, KindBuyIn, EscrowRequested, EscrowClaimed,
|
||||||
|
).Scan(&st.Pending); err != nil {
|
||||||
|
return ChipStack{}, fmt.Errorf("games: read pending buy-ins: %w", err)
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestBuyIn opens a buy-in: the player wants `amount` euros turned into chips.
|
||||||
|
// No chips exist yet — they appear only when gogobee confirms it took the euros.
|
||||||
|
// The table cap is enforced here, against chips already held *plus* buy-ins still
|
||||||
|
// in flight, so a player can't clear the cap by firing several at once.
|
||||||
|
func RequestBuyIn(user string, amount int64) (Escrow, error) {
|
||||||
|
if amount <= 0 {
|
||||||
|
return Escrow{}, ErrBadAmount
|
||||||
|
}
|
||||||
|
st, err := Chips(user)
|
||||||
|
if err != nil {
|
||||||
|
return Escrow{}, err
|
||||||
|
}
|
||||||
|
if st.Chips+st.Pending+amount > MaxChipsOnTable {
|
||||||
|
return Escrow{}, ErrOverTableCap
|
||||||
|
}
|
||||||
|
|
||||||
|
guid, err := newGUID()
|
||||||
|
if err != nil {
|
||||||
|
return Escrow{}, err
|
||||||
|
}
|
||||||
|
now := nowUnix()
|
||||||
|
if _, err := Get().Exec(
|
||||||
|
`INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
guid, user, KindBuyIn, amount, EscrowRequested, now, now,
|
||||||
|
); err != nil {
|
||||||
|
return Escrow{}, fmt.Errorf("games: request buy-in: %w", err)
|
||||||
|
}
|
||||||
|
return Escrow{GUID: guid, MatrixUser: user, Kind: KindBuyIn, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestCashOut opens a cash-out: chips are destroyed *now*, and the matching
|
||||||
|
// euros arrive when gogobee claims the row.
|
||||||
|
//
|
||||||
|
// Destroying them up front is what keeps the invariant true. If the chips lingered
|
||||||
|
// until gogobee confirmed, a player could bet them while the cash-out was in
|
||||||
|
// flight and the same euro would exist on both sides of the border. If the credit
|
||||||
|
// somehow fails, RefundCashOut puts the chips back.
|
||||||
|
func RequestCashOut(user string, amount int64) (Escrow, error) {
|
||||||
|
if amount <= 0 {
|
||||||
|
return Escrow{}, ErrBadAmount
|
||||||
|
}
|
||||||
|
guid, err := newGUID()
|
||||||
|
if err != nil {
|
||||||
|
return Escrow{}, err
|
||||||
|
}
|
||||||
|
now := nowUnix()
|
||||||
|
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return Escrow{}, fmt.Errorf("games: begin cash-out: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||||
|
|
||||||
|
// Conditional update: the chips leave only if they're actually there.
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`UPDATE game_chips SET chips = chips - ?, updated_at = ?
|
||||||
|
WHERE matrix_user = ? AND chips >= ?`,
|
||||||
|
amount, now, user, amount,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Escrow{}, fmt.Errorf("games: debit chips: %w", err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return Escrow{}, ErrInsufficientChips
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
guid, user, KindCashOut, amount, EscrowRequested, now, now,
|
||||||
|
); err != nil {
|
||||||
|
return Escrow{}, fmt.Errorf("games: request cash-out: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return Escrow{}, fmt.Errorf("games: commit cash-out: %w", err)
|
||||||
|
}
|
||||||
|
return Escrow{GUID: guid, MatrixUser: user, Kind: KindCashOut, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingEscrow is what gogobee's poll loop reads: everything waiting to be moved.
|
||||||
|
//
|
||||||
|
// It returns rows nobody has claimed, *and* rows claimed long enough ago that we
|
||||||
|
// have to assume gogobee died holding them. Re-offering a claimed row is safe
|
||||||
|
// because the guid is idempotent end to end: if gogobee already moved the euros,
|
||||||
|
// the retry is a no-op that reports the same answer.
|
||||||
|
func PendingEscrow(limit int) ([]Escrow, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
stale := nowUnix() - int64(EscrowStaleAfter.Seconds())
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`SELECT guid, matrix_user, kind, amount, state, created_at
|
||||||
|
FROM game_escrow
|
||||||
|
WHERE state = ?
|
||||||
|
OR (state = ? AND COALESCE(claimed_at, 0) < ?)
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT ?`,
|
||||||
|
EscrowRequested, EscrowClaimed, stale, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("games: pending escrow: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Escrow
|
||||||
|
for rows.Next() {
|
||||||
|
var e Escrow
|
||||||
|
if err := rows.Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &e.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("games: scan escrow: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimEscrow marks a row as taken by gogobee. Claiming is idempotent and is not
|
||||||
|
// a lock: a row already claimed can be claimed again (that's how a stale re-offer
|
||||||
|
// works), but a row already *finished* cannot be, which is what stops a settled
|
||||||
|
// cash-out from being paid a second time.
|
||||||
|
func ClaimEscrow(guid string) (Escrow, error) {
|
||||||
|
now := nowUnix()
|
||||||
|
res, err := Get().Exec(
|
||||||
|
`UPDATE game_escrow SET state = ?, claimed_at = ?, updated_at = ?
|
||||||
|
WHERE guid = ? AND state IN (?, ?)`,
|
||||||
|
EscrowClaimed, now, now, guid, EscrowRequested, EscrowClaimed,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Escrow{}, fmt.Errorf("games: claim escrow: %w", err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
// Either it doesn't exist or it's already finished. Tell the caller which.
|
||||||
|
e, err := EscrowByGUID(guid)
|
||||||
|
if err != nil {
|
||||||
|
return Escrow{}, err
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
return EscrowByGUID(guid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EscrowByGUID reads one row.
|
||||||
|
func EscrowByGUID(guid string) (Escrow, error) {
|
||||||
|
var e Escrow
|
||||||
|
var reason sql.NullString
|
||||||
|
var bal sql.NullFloat64
|
||||||
|
err := Get().QueryRow(
|
||||||
|
`SELECT guid, matrix_user, kind, amount, state, reason, balance_after, created_at
|
||||||
|
FROM game_escrow WHERE guid = ?`, guid,
|
||||||
|
).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &reason, &bal, &e.CreatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Escrow{}, ErrNoSuchEscrow
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Escrow{}, fmt.Errorf("games: read escrow: %w", err)
|
||||||
|
}
|
||||||
|
e.Reason, e.BalanceAfter = reason.String, bal.Float64
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettleEscrow applies gogobee's verdict on a claimed row, and is the only place
|
||||||
|
// chips are created or finally destroyed.
|
||||||
|
//
|
||||||
|
// buy-in, ok -> chips appear (funded)
|
||||||
|
// buy-in, !ok -> nothing happens, nothing moved (rejected)
|
||||||
|
// cash-out, ok -> chips stay destroyed, euros paid (settled)
|
||||||
|
// cash-out, !ok -> chips come back (funded — the player never lost them)
|
||||||
|
//
|
||||||
|
// It is idempotent: gogobee's push queue retries, so the same verdict can arrive
|
||||||
|
// more than once and only the first one may move chips. A row that has already
|
||||||
|
// reached a terminal state is a no-op, not an error.
|
||||||
|
func SettleEscrow(guid string, ok bool, reason string, balanceAfter float64) (Escrow, error) {
|
||||||
|
now := nowUnix()
|
||||||
|
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return Escrow{}, fmt.Errorf("games: begin settle: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||||
|
|
||||||
|
var e Escrow
|
||||||
|
var st string
|
||||||
|
if err := tx.QueryRow(
|
||||||
|
`SELECT guid, matrix_user, kind, amount, state FROM game_escrow WHERE guid = ?`, guid,
|
||||||
|
).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &st); errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Escrow{}, ErrNoSuchEscrow
|
||||||
|
} else if err != nil {
|
||||||
|
return Escrow{}, fmt.Errorf("games: settle lookup: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal already — a retried push. Report what we decided the first time.
|
||||||
|
if st == EscrowFunded || st == EscrowRejected || st == EscrowSettled {
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return Escrow{}, fmt.Errorf("games: commit settle: %w", err)
|
||||||
|
}
|
||||||
|
return EscrowByGUID(guid)
|
||||||
|
}
|
||||||
|
|
||||||
|
final := EscrowFunded
|
||||||
|
switch {
|
||||||
|
case e.Kind == KindBuyIn && ok:
|
||||||
|
if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil {
|
||||||
|
return Escrow{}, err
|
||||||
|
}
|
||||||
|
case e.Kind == KindBuyIn && !ok:
|
||||||
|
final = EscrowRejected // gogobee took nothing, so we create nothing
|
||||||
|
case e.Kind == KindCashOut && ok:
|
||||||
|
final = EscrowSettled // the chips were destroyed when the row was opened
|
||||||
|
case e.Kind == KindCashOut && !ok:
|
||||||
|
// gogobee couldn't pay. The chips were already destroyed on our side, so
|
||||||
|
// give them back rather than vanishing the player's money.
|
||||||
|
if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil {
|
||||||
|
return Escrow{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`UPDATE game_escrow SET state = ?, reason = ?, balance_after = ?, updated_at = ?
|
||||||
|
WHERE guid = ?`,
|
||||||
|
final, reason, balanceAfter, now, guid,
|
||||||
|
); err != nil {
|
||||||
|
return Escrow{}, fmt.Errorf("games: settle update: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The euro balance gogobee just reported is the freshest one we'll get.
|
||||||
|
// Advisory only — we display it, we never decide anything with it.
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`UPDATE game_chips SET euro_balance = ?, updated_at = ? WHERE matrix_user = ?`,
|
||||||
|
balanceAfter, now, e.MatrixUser,
|
||||||
|
); err != nil {
|
||||||
|
return Escrow{}, fmt.Errorf("games: cache euro balance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return Escrow{}, fmt.Errorf("games: commit settle: %w", err)
|
||||||
|
}
|
||||||
|
return EscrowByGUID(guid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addChips credits a stack inside an open transaction, creating the row if the
|
||||||
|
// player has never held chips before.
|
||||||
|
func addChips(tx *sql.Tx, user string, amount int64, now int64) error {
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(matrix_user) DO UPDATE SET chips = chips + excluded.chips, updated_at = excluded.updated_at`,
|
||||||
|
user, amount, now, now,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("games: credit chips: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stake takes chips off a player's stack to put them at risk on a hand. It is the
|
||||||
|
// conditional-update kind of debit: the chips leave in the same statement that
|
||||||
|
// checks they're there, so two hands opened at once can't spend the same chip.
|
||||||
|
func Stake(user string, amount int64) error {
|
||||||
|
if amount <= 0 {
|
||||||
|
return ErrBadAmount
|
||||||
|
}
|
||||||
|
now := nowUnix()
|
||||||
|
res, err := Get().Exec(
|
||||||
|
`UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ?
|
||||||
|
WHERE matrix_user = ? AND chips >= ?`,
|
||||||
|
amount, now, now, user, amount,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: stake: %w", err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrInsufficientChips
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// should not call this.
|
||||||
|
func Award(user string, amount int64) error {
|
||||||
|
if amount <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := nowUnix()
|
||||||
|
if _, err := Get().Exec(
|
||||||
|
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(matrix_user) DO UPDATE SET
|
||||||
|
chips = chips + excluded.chips, last_played = excluded.last_played, updated_at = excluded.updated_at`,
|
||||||
|
user, amount, now, now,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("games: award chips: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand is one settled hand, as the audit log keeps it.
|
||||||
|
type Hand struct {
|
||||||
|
MatrixUser string
|
||||||
|
Game string
|
||||||
|
Bet int64
|
||||||
|
Payout int64
|
||||||
|
Rake int64
|
||||||
|
Outcome string
|
||||||
|
Seed1 uint64
|
||||||
|
Seed2 uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordHand writes a finished hand to the audit trail. The seeds are the point:
|
||||||
|
// 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.
|
||||||
|
func RecordHand(h Hand) error {
|
||||||
|
if _, err := Get().Exec(
|
||||||
|
`INSERT INTO game_hands (matrix_user, game, bet, payout, rake, outcome, seed1, seed2, played_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
h.MatrixUser, h.Game, h.Bet, h.Payout, h.Rake, h.Outcome,
|
||||||
|
int64(h.Seed1), int64(h.Seed2), nowUnix(),
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("games: record hand: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdleStacks lists players holding chips who stopped playing a while ago. The
|
||||||
|
// reaper cashes these out on their behalf: chips in an abandoned session are
|
||||||
|
// euros in limbo, and they should be back in the player's balance where they can
|
||||||
|
// see them.
|
||||||
|
func IdleStacks(idleFor time.Duration) ([]ChipStack, []string, error) {
|
||||||
|
cutoff := nowUnix() - int64(idleFor.Seconds())
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`SELECT matrix_user, chips, last_played FROM game_chips
|
||||||
|
WHERE chips > 0 AND last_played > 0 AND last_played < ?`, cutoff,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("games: idle stacks: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var stacks []ChipStack
|
||||||
|
var users []string
|
||||||
|
for rows.Next() {
|
||||||
|
var st ChipStack
|
||||||
|
var user string
|
||||||
|
if err := rows.Scan(&user, &st.Chips, &st.LastPlayed); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("games: scan idle stack: %w", err)
|
||||||
|
}
|
||||||
|
stacks = append(stacks, st)
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
return stacks, users, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReapIdleSessions cashes out everyone who walked away, and reports how many it
|
||||||
|
// sent home. Safe to run on a timer: a player who comes back simply buys in again,
|
||||||
|
// and a cash-out that's already in flight can't be opened twice because the chips
|
||||||
|
// are gone from the stack the moment the first one is.
|
||||||
|
func ReapIdleSessions(idleFor time.Duration) (int, error) {
|
||||||
|
stacks, users, err := IdleStacks(idleFor)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
reaped := 0
|
||||||
|
for i, user := range users {
|
||||||
|
if _, err := RequestCashOut(user, stacks[i].Chips); err != nil {
|
||||||
|
// One player's stack failing to reap shouldn't strand everyone else's.
|
||||||
|
if !errors.Is(err, ErrInsufficientChips) {
|
||||||
|
return reaped, fmt.Errorf("games: reap %s: %w", user, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reaped++
|
||||||
|
}
|
||||||
|
return reaped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch marks a player as active, so the reaper leaves them alone. Called on any
|
||||||
|
// deliberate action at a table — not on a page load, or an open tab would keep a
|
||||||
|
// walked-away player's chips hostage forever.
|
||||||
|
func Touch(user string) {
|
||||||
|
exec("games: touch session",
|
||||||
|
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
|
||||||
|
nowUnix(), nowUnix(), user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the hand in progress -------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNoLiveHand means the player isn't in a hand right now.
|
||||||
|
ErrNoLiveHand = errors.New("games: no hand in progress")
|
||||||
|
// ErrHandInProgress means they already are, and may not be dealt another.
|
||||||
|
ErrHandInProgress = errors.New("games: already in a hand")
|
||||||
|
)
|
||||||
|
|
||||||
|
// LiveHand is a hand a player is in the middle of. State is the engine's own
|
||||||
|
// State, serialized whole — the shoe is in there, which is exactly why this row
|
||||||
|
// never leaves the server.
|
||||||
|
type LiveHand struct {
|
||||||
|
Game string
|
||||||
|
State []byte
|
||||||
|
Seed1 uint64
|
||||||
|
Seed2 uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartLiveHand seats a *new* hand, and refuses if the player is already in one.
|
||||||
|
// The plain INSERT is the point: it is the primary key, not a prior read, that
|
||||||
|
// decides. Two Deal clicks racing each other would otherwise both see an empty
|
||||||
|
// felt, both take a stake, and the second would overwrite the first — taking the
|
||||||
|
// player's chips for a hand that no longer exists anywhere.
|
||||||
|
func StartLiveHand(user string, h LiveHand) error {
|
||||||
|
res, err := Get().Exec(
|
||||||
|
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(matrix_user) DO NOTHING`,
|
||||||
|
user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), nowUnix(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("games: start live hand: %w", err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrHandInProgress
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveLiveHand stores the hand a player is in, replacing any earlier one. The
|
||||||
|
// player's stake has already left their stack by the time this is called, so
|
||||||
|
// the write is what makes the hand recoverable if Pete restarts mid-deal.
|
||||||
|
func SaveLiveHand(user string, h LiveHand) error {
|
||||||
|
now := nowUnix()
|
||||||
|
if _, err := Get().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, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), now,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("games: save live hand: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadLiveHand returns the hand a player is in, or ErrNoLiveHand.
|
||||||
|
func LoadLiveHand(user string) (LiveHand, error) {
|
||||||
|
var h LiveHand
|
||||||
|
var state string
|
||||||
|
var s1, s2 int64
|
||||||
|
err := Get().QueryRow(
|
||||||
|
`SELECT game, state, seed1, seed2 FROM game_live_hands WHERE matrix_user = ?`, user,
|
||||||
|
).Scan(&h.Game, &state, &s1, &s2)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return LiveHand{}, ErrNoLiveHand
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return LiveHand{}, fmt.Errorf("games: load live hand: %w", err)
|
||||||
|
}
|
||||||
|
h.State, h.Seed1, h.Seed2 = []byte(state), uint64(s1), uint64(s2)
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearLiveHand ends a hand. Called when it settles — the audit log in
|
||||||
|
// game_hands is what survives it.
|
||||||
|
func ClearLiveHand(user string) error {
|
||||||
|
if _, err := Get().Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil {
|
||||||
|
return fmt.Errorf("games: clear live hand: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HouseTake is the total rake collected since a given time — the number that
|
||||||
|
// answers "is this economy inflating".
|
||||||
|
func HouseTake(since int64) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
if err := Get().QueryRow(
|
||||||
|
`SELECT COALESCE(SUM(rake), 0) FROM game_hands WHERE played_at >= ?`, since,
|
||||||
|
).Scan(&total); err != nil {
|
||||||
|
return 0, fmt.Errorf("games: house take: %w", err)
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
494
internal/storage/games_test.go
Normal file
494
internal/storage/games_test.go
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const player = "@reala:parodia.dev"
|
||||||
|
|
||||||
|
// fund runs a buy-in all the way through the happy path, so a test that needs
|
||||||
|
// chips on the table can just say so.
|
||||||
|
func fund(t *testing.T, user string, amount int64) {
|
||||||
|
t.Helper()
|
||||||
|
e, err := RequestBuyIn(user, amount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chipsOf(t *testing.T, user string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
st, err := Chips(user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return st.Chips
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuyIn_ChipsOnlyExistOnceGogobeeConfirms(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
e, err := RequestBuyIn(player, 500)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if e.State != EscrowRequested {
|
||||||
|
t.Fatalf("state = %q, want %q", e.State, EscrowRequested)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requested is not funded. Nothing is spendable yet — gogobee hasn't taken
|
||||||
|
// the euros, so creating chips here would mint money out of nothing.
|
||||||
|
st, err := Chips(player)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Chips != 0 {
|
||||||
|
t.Fatalf("chips = %d before settlement, want 0", st.Chips)
|
||||||
|
}
|
||||||
|
if st.Pending != 500 {
|
||||||
|
t.Fatalf("pending = %d, want 500", st.Pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err = Chips(player)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Chips != 500 {
|
||||||
|
t.Fatalf("chips = %d after funding, want 500", st.Chips)
|
||||||
|
}
|
||||||
|
if st.Pending != 0 {
|
||||||
|
t.Fatalf("pending = %d after funding, want 0", st.Pending)
|
||||||
|
}
|
||||||
|
if st.EuroBalance != 4500 {
|
||||||
|
t.Fatalf("advisory euro balance = %v, want 4500", st.EuroBalance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuyIn_RejectedCreatesNoChips(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
e, err := RequestBuyIn(player, 500)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.State != EscrowRejected {
|
||||||
|
t.Fatalf("state = %q, want %q", got.State, EscrowRejected)
|
||||||
|
}
|
||||||
|
if c := chipsOf(t, player); c != 0 {
|
||||||
|
t.Fatalf("chips = %d after a rejected buy-in, want 0", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The push queue retries. A verdict that lands twice must only move chips once.
|
||||||
|
func TestSettle_IsIdempotent(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
e, err := RequestBuyIn(player, 500)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
|
||||||
|
t.Fatalf("settle %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c := chipsOf(t, player); c != 500 {
|
||||||
|
t.Fatalf("chips = %d after three identical pushes, want 500", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A late, contradictory push must not overturn a settled row — otherwise a
|
||||||
|
// delayed "rejected" could confiscate chips the player already won hands with.
|
||||||
|
func TestSettle_TerminalStateWins(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
e, err := RequestBuyIn(player, 500)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.State != EscrowFunded {
|
||||||
|
t.Fatalf("state = %q, want the original %q", got.State, EscrowFunded)
|
||||||
|
}
|
||||||
|
if c := chipsOf(t, player); c != 500 {
|
||||||
|
t.Fatalf("chips = %d, want 500 — a late rejection took funded chips", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCashOut_ChipsLeaveImmediately(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 1000)
|
||||||
|
|
||||||
|
e, err := RequestCashOut(player, 400)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The chips are gone the moment the row opens. If they lingered until gogobee
|
||||||
|
// confirmed, the player could bet them while the euros were also in flight —
|
||||||
|
// the same euro on both sides of the border.
|
||||||
|
if c := chipsOf(t, player); c != 600 {
|
||||||
|
t.Fatalf("chips = %d immediately after cash-out, want 600", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := SettleEscrow(e.GUID, true, "", 4400)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.State != EscrowSettled {
|
||||||
|
t.Fatalf("state = %q, want %q", got.State, EscrowSettled)
|
||||||
|
}
|
||||||
|
if c := chipsOf(t, player); c != 600 {
|
||||||
|
t.Fatalf("chips = %d after settlement, want 600", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCashOut_FailedCreditGivesTheChipsBack(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 1000)
|
||||||
|
|
||||||
|
e, err := RequestCashOut(player, 400)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// gogobee couldn't pay. The chips were already destroyed here, so they have to
|
||||||
|
// come back — the player's money cannot simply evaporate at the border.
|
||||||
|
if _, err := SettleEscrow(e.GUID, false, "ledger_error", 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c := chipsOf(t, player); c != 1000 {
|
||||||
|
t.Fatalf("chips = %d after a failed cash-out, want the full 1000 back", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCashOut_CannotExceedTheStack(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 100)
|
||||||
|
|
||||||
|
if _, err := RequestCashOut(player, 500); !errors.Is(err, ErrInsufficientChips) {
|
||||||
|
t.Fatalf("err = %v, want ErrInsufficientChips", err)
|
||||||
|
}
|
||||||
|
if c := chipsOf(t, player); c != 100 {
|
||||||
|
t.Fatalf("chips = %d after a refused cash-out, want 100", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuyIn_TableCapCountsChipsAndInFlightBuyIns(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
if _, err := RequestBuyIn(player, MaxChipsOnTable+1); !errors.Is(err, ErrOverTableCap) {
|
||||||
|
t.Fatalf("err = %v, want ErrOverTableCap", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fund(t, player, MaxChipsOnTable-1000)
|
||||||
|
|
||||||
|
// A second buy-in that fits is fine.
|
||||||
|
if _, err := RequestBuyIn(player, 1000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A third, while the second is still in flight, must not clear the cap by
|
||||||
|
// racing — pending buy-ins count against it.
|
||||||
|
if _, err := RequestBuyIn(player, 1); !errors.Is(err, ErrOverTableCap) {
|
||||||
|
t.Fatalf("err = %v, want ErrOverTableCap — in-flight buy-ins must count", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequest_RejectsNonPositiveAmounts(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 100)
|
||||||
|
|
||||||
|
for _, amount := range []int64{0, -50} {
|
||||||
|
if _, err := RequestBuyIn(player, amount); !errors.Is(err, ErrBadAmount) {
|
||||||
|
t.Errorf("buy-in %d: err = %v, want ErrBadAmount", amount, err)
|
||||||
|
}
|
||||||
|
if _, err := RequestCashOut(player, amount); !errors.Is(err, ErrBadAmount) {
|
||||||
|
t.Errorf("cash-out %d: err = %v, want ErrBadAmount", amount, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c := chipsOf(t, player); c != 100 {
|
||||||
|
t.Fatalf("chips = %d, want 100", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStakeAndAward(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 500)
|
||||||
|
|
||||||
|
if err := Stake(player, 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c := chipsOf(t, player); c != 400 {
|
||||||
|
t.Fatalf("chips = %d after staking 100, want 400", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A win pays the stake back plus the profit, net of rake.
|
||||||
|
if err := Award(player, 195); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c := chipsOf(t, player); c != 595 {
|
||||||
|
t.Fatalf("chips = %d after a 195 payout, want 595", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// You cannot bet chips you don't have.
|
||||||
|
if err := Stake(player, 10_000); !errors.Is(err, ErrInsufficientChips) {
|
||||||
|
t.Fatalf("err = %v, want ErrInsufficientChips", err)
|
||||||
|
}
|
||||||
|
if c := chipsOf(t, player); c != 595 {
|
||||||
|
t.Fatalf("chips = %d after a refused stake, want 595", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStake_UnknownPlayerHasNothingToBet(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
if err := Stake("@stranger:parodia.dev", 10); !errors.Is(err, ErrInsufficientChips) {
|
||||||
|
t.Fatalf("err = %v, want ErrInsufficientChips", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingEscrow_OffersUnclaimedAndAbandonedRows(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
fresh, err := RequestBuyIn(player, 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
abandoned, err := RequestBuyIn("@other:parodia.dev", 200)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := PendingEscrow(10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(pending) != 2 {
|
||||||
|
t.Fatalf("%d pending rows, want 2", len(pending))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim both: neither should be offered again while gogobee is working on them.
|
||||||
|
for _, e := range []Escrow{fresh, abandoned} {
|
||||||
|
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending, err = PendingEscrow(10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(pending) != 0 {
|
||||||
|
t.Fatalf("%d rows offered while claimed, want 0", len(pending))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now pretend gogobee died holding one of them. A claim that never came back
|
||||||
|
// must be re-offered, or the player's money sits in limbo forever.
|
||||||
|
stale := nowUnix() - int64(EscrowStaleAfter.Seconds()) - 1
|
||||||
|
if _, err := Get().Exec(`UPDATE game_escrow SET claimed_at = ? WHERE guid = ?`, stale, abandoned.GUID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pending, err = PendingEscrow(10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(pending) != 1 || pending[0].GUID != abandoned.GUID {
|
||||||
|
t.Fatalf("stale claim was not re-offered: got %d rows", len(pending))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaim_CannotReopenAFinishedRow(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 300)
|
||||||
|
|
||||||
|
e, err := RequestCashOut(player, 300)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-claiming a settled cash-out must not walk it back to claimed, or gogobee
|
||||||
|
// would pay the same euros out twice.
|
||||||
|
got, err := ClaimEscrow(e.GUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.State != EscrowSettled {
|
||||||
|
t.Fatalf("state = %q after re-claiming a settled row, want %q", got.State, EscrowSettled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscrow_UnknownGUID(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
if _, err := EscrowByGUID("nope"); !errors.Is(err, ErrNoSuchEscrow) {
|
||||||
|
t.Fatalf("err = %v, want ErrNoSuchEscrow", err)
|
||||||
|
}
|
||||||
|
if _, err := SettleEscrow("nope", true, "", 0); !errors.Is(err, ErrNoSuchEscrow) {
|
||||||
|
t.Fatalf("err = %v, want ErrNoSuchEscrow", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReaper_CashesOutTheWalkedAway(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 700)
|
||||||
|
|
||||||
|
// Still playing: the reaper must leave them alone.
|
||||||
|
Touch(player)
|
||||||
|
n, err := ReapIdleSessions(SessionIdleAfter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 0 || chipsOf(t, player) != 700 {
|
||||||
|
t.Fatalf("reaped %d active players (chips now %d)", n, chipsOf(t, player))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now they've been gone an hour.
|
||||||
|
old := nowUnix() - int64((2 * time.Hour).Seconds())
|
||||||
|
if _, err := Get().Exec(`UPDATE game_chips SET last_played = ? WHERE matrix_user = ?`, old, player); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n, err = ReapIdleSessions(SessionIdleAfter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("reaped %d, want 1", n)
|
||||||
|
}
|
||||||
|
if c := chipsOf(t, player); c != 0 {
|
||||||
|
t.Fatalf("chips = %d after the reaper ran, want 0 — they should be euros now", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it must be a real cash-out, waiting for gogobee to pay it out.
|
||||||
|
pending, err := PendingEscrow(10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(pending) != 1 || pending[0].Kind != KindCashOut || pending[0].Amount != 700 {
|
||||||
|
t.Fatalf("reaper did not queue a 700 cash-out: %+v", pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running again finds nothing left to reap.
|
||||||
|
if n, err := ReapIdleSessions(SessionIdleAfter); err != nil || n != 0 {
|
||||||
|
t.Fatalf("second sweep reaped %d (err=%v), want 0", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordHand_AndHouseTake(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
hands := []Hand{
|
||||||
|
{MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 195, Rake: 5, Outcome: "win", Seed1: 42, Seed2: 7},
|
||||||
|
{MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 0, Rake: 0, Outcome: "bust", Seed1: 43, Seed2: 8},
|
||||||
|
{MatrixUser: player, Game: "blackjack", Bet: 200, Payout: 486, Rake: 14, Outcome: "blackjack", Seed1: 44, Seed2: 9},
|
||||||
|
}
|
||||||
|
for _, h := range hands {
|
||||||
|
if err := RecordHand(h); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
take, err := HouseTake(0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if take != 19 {
|
||||||
|
t.Fatalf("house take = %d, want 19", take)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seeds have to survive the round trip, or a disputed hand can't be re-dealt.
|
||||||
|
var s1, s2 int64
|
||||||
|
if err := Get().QueryRow(
|
||||||
|
`SELECT seed1, seed2 FROM game_hands WHERE outcome = 'blackjack'`,
|
||||||
|
).Scan(&s1, &s2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s1 != 44 || s2 != 9 {
|
||||||
|
t.Fatalf("seeds came back as (%d, %d), want (44, 9)", s1, s2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The invariant, end to end: every euro that entered the casino is either a chip
|
||||||
|
// on the table or a euro on its way home. None are minted, none evaporate.
|
||||||
|
func TestBorder_ChipsAreConserved(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
fund(t, player, 1000)
|
||||||
|
|
||||||
|
// Play a losing hand and a winning one.
|
||||||
|
if err := Stake(player, 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} // 900
|
||||||
|
if err := Stake(player, 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} // 800
|
||||||
|
if err := Award(player, 195); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} // 995 — one loss, one win less rake
|
||||||
|
|
||||||
|
if c := chipsOf(t, player); c != 995 {
|
||||||
|
t.Fatalf("chips = %d, want 995", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
e, err := RequestCashOut(player, 995)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := SettleEscrow(e.GUID, true, "", 4995); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c := chipsOf(t, player); c != 0 {
|
||||||
|
t.Fatalf("chips = %d after cashing out everything, want 0", c)
|
||||||
|
}
|
||||||
|
// 1000 in, 995 out, 5 lost to the table and the rake. Nothing left stranded.
|
||||||
|
st, err := Chips(player)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Pending != 0 {
|
||||||
|
t.Fatalf("pending = %d, want 0", st.Pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
108
internal/storage/roster.go
Normal file
108
internal/storage/roster.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RosterEntry is one adventurer's currently-true state, as of the last snapshot
|
||||||
|
// gogobee pushed. Not an event — nothing here is a thing that *happened*.
|
||||||
|
type RosterEntry struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
ClassRace string `json:"class_race"`
|
||||||
|
Status string `json:"status"` // "expedition" | "idle"
|
||||||
|
Zone string `json:"zone,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
Day int `json:"day,omitempty"`
|
||||||
|
IdleHours int `json:"idle_hours,omitempty"`
|
||||||
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
|
||||||
|
//
|
||||||
|
// Replace — never merge. A player who dropped out of gogobee's snapshot (deleted
|
||||||
|
// character, or a fresh `!news optout`) must vanish from the board, and an
|
||||||
|
// upsert would leave them standing there forever. The transaction means a reader
|
||||||
|
// mid-swap sees the old board or the new one, never a half-empty realm.
|
||||||
|
func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`DELETE FROM adventure_roster`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO adventure_roster
|
||||||
|
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stamp the snapshot even when it carried zero adventurers — that is a
|
||||||
|
// legitimate state (quiet realm) and must not read as "gogobee went away".
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
INSERT INTO adventure_roster_meta (id, snapshot_at) VALUES (1, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET snapshot_at = excluded.snapshot_at`, snapshotAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadRoster returns the current board: everyone on an expedition first (most
|
||||||
|
// recently departed at the top of that group), then the idle, longest-idle last.
|
||||||
|
// Also returns the snapshot time so the caller can decide whether the wire has
|
||||||
|
// gone quiet — see rosterStale.
|
||||||
|
func LoadRoster() ([]RosterEntry, int64, error) {
|
||||||
|
rows, err := Get().Query(`
|
||||||
|
SELECT token, name, level, COALESCE(class_race, ''), status,
|
||||||
|
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at
|
||||||
|
FROM adventure_roster
|
||||||
|
ORDER BY status = 'expedition' DESC, day DESC, idle_hours ASC, level DESC, name ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []RosterEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e RosterEntry
|
||||||
|
if err := rows.Scan(&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
|
||||||
|
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return out, RosterSnapshotAt(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// legitimately carried nobody still counts as a snapshot.
|
||||||
|
func RosterSnapshotAt() int64 {
|
||||||
|
var at sql.NullInt64
|
||||||
|
err := Get().QueryRow(`SELECT snapshot_at FROM adventure_roster_meta WHERE id = 1`).Scan(&at)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("RosterSnapshotAt query failed", "err", err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return at.Int64
|
||||||
|
}
|
||||||
@@ -21,6 +21,37 @@ CREATE TABLE IF NOT EXISTS stories (
|
|||||||
published_at INTEGER
|
published_at INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- adventure_roster is a *snapshot*, not a log: gogobee POSTs the whole live
|
||||||
|
-- board and it replaces this table wholesale. Rows are state that is currently
|
||||||
|
-- true ("Josie is in holymachina"), which is the one thing the story feed can
|
||||||
|
-- never be — every dispatch there is an accomplishment, and an accomplishment is
|
||||||
|
-- a clipping the moment it lands.
|
||||||
|
--
|
||||||
|
-- token is gogobee's per-player roster token, not a Matrix handle and not a
|
||||||
|
-- story GUID. Players who ran "!news optout" are omitted from the snapshot
|
||||||
|
-- upstream and so never appear here at all.
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_roster (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
class_race TEXT,
|
||||||
|
status TEXT NOT NULL, -- "expedition" | "idle"
|
||||||
|
zone TEXT,
|
||||||
|
region TEXT,
|
||||||
|
day INTEGER NOT NULL DEFAULT 0, -- expedition day, 0 if idle
|
||||||
|
idle_hours INTEGER NOT NULL DEFAULT 0, -- hours since last player action
|
||||||
|
snapshot_at INTEGER NOT NULL -- when gogobee took the snapshot
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The snapshot time lives outside the rows because an *empty* board is
|
||||||
|
-- ambiguous: either nobody is playing, or gogobee has stopped talking to us. A
|
||||||
|
-- MAX(snapshot_at) over zero rows can't tell those apart, and the page must —
|
||||||
|
-- one is "quiet realm", the other is "the wire is down, trust nothing here".
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_roster_meta (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
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,
|
||||||
@@ -128,6 +159,89 @@ CREATE TABLE IF NOT EXISTS story_views (
|
|||||||
PRIMARY KEY (story_id, day)
|
PRIMARY KEY (story_id, day)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- games.parodia.dev
|
||||||
|
--
|
||||||
|
-- The invariant the whole casino rests on: a euro is either in gogobee's
|
||||||
|
-- euro_balances or in Pete's chip escrow, never both. It crosses between them
|
||||||
|
-- only via a GUID-idempotent claim, and Pete never writes a euro balance —
|
||||||
|
-- gogobee does, when it claims the escrow row and tells us how it went.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- A player's chips: euros that have crossed into the casino and haven't crossed
|
||||||
|
-- back yet. 1:1 with euros. Keyed by Matrix user id, because that's the identity
|
||||||
|
-- gogobee's ledger uses and the one an Authentik username maps onto.
|
||||||
|
CREATE TABLE IF NOT EXISTS game_chips (
|
||||||
|
matrix_user TEXT PRIMARY KEY,
|
||||||
|
chips INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- Advisory only, and stale by design: the last euro balance gogobee told us
|
||||||
|
-- about. Displayed, never trusted. The authoritative check is the debit at
|
||||||
|
-- claim time, which happens on gogobee's box against gogobee's ledger.
|
||||||
|
euro_balance REAL,
|
||||||
|
last_played INTEGER NOT NULL DEFAULT 0, -- unix; the reaper reads this
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One crossing of the euro/chip border, in either direction.
|
||||||
|
--
|
||||||
|
-- requested -> claimed -> funded (buy-in: gogobee debited, chips spendable)
|
||||||
|
-- -> rejected (buy-in: insufficient funds, no chips)
|
||||||
|
-- requested -> claimed -> settled (cash-out: chips gone, euros credited)
|
||||||
|
--
|
||||||
|
-- The guid is the idempotency key end to end: it's what gogobee passes to
|
||||||
|
-- DebitIdem/CreditIdem, so a claim whose ack is lost on the wire can be retried
|
||||||
|
-- without the player paying twice.
|
||||||
|
CREATE TABLE IF NOT EXISTS game_escrow (
|
||||||
|
guid TEXT PRIMARY KEY,
|
||||||
|
matrix_user TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- 'buyin' | 'cashout'
|
||||||
|
amount INTEGER NOT NULL, -- euros == chips
|
||||||
|
state TEXT NOT NULL, -- see the ladder above
|
||||||
|
reason TEXT, -- 'insufficient_funds', when rejected
|
||||||
|
balance_after REAL, -- gogobee's euro balance after the move
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
claimed_at INTEGER, -- when gogobee took it; drives the re-poll
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_escrow_state ON game_escrow(state, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_escrow_user ON game_escrow(matrix_user, created_at DESC);
|
||||||
|
|
||||||
|
-- Every hand played, for money. This is the audit trail: seeds so a disputed
|
||||||
|
-- hand can be re-dealt exactly as it fell, rake so the house's take is
|
||||||
|
-- accountable, and enough shape to answer "how fast is this economy actually
|
||||||
|
-- moving" before the answer becomes a problem.
|
||||||
|
CREATE TABLE IF NOT EXISTS game_hands (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
matrix_user TEXT NOT NULL,
|
||||||
|
game TEXT NOT NULL, -- 'blackjack'
|
||||||
|
bet INTEGER NOT NULL,
|
||||||
|
payout INTEGER NOT NULL, -- chips returned, net of rake
|
||||||
|
rake INTEGER NOT NULL,
|
||||||
|
outcome TEXT NOT NULL,
|
||||||
|
seed1 INTEGER NOT NULL, -- the shoe, reproducible
|
||||||
|
seed2 INTEGER NOT NULL,
|
||||||
|
played_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_hands_user ON game_hands(matrix_user, played_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_hands_played ON game_hands(played_at);
|
||||||
|
|
||||||
|
-- The hand a player is in the middle of. One per player: you cannot be dealt a
|
||||||
|
-- second hand while chips are riding on the first.
|
||||||
|
--
|
||||||
|
-- The state column is the engine's State, serialized whole — shoe included. It
|
||||||
|
-- lives here rather than in memory because Pete redeploys often, and a player
|
||||||
|
-- whose stake has already been taken must find their cards where they left them
|
||||||
|
-- rather than a table that has forgotten them. It is also why the deck never
|
||||||
|
-- goes to the browser: the authoritative shoe is this row, on the server.
|
||||||
|
CREATE TABLE IF NOT EXISTS game_live_hands (
|
||||||
|
matrix_user TEXT PRIMARY KEY,
|
||||||
|
game TEXT NOT NULL, -- 'blackjack'
|
||||||
|
state TEXT NOT NULL, -- JSON: the engine's State
|
||||||
|
seed1 INTEGER NOT NULL, -- carried to the audit log when it settles
|
||||||
|
seed2 INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id);
|
CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
|
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
|
||||||
|
|||||||
@@ -151,12 +151,14 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
|
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
|
||||||
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
||||||
// media), and the link's og:image carries the preview instead.
|
// media), and the link's og:image carries the preview instead.
|
||||||
|
// No Source: the source tag exists to credit an outlet Pete is relaying
|
||||||
|
// (`ars technica`). On his own reporting it renders as him signing his
|
||||||
|
// own name under his own message.
|
||||||
s.advPost(AdvPost{
|
s.advPost(AdvPost{
|
||||||
GUID: f.GUID,
|
GUID: f.GUID,
|
||||||
Headline: headline,
|
Headline: headline,
|
||||||
Lede: lede,
|
Lede: lede,
|
||||||
ArticleURL: articleURL,
|
ArticleURL: articleURL,
|
||||||
Source: advSource,
|
|
||||||
Channel: s.adv.Channel,
|
Channel: s.adv.Channel,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -188,6 +190,18 @@ func advEventMeta(eventType string) (label, emoji string) {
|
|||||||
return "Pete's duels", "🤝"
|
return "Pete's duels", "🤝"
|
||||||
case "milestone":
|
case "milestone":
|
||||||
return "Milestone", "🏅"
|
return "Milestone", "🏅"
|
||||||
|
case "retreat":
|
||||||
|
return "Pulled out", "🎒"
|
||||||
|
case "departure":
|
||||||
|
return "Wandered off", "🚪"
|
||||||
|
case "mischief_contract":
|
||||||
|
return "Coin on their head", "😈"
|
||||||
|
case "mischief_survived":
|
||||||
|
return "They walked away", "🛡️"
|
||||||
|
case "mischief_downed":
|
||||||
|
return "The contract landed", "💀"
|
||||||
|
case "mischief_fizzled":
|
||||||
|
return "Nobody home", "🚪"
|
||||||
}
|
}
|
||||||
return "Dispatch", "📣"
|
return "Dispatch", "📣"
|
||||||
}
|
}
|
||||||
@@ -375,6 +389,57 @@ func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
|
|||||||
case "death":
|
case "death":
|
||||||
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
|
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
|
||||||
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
|
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
|
||||||
|
case "retreat":
|
||||||
|
// An expedition that came apart without killing anyone. Until gogobee
|
||||||
|
// started sending these, the feed had no way to say "it went badly and
|
||||||
|
// everyone lived" — so it never said it, and the classes that retreat
|
||||||
|
// often simply never appeared. Warm, not a failure notice: everyone came
|
||||||
|
// home, and that is the part Pete leads with.
|
||||||
|
howFar := "barely a day in"
|
||||||
|
if f.Count > 1 {
|
||||||
|
howFar = fmt.Sprintf("%d days in", f.Count)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s backed out of %s.", f.Subject, f.Zone),
|
||||||
|
fmt.Sprintf("%s turned around %s — %s got the better of them this time%s, and they made the call to walk out rather than push it. Everybody came home breathing, which is the bit that counts. That dungeon'll still be there next week.",
|
||||||
|
f.Subject, howFar, f.Zone, atLevel), true
|
||||||
|
case "departure":
|
||||||
|
// A bored adventurer let themselves out. Nobody sent them — they got
|
||||||
|
// restless waiting on a player who wasn't coming, took the cheap supplies
|
||||||
|
// they could afford, and went. Pete plays it straight and a little fond;
|
||||||
|
// the joke tells itself, and the player it's about may well be reading.
|
||||||
|
return fmt.Sprintf("%s got bored and left without waiting.", f.Subject),
|
||||||
|
fmt.Sprintf("No orders, no escort, no fuss — %s packed the cheapest kit on the shelf and set off into %s%s. Nobody told them to. Nobody talked them out of it either. We'll let you know how it goes.", f.Subject, f.Zone, atLevel), true
|
||||||
|
case "mischief_contract":
|
||||||
|
// Somebody paid to have a monster sent after an adventurer who is out in a
|
||||||
|
// dungeon right now. Anonymous unless the buyer paid extra to sign it, and
|
||||||
|
// the anonymity is the story: Pete reports the money, not the name he
|
||||||
|
// doesn't have. Opponent carries the buyer only when it's public.
|
||||||
|
if f.Opponent != "" {
|
||||||
|
return fmt.Sprintf("%s has put %s on %s's head.", f.Opponent, f.Stakes, f.Subject),
|
||||||
|
fmt.Sprintf("No secret about it — %s paid for a %s to go find %s out in whatever hole they're currently down, and signed the thing. It's out there looking right now. If %s comes back breathing, they keep a cut of that money.",
|
||||||
|
f.Opponent, strings.ToLower(f.Boss), f.Subject, f.Subject), true
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Someone's put %s on %s's head.", f.Stakes, f.Subject),
|
||||||
|
fmt.Sprintf("Word came in quiet: %s has been paid for a %s to go looking for %s, and whoever paid it isn't saying so. It's already out there. Survive it and the money's theirs — and we all find out who signed the cheque.",
|
||||||
|
f.Stakes, strings.ToLower(f.Boss), f.Subject), true
|
||||||
|
case "mischief_survived":
|
||||||
|
// The unseal. A survival is the only thing that names an anonymous buyer,
|
||||||
|
// and it is the whole brake on casual griefing — so Pete leads with it.
|
||||||
|
return fmt.Sprintf("%s walked away from it. It was %s who paid.", f.Subject, f.Opponent),
|
||||||
|
fmt.Sprintf("%s came for %s, and %s is the one still standing — %s richer for the trouble. The contract's been opened up, and the name inside it is %s. Make of that what you will, folks.",
|
||||||
|
f.Boss, f.Subject, f.Subject, f.Stakes, f.Opponent), true
|
||||||
|
case "mischief_downed":
|
||||||
|
who := "Nobody's saying who paid for it"
|
||||||
|
if f.Opponent != "" {
|
||||||
|
who = fmt.Sprintf("%s paid for it, and put their name on it", f.Opponent)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s didn't walk away.", f.Subject),
|
||||||
|
fmt.Sprintf("A %s found %s mid-run%s and put them on the floor. They're being carried home — alive, which is more than the contract asked for, but that expedition's finished. %s.",
|
||||||
|
f.Boss, f.Subject, atLevel, who), true
|
||||||
|
case "mischief_fizzled":
|
||||||
|
return fmt.Sprintf("The monster sent for %s arrived to an empty dungeon.", f.Subject),
|
||||||
|
fmt.Sprintf("Somebody spent good money to have %s ambushed, and %s had already gone home. It wandered the halls for a bit and left. Most of the fee's been refunded. The rest, the town's keeping.",
|
||||||
|
f.Subject, f.Subject), true
|
||||||
case "arrival":
|
case "arrival":
|
||||||
return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
|
return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
|
||||||
fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true
|
fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ func (s *Server) postDailyDigest(now time.Time) {
|
|||||||
Headline: headline,
|
Headline: headline,
|
||||||
Lede: lede,
|
Lede: lede,
|
||||||
ArticleURL: s.digestURL(date),
|
ArticleURL: s.digestURL(date),
|
||||||
Source: advSource,
|
|
||||||
Channel: s.adv.Channel,
|
Channel: s.adv.Channel,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
55
internal/web/adventure_retreat_test.go
Normal file
55
internal/web/adventure_retreat_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The retreat bulletin — gogobee's newest event type.
|
||||||
|
//
|
||||||
|
// The failure this guards against is silent and total: handleAdventureIngest
|
||||||
|
// answers an unrecognized event_type with 400, and gogobee's sender treats any
|
||||||
|
// non-2xx as a failure, retries eight times over ~two hours, and then PARKS the
|
||||||
|
// row forever. So a gogobee that emits `retreat` against a Pete that doesn't
|
||||||
|
// know the word doesn't log an error anyone reads — it just quietly drops every
|
||||||
|
// retreat the realm ever files. Pete has to learn the word first, and this test
|
||||||
|
// is what says he has.
|
||||||
|
func TestAdventureIngest_AcceptsRetreat(t *testing.T) {
|
||||||
|
const token = "s3cret-token"
|
||||||
|
s, posted := newAdvServer(t, token)
|
||||||
|
|
||||||
|
f := AdvFact{
|
||||||
|
GUID: "retreat:abc:1000", EventType: "retreat", Tier: "bulletin",
|
||||||
|
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||||
|
Zone: "the Underforge", Level: 12, Count: 3, Outcome: "retreated",
|
||||||
|
OccurredAt: 1000,
|
||||||
|
}
|
||||||
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||||
|
t.Fatalf("ingest status = %d body=%s — Pete rejected a retreat, so gogobee will "+
|
||||||
|
"retry it eight times and park it forever", rw.Code, rw.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := storage.GetStoryByGUID("retreat:abc:1000")
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatalf("retreat was accepted but not stored: %v", err)
|
||||||
|
}
|
||||||
|
// It has to actually say what happened, in Pete's voice, using only the
|
||||||
|
// supplied facts.
|
||||||
|
body := got.Headline + " " + got.Lede
|
||||||
|
for _, want := range []string{"Brannigan", "the Underforge"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("rendered retreat is missing %q: %q", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.Lede, "3 days in") {
|
||||||
|
t.Errorf("the day count never made it into the copy: %q", got.Lede)
|
||||||
|
}
|
||||||
|
// Bulletin, not priority: a retreat goes in the daily digest, it does not
|
||||||
|
// interrupt the room. Pete announcing every failed run live would be a
|
||||||
|
// firehose — and an unkind one.
|
||||||
|
if len(*posted) != 0 {
|
||||||
|
t.Errorf("a bulletin was posted live: %+v", *posted)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -311,3 +311,61 @@ func TestAdventureDisabled(t *testing.T) {
|
|||||||
t.Errorf("disabled: status = %d, want 404", rw.Code)
|
t.Errorf("disabled: status = %d, want 404", rw.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRenderMischief: gogobee's four mischief event types must all render. An
|
||||||
|
// unknown event_type is a 400 at ingest, which gogobee retries and then parks
|
||||||
|
// forever — so "Pete deploys first" only helps if Pete actually knows the types.
|
||||||
|
//
|
||||||
|
// It also pins the anonymity contract, which is the feature's whole social
|
||||||
|
// engine: an unsigned contract must not name the buyer, and a survival must.
|
||||||
|
func TestRenderMischief(t *testing.T) {
|
||||||
|
anon := AdvFact{EventType: "mischief_contract", Tier: "priority",
|
||||||
|
Subject: "Josie", Boss: "Elite", Stakes: "€350", Level: 14}
|
||||||
|
hl, lede, ok := renderAdventure(anon)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("mischief_contract did not render — ingest would 400")
|
||||||
|
}
|
||||||
|
if strings.Contains(hl+lede, "Brannigan") {
|
||||||
|
t.Error("anonymous contract leaked a buyer name")
|
||||||
|
}
|
||||||
|
if !strings.Contains(hl, "€350") {
|
||||||
|
t.Errorf("contract headline lost the stakes: %q", hl)
|
||||||
|
}
|
||||||
|
|
||||||
|
signed := anon
|
||||||
|
signed.Opponent = "Brannigan"
|
||||||
|
hl, _, ok = renderAdventure(signed)
|
||||||
|
if !ok || !strings.Contains(hl, "Brannigan") {
|
||||||
|
t.Errorf("signed contract should name the buyer: %q (ok=%v)", hl, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unseal: a survival names the buyer whether or not they signed.
|
||||||
|
survived := AdvFact{EventType: "mischief_survived", Tier: "priority",
|
||||||
|
Subject: "Josie", Opponent: "Brannigan", Boss: "Bone Colossus", Stakes: "€228"}
|
||||||
|
hl, _, ok = renderAdventure(survived)
|
||||||
|
if !ok || !strings.Contains(hl, "Brannigan") {
|
||||||
|
t.Errorf("survival must unseal the buyer: %q (ok=%v)", hl, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A downed target with an anonymous buyer stays anonymous — being maimed
|
||||||
|
// doesn't buy you the name.
|
||||||
|
downed := AdvFact{EventType: "mischief_downed", Tier: "priority",
|
||||||
|
Subject: "Josie", Boss: "Bone Colossus", Level: 14}
|
||||||
|
hl, lede, ok = renderAdventure(downed)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("mischief_downed did not render")
|
||||||
|
}
|
||||||
|
if strings.Contains(hl+lede, "Brannigan") {
|
||||||
|
t.Error("anonymous buyer named on a downed contract")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, ok := renderAdventure(AdvFact{EventType: "mischief_fizzled", Subject: "Josie", Stakes: "€315"}); !ok {
|
||||||
|
t.Error("mischief_fizzled did not render")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, et := range []string{"mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled"} {
|
||||||
|
if lbl, _ := advEventMeta(et); lbl == "Dispatch" {
|
||||||
|
t.Errorf("%s has no permalink label", et)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ type Authenticator struct {
|
|||||||
oauth *oauth2.Config
|
oauth *oauth2.Config
|
||||||
verifier *oidc.IDTokenVerifier
|
verifier *oidc.IDTokenVerifier
|
||||||
secret []byte
|
secret []byte
|
||||||
|
domain string // cookie Domain; empty means host-only
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionUser is the identity carried in the signed session cookie.
|
// SessionUser is the identity carried in the signed session cookie.
|
||||||
@@ -39,7 +41,23 @@ type SessionUser struct {
|
|||||||
Sub string `json:"sub"`
|
Sub string `json:"sub"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
Exp int64 `json:"exp"`
|
// Username is the Authentik preferred_username, which MAS imported as the
|
||||||
|
// Matrix localpart — so it is also who this person is in the game economy.
|
||||||
|
// Sessions signed before games existed don't carry it; MatrixUser returns
|
||||||
|
// "" for those and the caller sends them back through sign-in.
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Exp int64 `json:"exp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatrixUser maps the session to a Matrix ID on the given server name, e.g.
|
||||||
|
// "reala" on "parodia.dev" -> "@reala:parodia.dev". Empty if either half is
|
||||||
|
// missing, which callers must treat as "not identified in the economy".
|
||||||
|
func (u *SessionUser) MatrixUser(serverName string) string {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(u.Username))
|
||||||
|
if name == "" || serverName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "@" + name + ":" + serverName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display is the friendly name shown in the header (name, else email, else sub).
|
// Display is the friendly name shown in the header (name, else email, else sub).
|
||||||
@@ -88,6 +106,7 @@ func newAuthenticator(ctx context.Context, cfg config.AuthConfig) (*Authenticato
|
|||||||
},
|
},
|
||||||
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
||||||
secret: []byte(cfg.SessionSecret),
|
secret: []byte(cfg.SessionSecret),
|
||||||
|
domain: strings.TrimSpace(cfg.CookieDomain),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,11 +169,23 @@ func (a *Authenticator) userFromRequest(r *http.Request) *SessionUser {
|
|||||||
return &u
|
return &u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cookieDomain is the Domain attribute for a given cookie. Only the session
|
||||||
|
// cookie is widened: the OAuth round-trip cookie stays host-only, because it
|
||||||
|
// pairs with a redirect back to the host that started the login and has no
|
||||||
|
// business being readable from anywhere else.
|
||||||
|
func (a *Authenticator) cookieDomain(name string) string {
|
||||||
|
if name == sessionCookie {
|
||||||
|
return a.domain
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) {
|
func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: name,
|
Name: name,
|
||||||
Value: value,
|
Value: value,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
|
Domain: a.cookieDomain(name),
|
||||||
Expires: time.Now().Add(ttl),
|
Expires: time.Now().Add(ttl),
|
||||||
MaxAge: int(ttl.Seconds()),
|
MaxAge: int(ttl.Seconds()),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
@@ -166,10 +197,42 @@ func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl
|
|||||||
func (a *Authenticator) clearCookie(w http.ResponseWriter, name string) {
|
func (a *Authenticator) clearCookie(w http.ResponseWriter, name string) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: name, Value: "", Path: "/", MaxAge: -1,
|
Name: name, Value: "", Path: "/", MaxAge: -1,
|
||||||
|
Domain: a.cookieDomain(name),
|
||||||
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
|
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// oauthFor returns the OAuth config to use for this request. The configured
|
||||||
|
// redirect_url names one host (news), but a login that starts on games has to
|
||||||
|
// come back to games — otherwise the browser is dumped on the news site with
|
||||||
|
// its "next" path pointing at a page that lives elsewhere. So when the request
|
||||||
|
// arrives on a host inside the shared cookie domain, keep the redirect on that
|
||||||
|
// host, reusing the configured URL's scheme and path. Every host used this way
|
||||||
|
// must be registered as a redirect URI in Authentik.
|
||||||
|
func (a *Authenticator) oauthFor(r *http.Request) *oauth2.Config {
|
||||||
|
if a.domain == "" || !hostInDomain(r.Host, a.domain) {
|
||||||
|
return a.oauth
|
||||||
|
}
|
||||||
|
u, err := url.Parse(a.oauth.RedirectURL)
|
||||||
|
if err != nil || u.Host == r.Host {
|
||||||
|
return a.oauth
|
||||||
|
}
|
||||||
|
cfg := *a.oauth
|
||||||
|
cfg.RedirectURL = u.Scheme + "://" + r.Host + u.Path
|
||||||
|
return &cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostInDomain reports whether host sits inside a cookie domain like
|
||||||
|
// ".parodia.dev" (which also covers the bare "parodia.dev").
|
||||||
|
func hostInDomain(host, domain string) bool {
|
||||||
|
if i := strings.IndexByte(host, ':'); i >= 0 {
|
||||||
|
host = host[:i] // strip port
|
||||||
|
}
|
||||||
|
host = strings.ToLower(host)
|
||||||
|
bare := strings.TrimPrefix(strings.ToLower(domain), ".")
|
||||||
|
return host == bare || strings.HasSuffix(host, "."+bare)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- handlers -------------------------------------------------------------
|
// ---- handlers -------------------------------------------------------------
|
||||||
|
|
||||||
// handleLogin starts the OIDC authorization-code flow.
|
// handleLogin starts the OIDC authorization-code flow.
|
||||||
@@ -182,7 +245,7 @@ func (a *Authenticator) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
payload, _ := json.Marshal(st)
|
payload, _ := json.Marshal(st)
|
||||||
a.setCookie(w, oauthCookie, a.sign(payload), oauthTTL)
|
a.setCookie(w, oauthCookie, a.sign(payload), oauthTTL)
|
||||||
http.Redirect(w, r, a.oauth.AuthCodeURL(st.State, oidc.Nonce(st.Nonce)), http.StatusFound)
|
http.Redirect(w, r, a.oauthFor(r).AuthCodeURL(st.State, oidc.Nonce(st.Nonce)), http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCallback completes the flow: validates state, exchanges the code,
|
// handleCallback completes the flow: validates state, exchanges the code,
|
||||||
@@ -212,7 +275,7 @@ func (a *Authenticator) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
tok, err := a.oauth.Exchange(ctx, r.URL.Query().Get("code"))
|
tok, err := a.oauthFor(r).Exchange(ctx, r.URL.Query().Get("code"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("auth: code exchange failed", "err", err)
|
slog.Error("auth: code exchange failed", "err", err)
|
||||||
http.Error(w, "sign-in failed", http.StatusBadGateway)
|
http.Error(w, "sign-in failed", http.StatusBadGateway)
|
||||||
@@ -247,7 +310,13 @@ func (a *Authenticator) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
name = claims.PreferredUsername
|
name = claims.PreferredUsername
|
||||||
}
|
}
|
||||||
u := SessionUser{Sub: claims.Sub, Name: name, Email: claims.Email, Exp: time.Now().Add(sessionTTL).Unix()}
|
u := SessionUser{
|
||||||
|
Sub: claims.Sub,
|
||||||
|
Name: name,
|
||||||
|
Email: claims.Email,
|
||||||
|
Username: claims.PreferredUsername,
|
||||||
|
Exp: time.Now().Add(sessionTTL).Unix(),
|
||||||
|
}
|
||||||
sess, _ := json.Marshal(u)
|
sess, _ := json.Marshal(u)
|
||||||
a.setCookie(w, sessionCookie, a.sign(sess), sessionTTL)
|
a.setCookie(w, sessionCookie, a.sign(sess), sessionTTL)
|
||||||
slog.Info("auth: user signed in", "sub", claims.Sub, "name", name)
|
slog.Info("auth: user signed in", "sub", claims.Sub, "name", name)
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
func TestSignVerifyRoundTrip(t *testing.T) {
|
func TestSignVerifyRoundTrip(t *testing.T) {
|
||||||
a := &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
a := &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
@@ -53,3 +59,95 @@ func TestSafeNext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMatrixUser(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
u SessionUser
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"lowercased", SessionUser{Username: "Reala"}, "@reala:parodia.dev"},
|
||||||
|
{"trimmed", SessionUser{Username: " reala "}, "@reala:parodia.dev"},
|
||||||
|
{"old session with no username", SessionUser{Sub: "abc", Name: "Reala"}, ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := tc.u.MatrixUser("parodia.dev"); got != tc.want {
|
||||||
|
t.Fatalf("MatrixUser = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if got := (&SessionUser{Username: "reala"}).MatrixUser(""); got != "" {
|
||||||
|
t.Fatalf("no server name should yield no identity, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostInDomain(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
host, domain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"games.parodia.dev", ".parodia.dev", true},
|
||||||
|
{"news.parodia.dev:8080", ".parodia.dev", true},
|
||||||
|
{"parodia.dev", ".parodia.dev", true},
|
||||||
|
{"GAMES.PARODIA.DEV", ".parodia.dev", true},
|
||||||
|
{"evil.com", ".parodia.dev", false},
|
||||||
|
// The suffix check must not match a domain that merely ends in the
|
||||||
|
// same letters: notparodia.dev is a different site entirely.
|
||||||
|
{"notparodia.dev", ".parodia.dev", false},
|
||||||
|
{"games.parodia.dev.evil.com", ".parodia.dev", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := hostInDomain(tc.host, tc.domain); got != tc.want {
|
||||||
|
t.Errorf("hostInDomain(%q, %q) = %v, want %v", tc.host, tc.domain, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOAuthForKeepsLoginOnTheHostItStartedOn(t *testing.T) {
|
||||||
|
base := &oauth2.Config{RedirectURL: "https://news.parodia.dev/auth/callback"}
|
||||||
|
a := &Authenticator{oauth: base, domain: ".parodia.dev"}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/auth/login", nil)
|
||||||
|
req.Host = "games.parodia.dev"
|
||||||
|
if got := a.oauthFor(req).RedirectURL; got != "https://games.parodia.dev/auth/callback" {
|
||||||
|
t.Fatalf("games login should come back to games, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Host = "news.parodia.dev"
|
||||||
|
if got := a.oauthFor(req).RedirectURL; got != base.RedirectURL {
|
||||||
|
t.Fatalf("news login should use the configured URL, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Host we don't own must never be echoed back into a redirect URI.
|
||||||
|
req.Host = "evil.com"
|
||||||
|
if got := a.oauthFor(req).RedirectURL; got != base.RedirectURL {
|
||||||
|
t.Fatalf("foreign host must fall back to the configured URL, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no cookie domain configured there is nothing to share, so the
|
||||||
|
// configured redirect stands whatever the Host header says.
|
||||||
|
off := &Authenticator{oauth: base}
|
||||||
|
req.Host = "games.parodia.dev"
|
||||||
|
if got := off.oauthFor(req).RedirectURL; got != base.RedirectURL {
|
||||||
|
t.Fatalf("host-only mode must not rewrite the redirect, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionCookieIsSharedButOAuthCookieIsNot(t *testing.T) {
|
||||||
|
a := &Authenticator{secret: []byte("test-secret-key-at-least-16"), domain: ".parodia.dev"}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.setCookie(rec, sessionCookie, "v", time.Minute)
|
||||||
|
a.setCookie(rec, oauthCookie, "v", time.Minute)
|
||||||
|
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
got[c.Name] = c.Domain
|
||||||
|
}
|
||||||
|
if got[sessionCookie] != "parodia.dev" {
|
||||||
|
t.Fatalf("session cookie domain = %q, want it shared across parodia.dev", got[sessionCookie])
|
||||||
|
}
|
||||||
|
if got[oauthCookie] != "" {
|
||||||
|
t.Fatalf("oauth cookie must stay host-only, got domain %q", got[oauthCookie])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
71
internal/web/devcasino_test.go
Normal file
71
internal/web/devcasino_test.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDevCasino is not a test. It is the casino, running, on a port, with one
|
||||||
|
// signed-in player who has chips — so the table can be driven in a real browser,
|
||||||
|
// which is the only honest way to review an animation.
|
||||||
|
//
|
||||||
|
// Skipped unless you ask for it:
|
||||||
|
//
|
||||||
|
// PETE_DEV_CASINO=:7788 go test ./internal/web -run TestDevCasino -timeout 0
|
||||||
|
//
|
||||||
|
// It prints the session cookie to plant. The routes are wired here rather than
|
||||||
|
// taken from New(), because New() decides whether the casino exists at the
|
||||||
|
// moment it builds the mux, and the test rig only signs the player in afterwards.
|
||||||
|
func TestDevCasino(t *testing.T) {
|
||||||
|
addr := os.Getenv("PETE_DEV_CASINO")
|
||||||
|
if addr == "" {
|
||||||
|
t.Skip("set PETE_DEV_CASINO=:port to run the casino for a browser")
|
||||||
|
}
|
||||||
|
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 5000)
|
||||||
|
|
||||||
|
payload, _ := json.Marshal(SessionUser{
|
||||||
|
Sub: "sub-1", Username: "reala", Name: "Reala",
|
||||||
|
Exp: time.Now().Add(24 * time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
cookie := s.auth.sign(payload)
|
||||||
|
|
||||||
|
staticSub, err := fs.Sub(staticFS, "static")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
||||||
|
mux.HandleFunc("GET /games", s.handleLobby)
|
||||||
|
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
|
||||||
|
mux.HandleFunc("GET /api/games/table", s.handleTable)
|
||||||
|
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
|
||||||
|
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
|
||||||
|
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
|
||||||
|
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Written to a file, not printed: `go test` buffers stdout, and the browser
|
||||||
|
// driver needs the cookie while the server is still running.
|
||||||
|
if out := os.Getenv("PETE_DEV_COOKIE_FILE"); out != "" {
|
||||||
|
if err := os.WriteFile(out, []byte(cookie), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("\nCASINO http://localhost%s/games/blackjack\nCOOKIE %s=%s\n\n", addr, sessionCookie, cookie)
|
||||||
|
|
||||||
|
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||||
|
t.Cleanup(func() { _ = srv.Close() })
|
||||||
|
_ = srv.Serve(ln)
|
||||||
|
}
|
||||||
151
internal/web/games.go
Normal file
151
internal/web/games.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The euro/chip wire.
|
||||||
|
//
|
||||||
|
// gogobee owns the euros and has no inbound API, so it is the only initiator:
|
||||||
|
// it polls this endpoint for border crossings, moves the money on its side, and
|
||||||
|
// pushes the verdict back through the durable queue it already uses for
|
||||||
|
// adventure facts. Pete never calls gogobee. That direction of travel is a
|
||||||
|
// standing rule of the seam, not an implementation detail — see roster.go.
|
||||||
|
//
|
||||||
|
// All three endpoints are bearer-authed with the same ingest token as the
|
||||||
|
// adventure seam, and all three are idempotent, because the thing on the other
|
||||||
|
// end of them is a retrying queue and the thing they move is money.
|
||||||
|
//
|
||||||
|
// The storage layer under this (internal/storage/games.go) is where the actual
|
||||||
|
// invariant lives: chips exist only once gogobee confirms it took the euros.
|
||||||
|
// These handlers are transport, and deliberately nothing more.
|
||||||
|
|
||||||
|
// escrowGUID is the body of the two POSTs that name a row.
|
||||||
|
type escrowGUID struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// escrowVerdict is gogobee's answer: did the money move, and what is the
|
||||||
|
// player's euro balance now.
|
||||||
|
type escrowVerdict struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
BalanceAfter float64 `json:"balance_after"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEscrowPending is gogobee's poll: every crossing waiting to be moved.
|
||||||
|
//
|
||||||
|
// It includes rows gogobee claimed but never reported on — see
|
||||||
|
// storage.PendingEscrow. Re-offering those is the whole reason the guid is an
|
||||||
|
// idempotency key: if gogobee already moved the euros, the retry is a no-op
|
||||||
|
// that reports the same answer, and if it died before moving them, the money
|
||||||
|
// gets moved now instead of being stranded.
|
||||||
|
func (s *Server) handleEscrowPending(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := storage.PendingEscrow(escrowPollLimit)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: pending escrow", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rows == nil {
|
||||||
|
rows = []storage.Escrow{} // an empty poll is [], never null
|
||||||
|
}
|
||||||
|
writeJSON(w, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// escrowPollLimit caps one poll. gogobee polls every few seconds, so a backlog
|
||||||
|
// drains in a handful of ticks rather than arriving as one enormous body.
|
||||||
|
const escrowPollLimit = 50
|
||||||
|
|
||||||
|
// handleEscrowClaim marks a row as taken. It is not a lock — a row already
|
||||||
|
// claimed can be claimed again, which is how a stale re-offer works — but a row
|
||||||
|
// that has already reached a verdict cannot be, which is what stops a settled
|
||||||
|
// cash-out being paid twice.
|
||||||
|
//
|
||||||
|
// The claimed row goes back in the response, so gogobee moves the money against
|
||||||
|
// the amount and the user *Pete* holds rather than the ones it read a poll ago.
|
||||||
|
func (s *Server) handleEscrowClaim(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req escrowGUID
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&req); err != nil || req.GUID == "" {
|
||||||
|
http.Error(w, "guid is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e, err := storage.ClaimEscrow(req.GUID)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchEscrow) {
|
||||||
|
http.Error(w, "no such escrow", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: claim escrow", "guid", req.GUID, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEscrowSettled applies gogobee's verdict. This is the only way chips are
|
||||||
|
// ever created, and it runs exactly once per guid no matter how many times the
|
||||||
|
// push is redelivered.
|
||||||
|
//
|
||||||
|
// An unknown guid is a 400 rather than a shrug: gogobee has, by this point,
|
||||||
|
// already moved real euros for a row Pete has no record of. Under the contract
|
||||||
|
// the adventure seam established, a 400 makes gogobee's sender park the row
|
||||||
|
// instead of retrying it forever — which is right, because no amount of retrying
|
||||||
|
// invents the missing row. It leaves the payload sitting in gogobee's queue,
|
||||||
|
// where a human can find it.
|
||||||
|
func (s *Server) handleEscrowSettled(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var v escrowVerdict
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
e, err := storage.SettleEscrow(v.GUID, v.OK, v.Reason, v.BalanceAfter)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchEscrow) {
|
||||||
|
slog.Error("games: verdict for an escrow row we have never heard of — "+
|
||||||
|
"gogobee has moved euros against it and Pete cannot honour them",
|
||||||
|
"guid", v.GUID, "ok", v.OK)
|
||||||
|
http.Error(w, "no such escrow", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: settle escrow", "guid", v.GUID, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("games: escrow settled", "guid", e.GUID, "user", e.MatrixUser,
|
||||||
|
"kind", e.Kind, "amount", e.Amount, "state", e.State, "reason", e.Reason)
|
||||||
|
writeJSON(w, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
slog.Error("games: write response", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
114
internal/web/games_pages.go
Normal file
114
internal/web/games_pages.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/games/blackjack"
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The casino's two pages. Both require a signed-in visitor — there is money in
|
||||||
|
// here, and a player has to be somebody gogobee's ledger can name.
|
||||||
|
//
|
||||||
|
// Neither page renders any game state server-side. The felt is drawn by the
|
||||||
|
// browser from /api/games/table, because a hand is a thing that *happens*: cards
|
||||||
|
// are dealt one at a time and the table plays that back. A server-rendered hand
|
||||||
|
// would arrive fully formed, which is the one thing a card table must never do.
|
||||||
|
|
||||||
|
// gameTeaser is a table that isn't open yet. They're on the lobby because an
|
||||||
|
// empty casino with one game reads as broken, and this reads as early.
|
||||||
|
type gameTeaser struct {
|
||||||
|
Name string
|
||||||
|
Emoji string
|
||||||
|
Blurb string
|
||||||
|
}
|
||||||
|
|
||||||
|
var comingSoon = []gameTeaser{
|
||||||
|
{Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
|
||||||
|
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
|
||||||
|
{Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."},
|
||||||
|
{Name: "Hangman", Emoji: "🪢", Blurb: "Guess the phrase before the gallows finish."},
|
||||||
|
}
|
||||||
|
|
||||||
|
// betDenominations are the chips you build a bet out of.
|
||||||
|
var betDenominations = []int64{5, 25, 100, 500}
|
||||||
|
|
||||||
|
// The casino is not called Pete — the news app is Pete's, and this is somewhere
|
||||||
|
// you go. It has two names, and which one is over the door depends on the hour:
|
||||||
|
// the lights come on at six and the place turns into Casino Night Zone until
|
||||||
|
// dawn. Same tables, different room.
|
||||||
|
type room struct {
|
||||||
|
Slug string // drives the palette: html[data-room="…"]
|
||||||
|
Name string // what's on the sign
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
roomDay = room{Slug: "casinopolis", Name: "Casinopolis"}
|
||||||
|
roomNight = room{Slug: "casino-night", Name: "Casino Night Zone"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// roomAt picks the room for an hour of the day. Daylight is 6am to 6pm; the rest
|
||||||
|
// belongs to the neon. The browser re-runs this same rule against its own clock
|
||||||
|
// (games_layout.html), so a player in another timezone sees their own evening —
|
||||||
|
// this server-side pick only exists so the first paint isn't the wrong room.
|
||||||
|
func roomAt(hour int) room {
|
||||||
|
if hour >= 6 && hour < 18 {
|
||||||
|
return roomDay
|
||||||
|
}
|
||||||
|
return roomNight
|
||||||
|
}
|
||||||
|
|
||||||
|
// gamesPage is deliberately *not* pageData. The casino shares Pete's design
|
||||||
|
// language and nothing else — no channels, no weather, no sources, no push.
|
||||||
|
// Giving it its own page struct is what stops the news app's furniture drifting
|
||||||
|
// back in one convenient field at a time.
|
||||||
|
type gamesPage struct {
|
||||||
|
Room room
|
||||||
|
User *SessionUser
|
||||||
|
Cap int64
|
||||||
|
RakePct int
|
||||||
|
Soon []gameTeaser
|
||||||
|
Denominations []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
|
||||||
|
// Anyone who is signed in but carries a session from before the casino existed
|
||||||
|
// has no username in it, so they get sent through sign-in too — which mints one.
|
||||||
|
func (s *Server) requirePlayer(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if !s.gamesReady() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
u := s.auth.userFromRequest(r)
|
||||||
|
if u != nil && u.MatrixUser(s.cfg.Games.MatrixServer) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/auth/login?next="+r.URL.Path, http.StatusFound)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) gamesPage(r *http.Request) gamesPage {
|
||||||
|
return gamesPage{
|
||||||
|
Room: roomAt(time.Now().Hour()),
|
||||||
|
User: s.auth.userFromRequest(r), // requirePlayer ran first, so this is non-nil
|
||||||
|
Cap: storage.MaxChipsOnTable,
|
||||||
|
RakePct: int(blackjack.DefaultRules().RakePct * 100),
|
||||||
|
Soon: comingSoon,
|
||||||
|
Denominations: betDenominations,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLobby(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requirePlayer(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, "games", s.gamesPage(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBlackjack(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requirePlayer(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, "blackjack", s.gamesPage(r))
|
||||||
|
}
|
||||||
530
internal/web/games_play.go
Normal file
530
internal/web/games_play.go
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/games/blackjack"
|
||||||
|
"pete/internal/games/cards"
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The table, as a browser sees it.
|
||||||
|
//
|
||||||
|
// Everything here is server-authoritative. The browser sends intents — deal me
|
||||||
|
// in, hit, stand — and gets back a *view*: the cards it is entitled to see and
|
||||||
|
// nothing else. The shoe stays in game_live_hands, on this side of the wire.
|
||||||
|
// That is not belt-and-braces, it is the whole reason the engines are Go: a game
|
||||||
|
// with money on it cannot trust a client-reported result, and a client that
|
||||||
|
// holds the deck can read the next card.
|
||||||
|
//
|
||||||
|
// The stake leaves the player's stack *before* the hand is dealt, and comes back
|
||||||
|
// only through the engine's own payout. So a hand that crashes halfway costs the
|
||||||
|
// player their bet and nothing more, and a hand that Pete restarts through is
|
||||||
|
// still sitting there when they come back.
|
||||||
|
|
||||||
|
// gamesReady reports whether the casino can actually run: it needs sign-in (a
|
||||||
|
// player has to be someone) and a Matrix server name (that someone has to exist
|
||||||
|
// in gogobee's ledger).
|
||||||
|
func (s *Server) gamesReady() bool {
|
||||||
|
return s.cfg.Games.Enabled && s.auth != nil && s.cfg.Games.MatrixServer != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// player resolves the signed-in visitor to their Matrix id, or writes the
|
||||||
|
// failure. An empty id means a session from before games existed, which carries
|
||||||
|
// no username: sending them back through sign-in mints one.
|
||||||
|
func (s *Server) player(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||||
|
if !s.gamesReady() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
u := s.auth.userFromRequest(r)
|
||||||
|
if u == nil {
|
||||||
|
writeJSONStatus(w, http.StatusUnauthorized, map[string]string{"error": "sign in to play"})
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
mx := u.MatrixUser(s.cfg.Games.MatrixServer)
|
||||||
|
if mx == "" {
|
||||||
|
writeJSONStatus(w, http.StatusForbidden, map[string]string{
|
||||||
|
"error": "your session predates the casino — sign out and back in",
|
||||||
|
})
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return mx, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- what the browser is allowed to see -----------------------------------
|
||||||
|
|
||||||
|
// cardView is one card, pre-rendered. The browser draws faces, not logic: it
|
||||||
|
// gets the glyph and the colour rather than a rank it has to map itself.
|
||||||
|
type cardView struct {
|
||||||
|
Label string `json:"label"` // "A♠"
|
||||||
|
Rank string `json:"rank"` // "A"
|
||||||
|
Suit string `json:"suit"` // "♠"
|
||||||
|
Red bool `json:"red"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewCard(c cards.Card) cardView {
|
||||||
|
label := c.String()
|
||||||
|
// String() renders rank then a three-byte suit glyph, except for a card that
|
||||||
|
// isn't one ("??"), which has no glyph to split off.
|
||||||
|
if len(label) <= len("♠") {
|
||||||
|
return cardView{Label: label, Rank: label}
|
||||||
|
}
|
||||||
|
cut := len(label) - len("♠")
|
||||||
|
return cardView{Label: label, Rank: label[:cut], Suit: label[cut:], Red: c.Red()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handView is a blackjack hand as its player may see it. While they are still
|
||||||
|
// acting, the dealer's hole card is *absent* — not sent and flagged hidden, but
|
||||||
|
// genuinely not in the payload. A field the browser is told to ignore is a field
|
||||||
|
// somebody reads in devtools.
|
||||||
|
type handView struct {
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Bet int64 `json:"bet"`
|
||||||
|
Player []cardView `json:"player"`
|
||||||
|
Dealer []cardView `json:"dealer"`
|
||||||
|
Hole bool `json:"hole"` // true: the dealer has a face-down card
|
||||||
|
Total int `json:"total"`
|
||||||
|
Soft bool `json:"soft"`
|
||||||
|
DTotal int `json:"dealer_total"` // what the *shown* dealer cards add to
|
||||||
|
Outcome string `json:"outcome,omitempty"`
|
||||||
|
Payout int64 `json:"payout,omitempty"`
|
||||||
|
Rake int64 `json:"rake,omitempty"`
|
||||||
|
Net int64 `json:"net"`
|
||||||
|
Double bool `json:"can_double"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewHand(st blackjack.State) handView {
|
||||||
|
v := handView{
|
||||||
|
Phase: string(st.Phase),
|
||||||
|
Bet: st.Bet,
|
||||||
|
Outcome: string(st.Outcome),
|
||||||
|
Payout: st.Payout,
|
||||||
|
Rake: st.Rake,
|
||||||
|
Net: st.Net(),
|
||||||
|
Double: st.CanDouble(),
|
||||||
|
}
|
||||||
|
for _, c := range st.Player {
|
||||||
|
v.Player = append(v.Player, viewCard(c))
|
||||||
|
}
|
||||||
|
v.Total, v.Soft = blackjack.HandValue(st.Player)
|
||||||
|
|
||||||
|
dealer := st.Dealer
|
||||||
|
if st.Phase == blackjack.PhasePlayer && len(dealer) > 1 {
|
||||||
|
dealer = dealer[:1] // the hole card is the dealer's business until it isn't
|
||||||
|
v.Hole = true
|
||||||
|
}
|
||||||
|
for _, c := range dealer {
|
||||||
|
v.Dealer = append(v.Dealer, viewCard(c))
|
||||||
|
}
|
||||||
|
v.DTotal, _ = blackjack.HandValue(dealer)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventView is the dealing script. The engine emits one event per card off the
|
||||||
|
// shoe, in order, and the table animates them one at a time — which is why the
|
||||||
|
// events go over the wire at all rather than the browser diffing two states.
|
||||||
|
type eventView struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Card *cardView `json:"card,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewEvents renders the engine's events for the browser, dropping the cards it
|
||||||
|
// is not yet allowed to see: the dealer's second card is dealt face-down, and
|
||||||
|
// only the "reveal" event turns it over.
|
||||||
|
func viewEvents(evs []blackjack.Event, phase blackjack.Phase) []eventView {
|
||||||
|
out := make([]eventView, 0, len(evs))
|
||||||
|
dealerCards := 0
|
||||||
|
for _, e := range evs {
|
||||||
|
v := eventView{Kind: e.Kind, Text: e.Text}
|
||||||
|
if e.Card != nil {
|
||||||
|
c := viewCard(*e.Card)
|
||||||
|
v.Card = &c
|
||||||
|
}
|
||||||
|
if e.Kind == "dealer_card" {
|
||||||
|
dealerCards++
|
||||||
|
// The hole card, while the hand is still the player's to play: send
|
||||||
|
// the event so the table deals a face-down card, but not the face.
|
||||||
|
if dealerCards == 2 && phase == blackjack.PhasePlayer {
|
||||||
|
v.Card = nil
|
||||||
|
v.Kind = "dealer_hole"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableView is the whole page state: the money, and the hand if there is one.
|
||||||
|
type tableView struct {
|
||||||
|
Chips int64 `json:"chips"`
|
||||||
|
Pending int64 `json:"pending"` // buy-ins gogobee hasn't answered yet
|
||||||
|
Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale
|
||||||
|
Cap int64 `json:"cap"`
|
||||||
|
Hand *handView `json:"hand,omitempty"`
|
||||||
|
Events []eventView `json:"events,omitempty"` // only on a move, for the animation
|
||||||
|
Rake float64 `json:"rake_pct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// table reads the player's money and any hand in progress.
|
||||||
|
func (s *Server) table(user string) (tableView, error) {
|
||||||
|
st, err := storage.Chips(user)
|
||||||
|
if err != nil {
|
||||||
|
return tableView{}, err
|
||||||
|
}
|
||||||
|
v := tableView{
|
||||||
|
Chips: st.Chips,
|
||||||
|
Pending: st.Pending,
|
||||||
|
Euros: st.EuroBalance,
|
||||||
|
Cap: storage.MaxChipsOnTable,
|
||||||
|
Rake: blackjack.DefaultRules().RakePct,
|
||||||
|
}
|
||||||
|
live, err := storage.LoadLiveHand(user)
|
||||||
|
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return tableView{}, err
|
||||||
|
}
|
||||||
|
var hand blackjack.State
|
||||||
|
if err := json.Unmarshal(live.State, &hand); err != nil {
|
||||||
|
// A hand we can't read is a hand nobody can play. Rather than wedge the
|
||||||
|
// player out of the casino forever, drop it and tell them.
|
||||||
|
slog.Error("games: unreadable live hand, discarding", "user", user, "err", err)
|
||||||
|
_ = storage.ClearLiveHand(user)
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
hv := viewHand(hand)
|
||||||
|
v.Hand = &hv
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- handlers -------------------------------------------------------------
|
||||||
|
|
||||||
|
// handleTable is the page's poll: chips, euros, and whatever hand is on the felt.
|
||||||
|
func (s *Server) handleTable(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := s.table(user)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: table", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// amountBody is {amount: n} — chips, which are euros.
|
||||||
|
type amountBody struct {
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(r *http.Request, v any) error {
|
||||||
|
return json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBuyIn opens a buy-in. It creates no chips: it writes an escrow row, and
|
||||||
|
// gogobee decides — up to three seconds later — whether the player could afford
|
||||||
|
// it. The browser watches `pending` fall to zero to know how it went.
|
||||||
|
func (s *Server) handleBuyIn(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req amountBody
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e, err := storage.RequestBuyIn(user, req.Amount)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrBadAmount):
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "buy in for something more than nothing"})
|
||||||
|
return
|
||||||
|
case errors.Is(err, storage.ErrOverTableCap):
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{
|
||||||
|
"error": "that would put more than the table cap in front of you",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
slog.Error("games: buy-in", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.Touch(user)
|
||||||
|
slog.Info("games: buy-in requested", "user", user, "amount", e.Amount, "guid", e.GUID)
|
||||||
|
v, err := s.table(user)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: table after buy-in", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCashOut sends chips back across the border. The chips are destroyed
|
||||||
|
// here and now — see storage.RequestCashOut for why that has to happen before
|
||||||
|
// gogobee has said anything — so the response already shows an empty stack. An
|
||||||
|
// amount of zero or less means "all of it", which is what the button does.
|
||||||
|
func (s *Server) handleCashOut(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req amountBody
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// You cannot walk away from a hand you have chips riding on. The stake is
|
||||||
|
// already off the stack, so this isn't about the money — it's that a hand
|
||||||
|
// left half-played would settle into a session that no longer exists.
|
||||||
|
if _, err := storage.LoadLiveHand(user); err == nil {
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand first"})
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, storage.ErrNoLiveHand) {
|
||||||
|
slog.Error("games: cash-out live-hand check", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := req.Amount
|
||||||
|
if amount <= 0 {
|
||||||
|
st, err := storage.Chips(user)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: cash-out", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
amount = st.Chips
|
||||||
|
}
|
||||||
|
|
||||||
|
e, err := storage.RequestCashOut(user, amount)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrBadAmount), errors.Is(err, storage.ErrInsufficientChips):
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you don't have those chips"})
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
slog.Error("games: cash-out", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("games: cash-out requested", "user", user, "amount", e.Amount, "guid", e.GUID)
|
||||||
|
v, err := s.table(user)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: table after cash-out", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- blackjack ------------------------------------------------------------
|
||||||
|
|
||||||
|
// handleDeal takes the bet and deals. The order matters: chips are staked first,
|
||||||
|
// in the same statement that checks they exist, so two deals fired at once
|
||||||
|
// cannot bet the same chip. Only then is a hand dealt.
|
||||||
|
func (s *Server) handleDeal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Bet int64 `json:"bet"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.Stake(user, req.Bet); err != nil {
|
||||||
|
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: stake", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seed1, seed2 := newSeeds()
|
||||||
|
rng := rand.New(rand.NewPCG(seed1, seed2))
|
||||||
|
st, evs, err := blackjack.New(req.Bet, blackjack.DefaultRules(), rng)
|
||||||
|
if err != nil {
|
||||||
|
// The hand never happened, so the stake never should have left. Give it back.
|
||||||
|
_ = storage.Award(user, req.Bet)
|
||||||
|
slog.Error("games: deal", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.persist(w, user, st, evs, seed1, seed2, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMove plays one move of the hand in progress.
|
||||||
|
func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Move string `json:"move"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
live, err := storage.LoadLiveHand(user)
|
||||||
|
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no hand in progress"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: load hand", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var st blackjack.State
|
||||||
|
if err := json.Unmarshal(live.State, &st); err != nil {
|
||||||
|
slog.Error("games: unreadable live hand", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
move := blackjack.Move(req.Move)
|
||||||
|
|
||||||
|
// A double doubles the stake, so the extra chips have to be taken before the
|
||||||
|
// move is applied — and if they aren't there, the move simply isn't legal.
|
||||||
|
// Take them first: if the engine then refuses the move, they go straight back.
|
||||||
|
doubled := false
|
||||||
|
if move == blackjack.Double {
|
||||||
|
if !st.CanDouble() {
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only double on the first two cards"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := storage.Stake(user, st.Bet); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrInsufficientChips) {
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to double"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("games: stake double", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doubled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
next, evs, err := blackjack.ApplyMove(st, move)
|
||||||
|
if err != nil {
|
||||||
|
if doubled {
|
||||||
|
_ = storage.Award(user, st.Bet) // the move didn't happen; neither did the raise
|
||||||
|
}
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that move isn't legal here"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.persist(w, user, next, evs, live.Seed1, live.Seed2, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// persist writes the hand back and answers the browser. A finished hand pays
|
||||||
|
// out, goes in the audit log, and leaves the felt; an unfinished one is saved
|
||||||
|
// as it stands, so a redeploy mid-hand is survivable.
|
||||||
|
//
|
||||||
|
// fresh marks a hand that has just been dealt, which is the one case where the
|
||||||
|
// write may be refused: the primary key, not an earlier read, is what enforces
|
||||||
|
// one hand at a time. A Deal that loses that race gets its stake back.
|
||||||
|
func (s *Server) persist(w http.ResponseWriter, user string, st blackjack.State, evs []blackjack.Event, seed1, seed2 uint64, fresh bool) {
|
||||||
|
blob, err := json.Marshal(st)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: marshal hand", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seat the hand before doing anything else with it — even one that is already
|
||||||
|
// over, because a natural settles the instant it's dealt. The insert is what
|
||||||
|
// enforces one hand at a time, and it has to happen for *every* new hand: a
|
||||||
|
// natural dealt on top of a hand already in progress would otherwise settle,
|
||||||
|
// clear the felt, and take the other hand's stake down with it.
|
||||||
|
hand := storage.LiveHand{Game: "blackjack", State: blob, Seed1: seed1, Seed2: seed2}
|
||||||
|
save := storage.SaveLiveHand
|
||||||
|
if fresh {
|
||||||
|
save = storage.StartLiveHand
|
||||||
|
}
|
||||||
|
if err := save(user, hand); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrHandInProgress) {
|
||||||
|
// Somebody was already sitting here. This hand was never seated, so the
|
||||||
|
// chips it staked go back: the player is in one hand, not two.
|
||||||
|
_ = storage.Award(user, st.Bet)
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a hand"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("games: save hand", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.Phase == blackjack.PhaseDone {
|
||||||
|
// Pay first, then clear. If Pete dies between the two, the player has been
|
||||||
|
// paid and the worst case is a settled hand 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, st.Payout); err != nil {
|
||||||
|
slog.Error("games: award", "user", user, "payout", st.Payout, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := storage.RecordHand(storage.Hand{
|
||||||
|
MatrixUser: user, Game: "blackjack",
|
||||||
|
Bet: st.Bet, Payout: st.Payout, Rake: st.Rake,
|
||||||
|
Outcome: string(st.Outcome), Seed1: seed1, Seed2: seed2,
|
||||||
|
}); err != nil {
|
||||||
|
slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's hand
|
||||||
|
}
|
||||||
|
if err := storage.ClearLiveHand(user); err != nil {
|
||||||
|
slog.Error("games: clear hand", "user", user, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.Touch(user)
|
||||||
|
|
||||||
|
v, err := s.table(user)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: table", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A settled hand is gone from storage, so the table view has no hand to show —
|
||||||
|
// but the browser still needs the final cards to animate the reveal onto.
|
||||||
|
if st.Phase == blackjack.PhaseDone {
|
||||||
|
hv := viewHand(st)
|
||||||
|
v.Hand = &hv
|
||||||
|
}
|
||||||
|
v.Events = viewEvents(evs, st.Phase)
|
||||||
|
writeJSON(w, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSeeds mints the shoe's seed. It goes in the audit log, so a hand somebody
|
||||||
|
// disputes can be dealt again exactly as it fell.
|
||||||
|
func newSeeds() (uint64, uint64) {
|
||||||
|
return rand.Uint64(), uint64(time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSONStatus(w http.ResponseWriter, code int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
slog.Error("games: write response", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
275
internal/web/games_play_test.go
Normal file
275
internal/web/games_play_test.go
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/config"
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testPlayer = "@reala:parodia.dev"
|
||||||
|
|
||||||
|
// newCasino is a server with the tables open and one signed-in player. The
|
||||||
|
// Authenticator is built by hand rather than through OIDC discovery: sign-in is
|
||||||
|
// a network call, and none of what's under test is about the handshake.
|
||||||
|
func newCasino(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
s.cfg.Games = config.GamesConfig{Enabled: true, MatrixServer: "parodia.dev"}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// as returns a request carrying the signed session of the given username. An
|
||||||
|
// empty username is the pre-casino session: signed in, but nobody the economy
|
||||||
|
// can name.
|
||||||
|
func as(t *testing.T, s *Server, username, method, path 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)
|
||||||
|
payload, _ := json.Marshal(SessionUser{
|
||||||
|
Sub: "sub-1", Username: username, Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// call runs one request against a handler and decodes the table view.
|
||||||
|
func call(t *testing.T, s *Server, h http.HandlerFunc, r *http.Request) (tableView, int) {
|
||||||
|
t.Helper()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h(w, r)
|
||||||
|
var v tableView
|
||||||
|
if w.Code == 200 {
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
|
||||||
|
t.Fatalf("decode table: %v (body %q)", err, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v, w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// fund puts chips in front of the player the way the border really does it.
|
||||||
|
func fund(t *testing.T, chips int64) {
|
||||||
|
t.Helper()
|
||||||
|
e, err := storage.RequestBuyIn(testPlayer, 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 chipsNow(t *testing.T) int64 {
|
||||||
|
t.Helper()
|
||||||
|
st, err := storage.Chips(testPlayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return st.Chips
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDealTakesTheStakeAndHidesTheHoleCard is the one thing the table cannot get
|
||||||
|
// wrong: the bet leaves the stack, and the dealer's second card does not leave
|
||||||
|
// the server.
|
||||||
|
func TestDealTakesTheStakeAndHidesTheHoleCard(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
v, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal", map[string]int64{"bet": 100}))
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("deal = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if v.Chips != 900 {
|
||||||
|
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
|
||||||
|
}
|
||||||
|
if v.Hand == nil {
|
||||||
|
t.Fatal("deal returned no hand")
|
||||||
|
}
|
||||||
|
if len(v.Hand.Player) != 2 {
|
||||||
|
t.Fatalf("player was dealt %d cards, want 2", len(v.Hand.Player))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A natural settles on the spot and legitimately shows both dealer cards.
|
||||||
|
if v.Hand.Phase == "done" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !v.Hand.Hole || len(v.Hand.Dealer) != 1 {
|
||||||
|
t.Fatalf("dealer shows %d cards (hole=%v), want 1 with the hole card held back",
|
||||||
|
len(v.Hand.Dealer), v.Hand.Hole)
|
||||||
|
}
|
||||||
|
// And it isn't smuggled out in the dealing script either.
|
||||||
|
for _, e := range v.Events {
|
||||||
|
if e.Kind == "dealer_hole" && e.Card != nil {
|
||||||
|
t.Fatal("the hole card's face went to the browser in the events")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandSettlesIntoTheChipStack plays a hand to the end and checks the chips
|
||||||
|
// moved by exactly what the engine said they did — and that the hand left the
|
||||||
|
// felt and landed in the audit log.
|
||||||
|
func TestHandSettlesIntoTheChipStack(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
|
||||||
|
for v.Hand != nil && v.Hand.Phase == "player" {
|
||||||
|
v, _ = call(t, s, s.handleMove, as(t, s, "reala", "POST", "/move", map[string]string{"move": "stand"}))
|
||||||
|
}
|
||||||
|
if v.Hand == nil || v.Hand.Phase != "done" {
|
||||||
|
t.Fatalf("hand didn't finish: %+v", v.Hand)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1000, minus the stake, plus whatever came back.
|
||||||
|
want := int64(1000) - 100 + v.Hand.Payout
|
||||||
|
if got := chipsNow(t); got != want {
|
||||||
|
t.Fatalf("chips = %d, want %d (payout %d, outcome %q)", got, want, v.Hand.Payout, v.Hand.Outcome)
|
||||||
|
}
|
||||||
|
if _, err := storage.LoadLiveHand(testPlayer); err == nil {
|
||||||
|
t.Fatal("a settled hand is still sitting on the felt")
|
||||||
|
}
|
||||||
|
if v.Hand.Outcome == "" {
|
||||||
|
t.Fatal("a finished hand with no outcome")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rake only ever comes out of winnings.
|
||||||
|
if v.Hand.Outcome == "push" && v.Hand.Payout != 100 {
|
||||||
|
t.Fatalf("a push paid %d, want the 100 back untouched", v.Hand.Payout)
|
||||||
|
}
|
||||||
|
if v.Hand.Net < 0 && v.Hand.Rake != 0 {
|
||||||
|
t.Fatalf("a losing hand was raked %d", v.Hand.Rake)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOneHandAtATime is the double-click: a second Deal must not overwrite the
|
||||||
|
// hand the first one is paying for, and must not keep the chips it staked.
|
||||||
|
func TestOneHandAtATime(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
first, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
|
||||||
|
if first.Hand != nil && first.Hand.Phase == "done" {
|
||||||
|
t.Skip("dealt a natural; there is no live hand to protect")
|
||||||
|
}
|
||||||
|
before := chipsNow(t)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleDeal(w, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("second deal = %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
if got := chipsNow(t); got != before {
|
||||||
|
t.Fatalf("the refused deal cost the player %d chips", before-got)
|
||||||
|
}
|
||||||
|
// The original hand is untouched.
|
||||||
|
live, err := storage.LoadLiveHand(testPlayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the live hand went missing: %v", err)
|
||||||
|
}
|
||||||
|
var st struct {
|
||||||
|
Player []struct{} `json:"player"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(live.State, &st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(st.Player) != len(first.Hand.Player) {
|
||||||
|
t.Fatal("the refused deal replaced the hand in progress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCannotCashOutMidHand — the stake is on the table, so the session it would
|
||||||
|
// settle into cannot be closed underneath it.
|
||||||
|
func TestCannotCashOutMidHand(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
|
||||||
|
if v.Hand != nil && v.Hand.Phase == "done" {
|
||||||
|
t.Skip("dealt a natural; nothing is in progress")
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleCashOut(w, as(t, s, "reala", "POST", "/cashout", map[string]int64{"amount": 0}))
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("cash-out mid-hand = %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
if got := chipsNow(t); got != 900 {
|
||||||
|
t.Fatalf("the refused cash-out moved chips: %d, want 900", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDoubleWithoutTheChipsChangesNothing: a double the player can't cover is
|
||||||
|
// refused, and refusing it must not quietly pocket the raise.
|
||||||
|
func TestDoubleWithoutTheChipsChangesNothing(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 100)
|
||||||
|
|
||||||
|
v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
|
||||||
|
if v.Hand == nil || v.Hand.Phase != "player" {
|
||||||
|
t.Skip("no live hand to double on")
|
||||||
|
}
|
||||||
|
if chipsNow(t) != 0 {
|
||||||
|
t.Fatal("test wants a player with nothing left to raise with")
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleMove(w, as(t, s, "reala", "POST", "/move", map[string]string{"move": "double"}))
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("broke double = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
if got := chipsNow(t); got != 0 {
|
||||||
|
t.Fatalf("chips = %d after a refused double, want 0", got)
|
||||||
|
}
|
||||||
|
// And the hand is still there, still doubleable once they can afford it.
|
||||||
|
after, code := call(t, s, s.handleTable, as(t, s, "reala", "GET", "/table", nil))
|
||||||
|
if code != 200 || after.Hand == nil || !after.Hand.Double {
|
||||||
|
t.Fatalf("the hand should be intact and still doubleable: %d %+v", code, after.Hand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTableNeedsAPlayerTheEconomyCanName. Anonymous is a 401. A session minted
|
||||||
|
// before the casino existed carries no username, so it can't be mapped to a
|
||||||
|
// Matrix id — that's a 403, and the fix is to sign in again.
|
||||||
|
func TestTableNeedsAPlayerTheEconomyCanName(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleTable(w, httptest.NewRequest("GET", "/api/games/table", nil))
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("anonymous = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
s.handleTable(w, as(t, s, "", "GET", "/api/games/table", nil))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("session with no username = %d, want 403", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCasinoIsShutWithoutAServerName. No Matrix server name means no player can
|
||||||
|
// be named to gogobee, so the tables 404 rather than dealing hands whose money
|
||||||
|
// has nowhere to go.
|
||||||
|
func TestCasinoIsShutWithoutAServerName(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
s.cfg.Games.MatrixServer = ""
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleTable(w, as(t, s, "reala", "GET", "/api/games/table", nil))
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("table with no server name = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
248
internal/web/games_test.go
Normal file
248
internal/web/games_test.go
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pollEscrow runs one gogobee poll.
|
||||||
|
func pollEscrow(t *testing.T, s *Server, token string) ([]storage.Escrow, int) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest("GET", "/api/games/escrow/pending", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleEscrowPending(w, req)
|
||||||
|
if w.Code != 200 {
|
||||||
|
return nil, w.Code
|
||||||
|
}
|
||||||
|
var out []storage.Escrow
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode pending: %v (body %q)", err, w.Body.String())
|
||||||
|
}
|
||||||
|
return out, w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimEscrow(t *testing.T, s *Server, token, guid string) (storage.Escrow, int) {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(escrowGUID{GUID: guid})
|
||||||
|
req := httptest.NewRequest("POST", "/api/games/escrow/claim", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleEscrowClaim(w, req)
|
||||||
|
var e storage.Escrow
|
||||||
|
if w.Code == 200 {
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &e)
|
||||||
|
}
|
||||||
|
return e, w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func settleEscrow(t *testing.T, s *Server, token string, v escrowVerdict) (storage.Escrow, int) {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(v)
|
||||||
|
req := httptest.NewRequest("POST", "/api/games/escrow/settled", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleEscrowSettled(w, req)
|
||||||
|
var e storage.Escrow
|
||||||
|
if w.Code == 200 {
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &e)
|
||||||
|
}
|
||||||
|
return e, w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscrowBuyInRoundTrip walks the happy path a player actually takes: ask for
|
||||||
|
// chips, watch gogobee pick the row up, and have the chips appear only once the
|
||||||
|
// euros are confirmed gone.
|
||||||
|
func TestEscrowBuyInRoundTrip(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
const user = "@reala:parodia.dev"
|
||||||
|
|
||||||
|
req, err := storage.RequestBuyIn(user, 500)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No chips yet. The euros are still gogobee's.
|
||||||
|
if st, _ := storage.Chips(user); st.Chips != 0 || st.Pending != 500 {
|
||||||
|
t.Fatalf("before settle: chips=%d pending=%d, want 0/500", st.Chips, st.Pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, code := pollEscrow(t, s, "tok")
|
||||||
|
if code != 200 || len(pending) != 1 || pending[0].GUID != req.GUID {
|
||||||
|
t.Fatalf("poll = %d %+v, want the one buy-in", code, pending)
|
||||||
|
}
|
||||||
|
if pending[0].MatrixUser != user || pending[0].Kind != storage.KindBuyIn || pending[0].Amount != 500 {
|
||||||
|
t.Fatalf("poll row = %+v, want the row we opened", pending[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
claimed, code := claimEscrow(t, s, "tok", req.GUID)
|
||||||
|
if code != 200 || claimed.State != storage.EscrowClaimed {
|
||||||
|
t.Fatalf("claim = %d state=%q, want 200/claimed", code, claimed.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: req.GUID, OK: true, BalanceAfter: 1500})
|
||||||
|
if code != 200 || e.State != storage.EscrowFunded {
|
||||||
|
t.Fatalf("settle = %d state=%q, want 200/funded", code, e.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
st, _ := storage.Chips(user)
|
||||||
|
if st.Chips != 500 || st.Pending != 0 {
|
||||||
|
t.Fatalf("after settle: chips=%d pending=%d, want 500/0", st.Chips, st.Pending)
|
||||||
|
}
|
||||||
|
if st.EuroBalance != 1500 {
|
||||||
|
t.Fatalf("euro balance = %v, want the 1500 gogobee reported", st.EuroBalance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settled rows are done. A later poll must not offer it again, or gogobee
|
||||||
|
// would debit the player a second time.
|
||||||
|
if pending, _ := pollEscrow(t, s, "tok"); len(pending) != 0 {
|
||||||
|
t.Fatalf("settled row still pending: %+v", pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscrowVerdictReplayedCreatesChipsOnce is the property the whole guid
|
||||||
|
// scheme exists for. gogobee's push queue retries: the same verdict lands twice,
|
||||||
|
// and only the first one may create chips.
|
||||||
|
func TestEscrowVerdictReplayedCreatesChipsOnce(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
const user = "@replay:parodia.dev"
|
||||||
|
|
||||||
|
req, err := storage.RequestBuyIn(user, 200)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, code := claimEscrow(t, s, "tok", req.GUID); code != 200 {
|
||||||
|
t.Fatalf("claim = %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: req.GUID, OK: true, BalanceAfter: 800})
|
||||||
|
if code != 200 || e.State != storage.EscrowFunded {
|
||||||
|
t.Fatalf("settle %d = %d state=%q", i, code, e.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if st, _ := storage.Chips(user); st.Chips != 200 {
|
||||||
|
t.Fatalf("chips = %d after three deliveries of one verdict, want 200", st.Chips)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscrowRejectedBuyInMovesNothing — the player couldn't cover it. gogobee
|
||||||
|
// took no euros, so Pete must create no chips, and the pending amount has to
|
||||||
|
// clear or it would eat into the table cap forever.
|
||||||
|
func TestEscrowRejectedBuyInMovesNothing(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
const user = "@broke:parodia.dev"
|
||||||
|
|
||||||
|
req, err := storage.RequestBuyIn(user, 9000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, code := claimEscrow(t, s, "tok", req.GUID); code != 200 {
|
||||||
|
t.Fatalf("claim = %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
e, code := settleEscrow(t, s, "tok",
|
||||||
|
escrowVerdict{GUID: req.GUID, OK: false, Reason: "insufficient_funds", BalanceAfter: 12})
|
||||||
|
if code != 200 || e.State != storage.EscrowRejected {
|
||||||
|
t.Fatalf("settle = %d state=%q, want 200/rejected", code, e.State)
|
||||||
|
}
|
||||||
|
if e.Reason != "insufficient_funds" {
|
||||||
|
t.Fatalf("reason = %q, want it carried back for the player to read", e.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
st, _ := storage.Chips(user)
|
||||||
|
if st.Chips != 0 || st.Pending != 0 {
|
||||||
|
t.Fatalf("after rejection: chips=%d pending=%d, want 0/0", st.Chips, st.Pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscrowCashOutFailureReturnsChips — gogobee couldn't pay. The chips were
|
||||||
|
// destroyed the moment the cash-out opened, so if we simply mark it done the
|
||||||
|
// player's money is gone from both sides. It has to come back.
|
||||||
|
func TestEscrowCashOutFailureReturnsChips(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
const user = "@unlucky:parodia.dev"
|
||||||
|
|
||||||
|
buy, err := storage.RequestBuyIn(user, 300)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: buy.GUID, OK: true}); code != 200 {
|
||||||
|
t.Fatalf("fund = %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := storage.RequestCashOut(user, 300)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st, _ := storage.Chips(user); st.Chips != 0 {
|
||||||
|
t.Fatalf("chips during cash-out = %d, want 0 — they must not be bettable in flight", st.Chips)
|
||||||
|
}
|
||||||
|
|
||||||
|
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: out.GUID, OK: false, Reason: "ledger_error"})
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("settle = %d", code)
|
||||||
|
}
|
||||||
|
if e.State != storage.EscrowFunded {
|
||||||
|
t.Fatalf("state = %q, want funded — a failed cash-out gives the chips back", e.State)
|
||||||
|
}
|
||||||
|
if st, _ := storage.Chips(user); st.Chips != 300 {
|
||||||
|
t.Fatalf("chips = %d after a failed cash-out, want the 300 back", st.Chips)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscrowUnknownGUIDIsA400 — a verdict for a row Pete has never heard of.
|
||||||
|
// gogobee has already moved real euros for it, and no amount of retrying will
|
||||||
|
// conjure the row, so the answer is the one that parks the row in gogobee's
|
||||||
|
// queue for a human rather than the one that retries forever.
|
||||||
|
func TestEscrowUnknownGUIDIsA400(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
if _, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: "ghost", OK: true}); code != 400 {
|
||||||
|
t.Fatalf("settle unknown guid = %d, want 400", code)
|
||||||
|
}
|
||||||
|
if _, code := claimEscrow(t, s, "tok", "ghost"); code != 404 {
|
||||||
|
t.Fatalf("claim unknown guid = %d, want 404", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscrowNeedsTheBearerToken. These endpoints move money and are reachable on
|
||||||
|
// the same mux as the public news site.
|
||||||
|
func TestEscrowNeedsTheBearerToken(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
req, err := storage.RequestBuyIn("@x:parodia.dev", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, code := pollEscrow(t, s, "wrong"); code != 401 {
|
||||||
|
t.Fatalf("poll with a bad token = %d, want 401", code)
|
||||||
|
}
|
||||||
|
if _, code := claimEscrow(t, s, "", req.GUID); code != 401 {
|
||||||
|
t.Fatalf("claim with no token = %d, want 401", code)
|
||||||
|
}
|
||||||
|
if _, code := settleEscrow(t, s, "wrong", escrowVerdict{GUID: req.GUID, OK: true}); code != 401 {
|
||||||
|
t.Fatalf("settle with a bad token = %d, want 401", code)
|
||||||
|
}
|
||||||
|
if st, _ := storage.Chips("@x:parodia.dev"); st.Chips != 0 {
|
||||||
|
t.Fatal("an unauthenticated settle created chips")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscrowEmptyPollIsAnArrayNotNull. gogobee decodes into []Escrow; a bare
|
||||||
|
// `null` body decodes fine in Go but is a trap for anything else that ever reads
|
||||||
|
// this, and an empty poll is the overwhelmingly common case.
|
||||||
|
func TestEscrowEmptyPollIsAnArrayNotNull(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
req := httptest.NewRequest("GET", "/api/games/escrow/pending", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer tok")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleEscrowPending(w, req)
|
||||||
|
if got := w.Body.String(); got != "[]\n" {
|
||||||
|
t.Fatalf("empty poll body = %q, want %q", got, "[]\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,13 @@ type channelPage struct {
|
|||||||
PrevURL string
|
PrevURL string
|
||||||
NextURL string
|
NextURL string
|
||||||
Total int
|
Total int
|
||||||
|
|
||||||
|
// Roster is the live adventurer board, populated on the adventure section
|
||||||
|
// only and only on page 1 — it is present tense, and page 2 of an archive is
|
||||||
|
// not where anyone looks for what's happening right now.
|
||||||
|
Roster []RosterView
|
||||||
|
RosterStale bool
|
||||||
|
ShowRoster bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type indexPage struct {
|
type indexPage struct {
|
||||||
@@ -343,6 +350,10 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
|
|||||||
NextURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page+1),
|
NextURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page+1),
|
||||||
Total: total,
|
Total: total,
|
||||||
}
|
}
|
||||||
|
if ch.Slug == "adventure" && page == 1 {
|
||||||
|
data.Roster, data.RosterStale, _ = s.roster()
|
||||||
|
data.ShowRoster = true
|
||||||
|
}
|
||||||
s.render(w, "channel", data)
|
s.render(w, "channel", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
183
internal/web/roster.go
Normal file
183
internal/web/roster.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The live adventurer board.
|
||||||
|
//
|
||||||
|
// Everything else Pete publishes about the realm is an *accomplishment* — a
|
||||||
|
// death, a clear, a milestone. Those are clippings: they read as archive the
|
||||||
|
// instant they land, however fast we deliver them, and no refresh interval fixes
|
||||||
|
// that. The board is the opposite kind of thing. It is state that is currently
|
||||||
|
// true, so it is worth looking at *now*, and it goes stale on its own if we stop
|
||||||
|
// hearing from gogobee.
|
||||||
|
//
|
||||||
|
// Direction of travel is gogobee → Pete, like every other adventure payload:
|
||||||
|
// Pete has no route back into the game box's network and we are not opening one.
|
||||||
|
// gogobee pushes the whole board; we replace ours with it.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// rosterStaleAfter — how old a snapshot can get before the board stops
|
||||||
|
// claiming to be live. gogobee pushes every couple of minutes, so this is
|
||||||
|
// several missed pushes, not one unlucky one.
|
||||||
|
//
|
||||||
|
// This matters more than it looks: if gogobee dies mid-expedition, the last
|
||||||
|
// snapshot says "Josie is in holymachina" and would say so forever. A board
|
||||||
|
// that lies confidently is worse than one that admits it lost the wire —
|
||||||
|
// especially once players can act on it (see the target-list note below).
|
||||||
|
rosterStaleAfter = 12 * time.Minute
|
||||||
|
|
||||||
|
// rosterMaxEntries — hard cap on a snapshot. A bounded realm; this only
|
||||||
|
// exists so a malformed or hostile push can't spool unbounded rows.
|
||||||
|
rosterMaxEntries = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
// rosterPush is the payload gogobee POSTs to /api/ingest/roster.
|
||||||
|
type rosterPush struct {
|
||||||
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
|
Adventurers []storage.RosterEntry `json:"adventurers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RosterView is one row as the page renders it.
|
||||||
|
type RosterView struct {
|
||||||
|
Name string
|
||||||
|
Level int
|
||||||
|
ClassRace string
|
||||||
|
OnRun bool
|
||||||
|
Zone string
|
||||||
|
Region string
|
||||||
|
Where string // "holymachina, day 3" | "in town"
|
||||||
|
Idle string // "quiet for 2 days" — only when idle
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRosterIngest replaces the board with gogobee's latest snapshot.
|
||||||
|
func (s *Server) handleRosterIngest(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
|
||||||
|
}
|
||||||
|
|
||||||
|
var push rosterPush
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&push); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(push.Adventurers) > rosterMaxEntries {
|
||||||
|
http.Error(w, "roster too large", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A snapshot with no timestamp can't be aged, so it could never go stale —
|
||||||
|
// it would sit on the page claiming to be live forever. Treat it as now.
|
||||||
|
if push.SnapshotAt <= 0 {
|
||||||
|
push.SnapshotAt = time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never trust the channel with a name. Same rule as factGuard: gogobee
|
||||||
|
// pre-sanitizes to character names, and Pete checks anyway, because this is
|
||||||
|
// the last thing standing between the payload and a public page.
|
||||||
|
for i, e := range push.Adventurers {
|
||||||
|
if e.Token == "" || e.Name == "" {
|
||||||
|
http.Error(w, "each adventurer needs token and name", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e.Status != "expedition" && e.Status != "idle" {
|
||||||
|
http.Error(w, fmt.Sprintf("adventurer %d: unknown status %q", i, e.Status), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.ReplaceRoster(push.Adventurers, push.SnapshotAt); err != nil {
|
||||||
|
slog.Error("roster ingest: replace failed", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRosterAPI serves the board as JSON for the page's own re-poll, so an
|
||||||
|
// open tab goes live without a reload. Public — same exposure as the rendered
|
||||||
|
// page, no more.
|
||||||
|
func (s *Server) handleRosterAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.adv.Enabled {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
views, stale, _ := s.roster()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"stale": stale,
|
||||||
|
"adventurers": views,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// roster loads the board and decides whether it is still live. A stale board is
|
||||||
|
// still returned — the page shows it dimmed and says so, rather than blanking,
|
||||||
|
// because "here is who was out when we lost contact" beats an empty page.
|
||||||
|
func (s *Server) roster() (views []RosterView, stale bool, snapshotAt int64) {
|
||||||
|
entries, snapshotAt, err := storage.LoadRoster()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("roster: load failed", "err", err)
|
||||||
|
return nil, true, 0
|
||||||
|
}
|
||||||
|
stale = snapshotAt == 0 || time.Since(time.Unix(snapshotAt, 0)) > rosterStaleAfter
|
||||||
|
for _, e := range entries {
|
||||||
|
views = append(views, toRosterView(e))
|
||||||
|
}
|
||||||
|
return views, stale, snapshotAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func toRosterView(e storage.RosterEntry) RosterView {
|
||||||
|
v := RosterView{
|
||||||
|
Name: e.Name,
|
||||||
|
Level: e.Level,
|
||||||
|
ClassRace: e.ClassRace,
|
||||||
|
OnRun: e.Status == "expedition",
|
||||||
|
Zone: e.Zone,
|
||||||
|
Region: e.Region,
|
||||||
|
}
|
||||||
|
if v.OnRun {
|
||||||
|
where := e.Zone
|
||||||
|
if e.Region != "" {
|
||||||
|
where = fmt.Sprintf("%s, %s", e.Zone, e.Region)
|
||||||
|
}
|
||||||
|
if e.Day > 0 {
|
||||||
|
where = fmt.Sprintf("%s — day %d", where, e.Day)
|
||||||
|
}
|
||||||
|
v.Where = where
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
v.Where = "in town"
|
||||||
|
v.Idle = humanIdle(e.IdleHours)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// humanIdle renders the idle clock the way a person would say it. Deliberately
|
||||||
|
// coarse: this is colour on a board, not the boredom ticker's actual threshold.
|
||||||
|
func humanIdle(hours int) string {
|
||||||
|
switch {
|
||||||
|
case hours <= 0:
|
||||||
|
return ""
|
||||||
|
case hours < 2:
|
||||||
|
return "just got back"
|
||||||
|
case hours < 24:
|
||||||
|
return fmt.Sprintf("quiet for %dh", hours)
|
||||||
|
case hours < 48:
|
||||||
|
return "quiet for a day"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("quiet for %d days", hours/24)
|
||||||
|
}
|
||||||
|
}
|
||||||
193
internal/web/roster_test.go
Normal file
193
internal/web/roster_test.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func postRoster(t *testing.T, s *Server, token string, push rosterPush) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(push)
|
||||||
|
req := httptest.NewRequest("POST", "/api/ingest/roster", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleRosterIngest(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func entry(token, name, status, zone string) storage.RosterEntry {
|
||||||
|
return storage.RosterEntry{Token: token, Name: name, Level: 5, ClassRace: "elf ranger", Status: status, Zone: zone}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRosterReplacesNeverMerges is the whole contract of the board. gogobee
|
||||||
|
// sends the *complete* set of adventurers it is willing to show; anyone missing
|
||||||
|
// from a later snapshot has left the board — they deleted their character, or
|
||||||
|
// (the case that actually matters) they just ran `!news optout` and must
|
||||||
|
// disappear from a public page. An upsert would leave them standing there
|
||||||
|
// forever, which is precisely the exposure the opt-out exists to prevent.
|
||||||
|
func TestRosterReplacesNeverMerges(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
|
||||||
|
entry("t1", "Josie", "expedition", "holymachina"),
|
||||||
|
entry("t2", "Quack", "idle", ""),
|
||||||
|
}}); w.Code != 200 {
|
||||||
|
t.Fatalf("first push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quack opts out; gogobee stops including her.
|
||||||
|
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now + 60, Adventurers: []storage.RosterEntry{
|
||||||
|
entry("t1", "Josie", "expedition", "holymachina"),
|
||||||
|
}}); w.Code != 200 {
|
||||||
|
t.Fatalf("second push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
views, stale, _ := s.roster()
|
||||||
|
if stale {
|
||||||
|
t.Error("fresh snapshot read as stale")
|
||||||
|
}
|
||||||
|
if len(views) != 1 {
|
||||||
|
t.Fatalf("board has %d rows, want 1 — a dropped adventurer survived the swap", len(views))
|
||||||
|
}
|
||||||
|
if views[0].Name != "Josie" {
|
||||||
|
t.Errorf("board kept %q, want Josie", views[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRosterGoesStale: if gogobee dies mid-expedition, the last snapshot says
|
||||||
|
// "Josie is in holymachina" and would say so forever. The board has to admit it
|
||||||
|
// lost the wire rather than keep asserting a stale fact as live.
|
||||||
|
func TestRosterGoesStale(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
old := time.Now().Add(-2 * rosterStaleAfter).Unix()
|
||||||
|
|
||||||
|
postRoster(t, s, "tok", rosterPush{SnapshotAt: old, Adventurers: []storage.RosterEntry{
|
||||||
|
entry("t1", "Josie", "expedition", "holymachina"),
|
||||||
|
}})
|
||||||
|
|
||||||
|
views, stale, _ := s.roster()
|
||||||
|
if !stale {
|
||||||
|
t.Error("snapshot older than rosterStaleAfter still reported live")
|
||||||
|
}
|
||||||
|
// Stale, but still shown: "who was out when we lost contact" beats a blank page.
|
||||||
|
if len(views) != 1 {
|
||||||
|
t.Errorf("stale board dropped its rows (%d), want them kept and dimmed", len(views))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRosterEmptySnapshotIsNotStale: a realm where nobody is playing is a
|
||||||
|
// legitimate state and must not be confused with gogobee having gone away. This
|
||||||
|
// is why the snapshot time lives in its own row instead of MAX() over entries.
|
||||||
|
func TestRosterEmptySnapshotIsNotStale(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
|
||||||
|
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: time.Now().Unix()}); w.Code != 200 {
|
||||||
|
t.Fatalf("empty push = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
views, stale, _ := s.roster()
|
||||||
|
if len(views) != 0 {
|
||||||
|
t.Errorf("empty snapshot produced %d rows", len(views))
|
||||||
|
}
|
||||||
|
if stale {
|
||||||
|
t.Error("a quiet realm read as a dead wire — the two must not collapse")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRosterIngestRejects(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
if w := postRoster(t, s, "wrong", rosterPush{SnapshotAt: now}); w.Code != 401 {
|
||||||
|
t.Errorf("bad bearer = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
// An unknown status would render as neither "out there" nor "in town".
|
||||||
|
bad := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
|
||||||
|
{Token: "t1", Name: "Josie", Status: "vibing"},
|
||||||
|
}}
|
||||||
|
if w := postRoster(t, s, "tok", bad); w.Code != 400 {
|
||||||
|
t.Errorf("unknown status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
// A nameless row would render an empty slot on a public page.
|
||||||
|
nameless := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
|
||||||
|
{Token: "t1", Status: "idle"},
|
||||||
|
}}
|
||||||
|
if w := postRoster(t, s, "tok", nameless); w.Code != 400 {
|
||||||
|
t.Errorf("nameless adventurer = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDepartureRenders guards the cross-repo contract: gogobee emits this
|
||||||
|
// event_type for a bored adventurer, and an event_type Pete doesn't know is a
|
||||||
|
// 400 that retries and parks the bulletin forever.
|
||||||
|
func TestDepartureRenders(t *testing.T) {
|
||||||
|
headline, lede, ok := renderAdventure(AdvFact{
|
||||||
|
EventType: "departure",
|
||||||
|
Subject: "Camcast",
|
||||||
|
Zone: "crypt_valdris",
|
||||||
|
Level: 1,
|
||||||
|
Actors: []string{"Camcast"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("departure event_type unknown to Pete — gogobee's bulletin would 400 and park")
|
||||||
|
}
|
||||||
|
if headline == "" || lede == "" {
|
||||||
|
t.Error("departure rendered empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRosterViewWhere(t *testing.T) {
|
||||||
|
out := toRosterView(storage.RosterEntry{
|
||||||
|
Name: "Josie", Status: "expedition", Zone: "holymachina", Region: "the deep", Day: 3,
|
||||||
|
})
|
||||||
|
if !out.OnRun || out.Where != "holymachina, the deep — day 3" {
|
||||||
|
t.Errorf("expedition row = %+v", out)
|
||||||
|
}
|
||||||
|
in := toRosterView(storage.RosterEntry{Name: "Quack", Status: "idle", IdleHours: 50})
|
||||||
|
if in.OnRun || in.Where != "in town" || in.Idle != "quiet for 2 days" {
|
||||||
|
t.Errorf("idle row = %+v", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdventurePageRendersBoard drives the real page through the real template.
|
||||||
|
// The handler tests above prove the data is right; this proves it reaches a
|
||||||
|
// reader. A template slip (bad field, unclosed block) doesn't fail a build — it
|
||||||
|
// 500s the whole adventure section at request time, board and clippings alike.
|
||||||
|
func TestAdventurePageRendersBoard(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
|
||||||
|
postRoster(t, s, "tok", rosterPush{
|
||||||
|
SnapshotAt: time.Now().Unix(),
|
||||||
|
Adventurers: []storage.RosterEntry{
|
||||||
|
{Token: "t1", Name: "Josie", Level: 14, ClassRace: "human cleric",
|
||||||
|
Status: "expedition", Zone: "holymachina", Day: 3},
|
||||||
|
{Token: "t2", Name: "Quack", Level: 4, ClassRace: "elf ranger",
|
||||||
|
Status: "idle", IdleHours: 50},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/adventure", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleChannel(w, req, Channel{Slug: "adventure", Title: "Adventure", Theme: "adventure"})
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("GET /adventure = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"Out there right now", // the board's headline
|
||||||
|
"Josie", "holymachina", "day 3", // live zone, shown while she's still in it
|
||||||
|
"Quack", "in town", "quiet for 2 days",
|
||||||
|
"/api/roster", // the client re-poll, so an open tab stays true
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("adventure page missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -77,22 +78,39 @@ type Server struct {
|
|||||||
salt [16]byte
|
salt [16]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds the server. Templates are parsed once at startup. Each page
|
// New builds the server. Templates are parsed once at startup. Each page gets
|
||||||
// gets its own template set sharing layout.html + _card.html, which avoids
|
// its own template set sharing a layout, which avoids `main` define collisions
|
||||||
// `main` define collisions between pages.
|
// between pages.
|
||||||
|
//
|
||||||
|
// There are two layouts, and they are not the same site. The news pages hang off
|
||||||
|
// layout.html: Pete's face, the channel nav, search, the reader, the weather
|
||||||
|
// canvas. The casino hangs off games_layout.html, which shares the *design* —
|
||||||
|
// the palette vars, the fonts, the fat rounded cards — and none of the
|
||||||
|
// furniture. games.parodia.dev is its own place, not a news page with a felt on
|
||||||
|
// it, so it doesn't inherit a single control it has no use for.
|
||||||
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) {
|
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) {
|
||||||
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}
|
sets := []struct {
|
||||||
tpls := make(map[string]*template.Template, len(pages))
|
layout string
|
||||||
for _, p := range pages {
|
shared []string
|
||||||
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
|
pages []string
|
||||||
"templates/layout.html",
|
}{
|
||||||
"templates/_card.html",
|
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
|
||||||
"templates/"+p+".html",
|
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack"}},
|
||||||
)
|
}
|
||||||
if err != nil {
|
tpls := make(map[string]*template.Template)
|
||||||
return nil, err
|
for _, set := range sets {
|
||||||
|
for _, p := range set.pages {
|
||||||
|
files := []string{"templates/" + set.layout + ".html"}
|
||||||
|
for _, s := range set.shared {
|
||||||
|
files = append(files, "templates/"+s+".html")
|
||||||
|
}
|
||||||
|
files = append(files, "templates/"+p+".html")
|
||||||
|
t, err := template.New("").Funcs(funcs).ParseFS(templateFS, files...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tpls[p] = t
|
||||||
}
|
}
|
||||||
tpls[p] = t
|
|
||||||
}
|
}
|
||||||
infos := make([]SourceInfo, 0, len(sources))
|
infos := make([]SourceInfo, 0, len(sources))
|
||||||
for _, sc := range sources {
|
for _, sc := range sources {
|
||||||
@@ -188,6 +206,11 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
// outside the sign-in block. Self-gates on adv.Enabled (404 when off).
|
// outside the sign-in block. Self-gates on adv.Enabled (404 when off).
|
||||||
mux.HandleFunc("POST /api/ingest/adventure", s.handleAdventureIngest)
|
mux.HandleFunc("POST /api/ingest/adventure", s.handleAdventureIngest)
|
||||||
|
|
||||||
|
// The live board. Ingest is bearer-authed like the fact seam; the read side
|
||||||
|
// is public because it renders on a public page anyway.
|
||||||
|
mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest)
|
||||||
|
mux.HandleFunc("GET /api/roster", s.handleRosterAPI)
|
||||||
|
|
||||||
// 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).
|
||||||
@@ -197,6 +220,27 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
// /adventure/{guid}, so the two patterns never overlap.
|
// /adventure/{guid}, so the two patterns never overlap.
|
||||||
mux.HandleFunc("GET /adventure/art/{type}", s.handleAdventureArt)
|
mux.HandleFunc("GET /adventure/art/{type}", s.handleAdventureArt)
|
||||||
|
|
||||||
|
// The euro/chip border. gogobee polls pending, claims a row, moves the euros,
|
||||||
|
// and pushes the verdict back. Bearer-authed on the same ingest token as the
|
||||||
|
// adventure seam, so like it, these sit outside the sign-in block — the caller
|
||||||
|
// is a machine on the tailnet, not a browser.
|
||||||
|
mux.HandleFunc("GET /api/games/escrow/pending", s.handleEscrowPending)
|
||||||
|
mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim)
|
||||||
|
mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
||||||
|
if s.gamesReady() {
|
||||||
|
mux.HandleFunc("GET /games", s.handleLobby)
|
||||||
|
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
|
||||||
|
mux.HandleFunc("GET /api/games/table", s.handleTable)
|
||||||
|
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
|
||||||
|
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
|
||||||
|
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
|
||||||
|
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
|
||||||
|
}
|
||||||
|
|
||||||
if s.auth != nil {
|
if s.auth != nil {
|
||||||
mux.HandleFunc("GET /auth/login", s.auth.handleLogin)
|
mux.HandleFunc("GET /auth/login", s.auth.handleLogin)
|
||||||
mux.HandleFunc("GET /auth/callback", s.auth.handleCallback)
|
mux.HandleFunc("GET /auth/callback", s.auth.handleCallback)
|
||||||
@@ -219,12 +263,56 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
|
|
||||||
s.srv = &http.Server{
|
s.srv = &http.Server{
|
||||||
Addr: cfg.ListenAddr,
|
Addr: cfg.ListenAddr,
|
||||||
Handler: mux,
|
Handler: s.hostRouter(mux),
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hostRouter puts the casino at the root of its own hostname without giving it
|
||||||
|
// its own mux. games.parodia.dev and news.parodia.dev are the same process on
|
||||||
|
// the same port — Caddy sends both here — so a request that arrives on the games
|
||||||
|
// host has /games prefixed onto its path, and "/" lands on the lobby.
|
||||||
|
//
|
||||||
|
// Shared plumbing (the API, sign-in, static files, health) is left alone: it is
|
||||||
|
// the same plumbing whichever door you came in by, and the login round-trip in
|
||||||
|
// particular has to keep working on both hosts.
|
||||||
|
func (s *Server) hostRouter(mux http.Handler) http.Handler {
|
||||||
|
host := strings.ToLower(strings.TrimSpace(s.cfg.Games.Host))
|
||||||
|
if host == "" || !s.gamesReady() {
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.EqualFold(hostOnly(r.Host), host) || isSharedPath(r.URL.Path) {
|
||||||
|
mux.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Rewrite a copy: the request's URL is shared with anything that logged it.
|
||||||
|
r2 := r.Clone(r.Context())
|
||||||
|
r2.URL.Path = "/games" + strings.TrimSuffix(r.URL.Path, "/")
|
||||||
|
mux.ServeHTTP(w, r2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSharedPath marks the paths that mean the same thing on every host and must
|
||||||
|
// not be shuffled under /games.
|
||||||
|
func isSharedPath(p string) bool {
|
||||||
|
for _, prefix := range []string{"/api/", "/auth/", "/static/", "/img/", "/games"} {
|
||||||
|
if strings.HasPrefix(p, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p == "/healthz" || p == "/sw.js" || p == "/manifest.webmanifest"
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostOnly strips the port from a Host header.
|
||||||
|
func hostOnly(host string) string {
|
||||||
|
if i := strings.IndexByte(host, ':'); i >= 0 {
|
||||||
|
return host[:i]
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
// Start runs the HTTP server and blocks until ctx is canceled.
|
// Start runs the HTTP server and blocks until ctx is canceled.
|
||||||
func (s *Server) Start(ctx context.Context) {
|
func (s *Server) Start(ctx context.Context) {
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -527,4 +527,471 @@ html[data-phase="night"] {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- the casino ---------------------------------------------------------
|
||||||
|
Cards are dealt, not swapped in: each one starts at the shoe in the corner,
|
||||||
|
flies to its place, and turns over. The whole point of the table is that you
|
||||||
|
watch it happen, so this is written as one animation per card with a delay,
|
||||||
|
and the JS just says which cards exist and in what order. */
|
||||||
|
|
||||||
|
.pete-felt {
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 90% at 50% -10%, rgba(255,255,255,0.10), transparent 60%),
|
||||||
|
linear-gradient(160deg, #2f7d5b 0%, #24614a 55%, #1c4d3c 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The shoe: where every card comes from. */
|
||||||
|
.pete-shoe {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.25rem;
|
||||||
|
right: 1.25rem;
|
||||||
|
height: 4.2rem;
|
||||||
|
width: 3rem;
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: linear-gradient(150deg, #b4553f, #8d3f2f);
|
||||||
|
border: 2px solid rgba(0,0,0,0.18);
|
||||||
|
box-shadow: 0 4px 0 rgba(0,0,0,0.18), inset 0 0 0 3px rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
.pete-shoe::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0.45rem 0.4rem auto 0.4rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
background: rgba(0,0,0,0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pete-hand {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
min-height: 8.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One card. The wrapper does the flight, the inner face does the flip, so the
|
||||||
|
two never fight over the same transform. */
|
||||||
|
.pete-card {
|
||||||
|
perspective: 700px;
|
||||||
|
height: 8.4rem;
|
||||||
|
width: 6rem;
|
||||||
|
animation: pete-deal 0.42s cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||||
|
}
|
||||||
|
.pete-card-inner {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
/* Face-down is the resting state of a card that hasn't been turned over: the
|
||||||
|
hole card sits like this until the dealer plays. */
|
||||||
|
.pete-card[data-face="down"] .pete-card-inner { transform: rotateY(180deg); }
|
||||||
|
|
||||||
|
.pete-card-front,
|
||||||
|
.pete-card-back {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
box-shadow: 0 3px 0 rgba(0,0,0,0.18), 0 6px 14px rgba(0,0,0,0.22);
|
||||||
|
}
|
||||||
|
/* The face is a single SVG on a 100×140 field (see blackjack.js) — suits as
|
||||||
|
vector shapes, pips at printed-deck coordinates. The card element only has
|
||||||
|
to hold it and colour it: `fill: currentColor` means the red/black switch
|
||||||
|
below repaints every pip, index and court letter at once. */
|
||||||
|
.pete-card-front {
|
||||||
|
place-items: stretch;
|
||||||
|
background: #fdfaf2;
|
||||||
|
border: 2px solid rgba(30,20,10,0.12);
|
||||||
|
color: #2b2118;
|
||||||
|
font-family: "Fredoka", "Nunito", system-ui, sans-serif;
|
||||||
|
line-height: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pete-card-front[data-red="1"] { color: #cc3d4a; }
|
||||||
|
|
||||||
|
/* The back. Turned away from you until the card is: this is what the dealer's
|
||||||
|
hole card shows while you're still acting. */
|
||||||
|
.pete-card-back {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, rgba(255,255,255,0.10) 0 6px, transparent 6px 12px),
|
||||||
|
linear-gradient(150deg, #b4553f, #8d3f2f);
|
||||||
|
border: 2px solid rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pete-card-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The corner index. Tabular-ish and tight, so a 10 doesn't shove the suit. */
|
||||||
|
.pete-card-idx {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-anchor: middle;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The court panel: a frame, not a portrait. */
|
||||||
|
.pete-card-panel {
|
||||||
|
fill: currentColor;
|
||||||
|
fill-opacity: 0.06;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-opacity: 0.35;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
.pete-card-court {
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-anchor: middle;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The flight itself: out of the shoe (up and to the right), scaled down and
|
||||||
|
spinning slightly, into place — and then a beat of settling, because a card
|
||||||
|
that stops dead on the felt looks like it was pasted there. It overshoots a
|
||||||
|
little and rocks back onto --tilt, the small resting angle the JS gives each
|
||||||
|
card so a hand looks handled rather than typeset. */
|
||||||
|
@keyframes pete-deal {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(var(--deal-x, 14rem), var(--deal-y, -7rem)) scale(0.72) rotate(9deg);
|
||||||
|
}
|
||||||
|
62% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(0, 0.35rem) scale(1.05) rotate(calc(var(--tilt, 0deg) - 2deg));
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(0, 0) scale(1) rotate(var(--tilt, 0deg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* And the weight: the felt takes the hit. The shadow under a landing card
|
||||||
|
spreads and darkens for the instant it arrives. */
|
||||||
|
.pete-card::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: auto 6% -0.35rem 6%;
|
||||||
|
height: 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
filter: blur(5px);
|
||||||
|
animation: pete-thud 0.42s cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||||
|
}
|
||||||
|
@keyframes pete-thud {
|
||||||
|
0%, 55% { opacity: 0; transform: scale(0.4); }
|
||||||
|
72% { opacity: 0.9; transform: scale(1.15); }
|
||||||
|
100% { opacity: 0.45; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A settled hand: the winning side gets a little lift, so a win reads at a
|
||||||
|
glance rather than only in the text. */
|
||||||
|
.pete-hand[data-won="1"] .pete-card { animation: pete-deal 0.42s cubic-bezier(0.22,1,0.36,1) backwards, pete-win 0.6s ease 0.1s; }
|
||||||
|
@keyframes pete-win {
|
||||||
|
0%, 100% { transform: rotate(var(--tilt, 0deg)) translateY(0); }
|
||||||
|
40% { transform: rotate(var(--tilt, 0deg)) translateY(-0.6rem); }
|
||||||
|
}
|
||||||
|
/* A losing hand doesn't just sit there being wrong: it greys off. */
|
||||||
|
.pete-hand[data-won="-1"] .pete-card {
|
||||||
|
filter: saturate(0.45) brightness(0.78);
|
||||||
|
transition: filter 0.5s ease 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- chips ---------------------------------------------------------------
|
||||||
|
One disc, three jobs: the buttons you bet with, the stack sitting in the bet
|
||||||
|
spot, and the chip in mid-air between them. Same face on all three, because
|
||||||
|
they are meant to read as the *same chip* being moved around. */
|
||||||
|
|
||||||
|
.pete-disc {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 30%, rgba(255,255,255,0.30), transparent 62%),
|
||||||
|
var(--chip, #e07a5f);
|
||||||
|
border: 2px solid rgba(0,0,0,0.22);
|
||||||
|
box-shadow: 0 3px 0 rgba(0,0,0,0.28), 0 6px 14px rgba(0,0,0,0.20);
|
||||||
|
}
|
||||||
|
/* The edge spots every casino chip has, cut as a dashed ring inside the rim. */
|
||||||
|
.pete-disc::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 11%;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px dashed rgba(255,255,255,0.55);
|
||||||
|
}
|
||||||
|
.pete-disc[data-chip="5"] { --chip: #5aa9e6; }
|
||||||
|
.pete-disc[data-chip="25"] { --chip: #4caf7d; }
|
||||||
|
.pete-disc[data-chip="100"] { --chip: #2b2118; }
|
||||||
|
.pete-disc[data-chip="500"] { --chip: #b079d6; }
|
||||||
|
|
||||||
|
.pete-chip { /* the buttons */
|
||||||
|
transition: transform 0.12s ease;
|
||||||
|
}
|
||||||
|
.pete-chip > span {
|
||||||
|
position: relative; /* over the dashed ring */
|
||||||
|
}
|
||||||
|
.pete-chip:active { transform: translateY(1px) scale(0.94); }
|
||||||
|
|
||||||
|
/* The bet spot: where your stake actually sits while the hand is played. */
|
||||||
|
.pete-spot {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 7rem;
|
||||||
|
width: 7rem;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px dashed rgba(255,255,255,0.35);
|
||||||
|
background: radial-gradient(circle at 50% 40%, rgba(255,255,255,0.10), transparent 70%);
|
||||||
|
box-shadow: inset 0 0 0 6px rgba(0,0,0,0.08);
|
||||||
|
transition: box-shadow 0.3s ease, border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
.pete-spot[data-live="1"] {
|
||||||
|
border-color: rgba(var(--glow, 242,181,61), 0.85);
|
||||||
|
box-shadow: inset 0 0 0 6px rgba(0,0,0,0.08), 0 0 22px rgba(var(--glow, 242,181,61), 0.35);
|
||||||
|
}
|
||||||
|
.pete-spot-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255,255,255,0.35);
|
||||||
|
}
|
||||||
|
.pete-spot[data-live="1"] .pete-spot-label { opacity: 0; }
|
||||||
|
|
||||||
|
/* 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. */
|
||||||
|
.pete-stack {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.pete-stack .pete-disc {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
height: 2.6rem;
|
||||||
|
width: 2.6rem;
|
||||||
|
margin: -1.3rem 0 0 -1.3rem;
|
||||||
|
/* Each chip sits proud of the one below it by more than its own rim, so a
|
||||||
|
stack of three reads as three. Less than this and they hide inside each
|
||||||
|
other and a 150 bet looks the same as a 25 one. */
|
||||||
|
transform: translateY(calc(-0.5rem * var(--i, 0))) rotate(var(--spin, 0deg));
|
||||||
|
box-shadow: 0 2px 0 rgba(0,0,0,0.35), 0 4px 8px rgba(0,0,0,0.25);
|
||||||
|
animation: pete-chip-land 0.28s cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
|
||||||
|
}
|
||||||
|
@keyframes pete-chip-land {
|
||||||
|
from { transform: translateY(calc(-0.5rem * var(--i, 0) - 1.1rem)) rotate(var(--spin, 0deg)) scale(1.18); }
|
||||||
|
}
|
||||||
|
.pete-spot-total {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -0.6rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
padding: 0.1rem 0.6rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A chip in mid-air. Lives in a fixed overlay so it can cross from the betting
|
||||||
|
buttons, over the page, and onto the felt without any container clipping it. */
|
||||||
|
.pete-fly-layer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pete-fly {
|
||||||
|
position: absolute;
|
||||||
|
height: 2.6rem;
|
||||||
|
width: 2.6rem;
|
||||||
|
margin: -1.3rem 0 0 -1.3rem;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The house's own chips, racked on the felt. This is where a payout comes
|
||||||
|
from, and where a losing stake goes — so the money has somewhere to be.
|
||||||
|
It sits alongside the shoe rather than over on the left, because the left of
|
||||||
|
the felt is where the dealer's cards land. */
|
||||||
|
.pete-rack {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.25rem;
|
||||||
|
right: 5.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 0.6rem 0.45rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgba(0,0,0,0.20);
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
/* A column in the rack is a *stack*, so it's striped: one band per chip, with
|
||||||
|
the shadow of the chip above it cut into each. A plain coloured lozenge here
|
||||||
|
read as a blob, which is the one thing a pile of money shouldn't. */
|
||||||
|
.pete-rack span {
|
||||||
|
display: block;
|
||||||
|
width: 1.7rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(0,0,0,0.28), transparent 35%, transparent 65%, rgba(0,0,0,0.28)),
|
||||||
|
repeating-linear-gradient(180deg,
|
||||||
|
rgba(255,255,255,0.18) 0 0.06rem,
|
||||||
|
var(--chip, #e07a5f) 0.06rem 0.3rem,
|
||||||
|
rgba(0,0,0,0.35) 0.3rem 0.36rem);
|
||||||
|
border: 1px solid rgba(0,0,0,0.35);
|
||||||
|
height: calc(0.36rem * var(--stack, 3));
|
||||||
|
}
|
||||||
|
.pete-rack span[data-chip="5"] { --chip: #5aa9e6; }
|
||||||
|
.pete-rack span[data-chip="25"] { --chip: #4caf7d; }
|
||||||
|
.pete-rack span[data-chip="100"] { --chip: #2b2118; }
|
||||||
|
.pete-rack span[data-chip="500"] { --chip: #b079d6; }
|
||||||
|
|
||||||
|
/* The burst. A natural gets confetti; nothing else in the room does, which is
|
||||||
|
what keeps it worth something. */
|
||||||
|
.pete-spark {
|
||||||
|
position: absolute;
|
||||||
|
height: 0.75rem;
|
||||||
|
width: 0.45rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The dealer's beat before they draw out. */
|
||||||
|
.pete-dealer-think {
|
||||||
|
animation: pete-think 0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pete-think {
|
||||||
|
0%, 100% { opacity: 0.6; }
|
||||||
|
50% { opacity: 1; transform: translateY(-1px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pete-card,
|
||||||
|
.pete-card::after,
|
||||||
|
.pete-stack .pete-disc,
|
||||||
|
.pete-dealer-think { animation: none; }
|
||||||
|
.pete-card-inner { transition: none; }
|
||||||
|
.pete-hand[data-won="1"] .pete-card { animation: none; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------------------
|
||||||
|
The rooms.
|
||||||
|
|
||||||
|
The casino borrows the news app's design language wholesale — the same four
|
||||||
|
palette vars, the same Fredoka/Nunito, the same fat rounded cards and dropped
|
||||||
|
shadows — and none of its furniture.
|
||||||
|
|
||||||
|
It borrows the clock too, but tells a different joke with it. News shifts
|
||||||
|
dawn→day→dusk→night. The casino has two rooms and swaps between them on the
|
||||||
|
hour, name and all:
|
||||||
|
|
||||||
|
casinopolis by day — honey and green felt, lamps low
|
||||||
|
casino-night by dark — neon blue, pinball purple, lit like a machine
|
||||||
|
|
||||||
|
Both are dark rooms of the same shape; only the light and the sign change.
|
||||||
|
Adding a third is a palette block, a felt, and a name — nothing else.
|
||||||
|
|
||||||
|
Written outside @layer on purpose: these must beat the base .shadow-pete.
|
||||||
|
---------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
html[data-room="casinopolis"] {
|
||||||
|
--bg: #16211c; /* the room, lights low */
|
||||||
|
--card: #23342b; /* a table in it */
|
||||||
|
--ink: #f6ecd8; /* warm cream, not white */
|
||||||
|
--accent: #f2b53d; /* honey */
|
||||||
|
--felt-a: #2f7d5b;
|
||||||
|
--felt-b: #24614a;
|
||||||
|
--felt-c: #1c4d3c;
|
||||||
|
--glow: 242,181,61; /* what the lamps throw, as rgb parts */
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-room="casino-night"] {
|
||||||
|
--bg: #140f2e; /* the machine's cabinet */
|
||||||
|
--card: #241a4d;
|
||||||
|
--ink: #f2ecff;
|
||||||
|
--accent: #ffcc2f; /* the jackpot bulb */
|
||||||
|
--felt-a: #4a2fa8; /* the table is a pinball table now */
|
||||||
|
--felt-b: #351f80;
|
||||||
|
--felt-c: #241659;
|
||||||
|
--glow: 126,86,255;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A lamp over the table and a darker floor at the edges, so the page has a
|
||||||
|
middle to sit in rather than being flat dark. */
|
||||||
|
html[data-room] .cs-room {
|
||||||
|
background:
|
||||||
|
radial-gradient(80% 55% at 50% -8%, rgba(var(--glow), 0.18), transparent 62%),
|
||||||
|
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
|
||||||
|
the top of the room. Cheap (one gradient, one transform) and it makes the
|
||||||
|
place feel plugged in rather than painted. */
|
||||||
|
html[data-room="casino-night"] .cs-room::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 -50% auto -50%;
|
||||||
|
height: 0.5rem;
|
||||||
|
background: repeating-linear-gradient(90deg,
|
||||||
|
rgba(255,204,47,0.55) 0 0.5rem, transparent 0.5rem 2.25rem);
|
||||||
|
filter: blur(1px);
|
||||||
|
animation: cs-bulbs 2.4s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes cs-bulbs {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(2.75rem); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The house shadow, restruck for a dark room: the news app's soft brown lift is
|
||||||
|
invisible down here, so the same shape is cut in black instead. */
|
||||||
|
html[data-room] .shadow-pete { box-shadow: 0 4px 0 rgba(0,0,0,0.30), 0 10px 26px rgba(0,0,0,0.30); }
|
||||||
|
html[data-room] .shadow-pete-lg { box-shadow: 0 6px 0 rgba(0,0,0,0.34), 0 20px 40px rgba(0,0,0,0.38); }
|
||||||
|
|
||||||
|
html[data-room] .cs-comb svg { filter: drop-shadow(0 3px 0 rgba(0,0,0,0.35)); }
|
||||||
|
|
||||||
|
/* The felt takes its colours from the room, so switching rooms reupholsters the
|
||||||
|
table too. */
|
||||||
|
html[data-room] .pete-felt {
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 90% at 50% -10%, rgba(255,255,255,0.10), transparent 60%),
|
||||||
|
linear-gradient(160deg, var(--felt-a) 0%, var(--felt-b) 55%, var(--felt-c) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html[data-room="casino-night"] .cs-room::before { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Three stacks of chips on the lobby's welcome card, in the same denomination
|
||||||
|
colours you actually bet with. Height comes from --stack, so the row reads as
|
||||||
|
a real pile someone left on the table rather than an icon of one. */
|
||||||
|
.cs-stack {
|
||||||
|
display: block;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: calc(0.45rem * var(--stack, 1) + 0.55rem);
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 30%, rgba(255,255,255,0.30), transparent 60%),
|
||||||
|
var(--chip, #e07a5f);
|
||||||
|
border: 2px solid rgba(0,0,0,0.25);
|
||||||
|
box-shadow:
|
||||||
|
0 3px 0 rgba(0,0,0,0.30),
|
||||||
|
inset 0 -0.4rem 0 rgba(0,0,0,0.16),
|
||||||
|
inset 0 0.35rem 0 rgba(255,255,255,0.14);
|
||||||
|
}
|
||||||
|
.cs-stack[data-chip="5"] { --chip: #5aa9e6; }
|
||||||
|
.cs-stack[data-chip="25"] { --chip: #4caf7d; }
|
||||||
|
.cs-stack[data-chip="100"] { --chip: #2b2118; }
|
||||||
|
.cs-stack[data-chip="500"] { --chip: #b079d6; }
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
591
internal/web/static/js/blackjack.js
Normal file
591
internal/web/static/js/blackjack.js
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
// The blackjack table.
|
||||||
|
//
|
||||||
|
// The browser holds no game. It sends intents — deal, hit, stand, double — and
|
||||||
|
// the server answers with the cards you're allowed to see plus the *script* 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
|
||||||
|
// rather than snapping the finished hand into place.
|
||||||
|
//
|
||||||
|
// Which is also why the hole card works the way it does: the server sends a
|
||||||
|
// "dealer_hole" event with no card attached, because while you are still acting
|
||||||
|
// it hasn't told anyone what that card is. It arrives with the reveal.
|
||||||
|
//
|
||||||
|
// The money is animated on the same principle. Every number the server sends is
|
||||||
|
// also a movement: a stake is chips leaving your pile and landing on the spot, a
|
||||||
|
// payout is chips coming out of the house's rack, a loss is your stack being
|
||||||
|
// 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,
|
||||||
|
// which is deliberately not updated until the chips that justify it have landed.
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var root = document.querySelector("[data-blackjack]");
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
var FX = window.PeteFX;
|
||||||
|
|
||||||
|
var dealerEl = root.querySelector("[data-dealer]");
|
||||||
|
var playerEl = root.querySelector("[data-player]");
|
||||||
|
var dTotalEl = root.querySelector("[data-dealer-total]");
|
||||||
|
var pTotalEl = root.querySelector("[data-player-total]");
|
||||||
|
var dLabelEl = root.querySelector("[data-dealer-label]");
|
||||||
|
var verdictEl = root.querySelector("[data-verdict]");
|
||||||
|
var betting = root.querySelector("[data-betting]");
|
||||||
|
var actions = root.querySelector("[data-actions]");
|
||||||
|
var betAmount = root.querySelector("[data-bet-amount]");
|
||||||
|
var dealBtn = root.querySelector("[data-deal]");
|
||||||
|
var msgEl = root.querySelector("[data-table-msg]");
|
||||||
|
|
||||||
|
// The three places a chip can be: your pile (in the bar above), the spot in
|
||||||
|
// front of you, and the house's rack on the felt.
|
||||||
|
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]");
|
||||||
|
|
||||||
|
// Nothing is bet until a chip is on the felt. The number in the panel is a
|
||||||
|
// readout of the pile, so it starts where the pile does — at nothing — rather
|
||||||
|
// than at a default stake nobody put down.
|
||||||
|
var bet = 0; // what you're building between hands
|
||||||
|
var staked = 0; // what is actually sitting on the spot right now
|
||||||
|
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 DEAL_MS = 380; // one card's flight, and the gap before the next
|
||||||
|
var FLIP_MS = 450;
|
||||||
|
var BEAT_MS = 600; // the dealer thinking before they draw out
|
||||||
|
|
||||||
|
var reduced = FX.reduced;
|
||||||
|
function pace(ms) { return reduced ? 0 : ms; }
|
||||||
|
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
|
||||||
|
|
||||||
|
function say(text, tone) {
|
||||||
|
if (!text) { msgEl.classList.add("hidden"); return; }
|
||||||
|
msgEl.textContent = text;
|
||||||
|
msgEl.classList.remove("hidden");
|
||||||
|
msgEl.style.color = tone === "bad" ? "#cc3d4a" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- drawing --------------------------------------------------------------
|
||||||
|
|
||||||
|
var dealt = 0; // how many cards this table has put down, ever — the tilt seed
|
||||||
|
|
||||||
|
// cardEl builds one card. face === null means face-down: the card is dealt,
|
||||||
|
// but this browser has not been told what it is.
|
||||||
|
function cardEl(face) {
|
||||||
|
var wrap = document.createElement("div");
|
||||||
|
wrap.className = "pete-card";
|
||||||
|
wrap.dataset.face = face ? "up" : "down";
|
||||||
|
// Every card flies out of the shoe, which sits in the top-right of the felt.
|
||||||
|
// The offset is per-card, so a card landing further left flies further.
|
||||||
|
wrap.style.setProperty("--deal-x", "14rem");
|
||||||
|
wrap.style.setProperty("--deal-y", "-6rem");
|
||||||
|
// Where it comes to rest. A degree or two either way is the whole difference
|
||||||
|
// between cards that were dealt onto a table and cards that were laid out in
|
||||||
|
// a grid, and it costs one custom property.
|
||||||
|
wrap.style.setProperty("--tilt", FX.jitter(dealt++, 2.4).toFixed(2) + "deg");
|
||||||
|
|
||||||
|
var inner = document.createElement("div");
|
||||||
|
inner.className = "pete-card-inner";
|
||||||
|
|
||||||
|
var front = document.createElement("div");
|
||||||
|
front.className = "pete-card-front";
|
||||||
|
var back = document.createElement("div");
|
||||||
|
back.className = "pete-card-back";
|
||||||
|
|
||||||
|
inner.appendChild(front);
|
||||||
|
inner.appendChild(back);
|
||||||
|
wrap.appendChild(inner);
|
||||||
|
if (face) paintFace(front, face);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the face ------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// A card is drawn, not typed. The first attempt set the pips as text — "♠" in a
|
||||||
|
// span — and at the size a card actually is, a suit character renders as a
|
||||||
|
// speck: the shape is whatever font happened to answer, it doesn't scale, and
|
||||||
|
// it can't be positioned to the half-row a real pip layout needs.
|
||||||
|
//
|
||||||
|
// So each face is one SVG on a 100×140 field (the proportions of a real card),
|
||||||
|
// with the suits as vector shapes. Everything below is coordinates on that
|
||||||
|
// field, which is why the pips land where a printed deck puts them instead of
|
||||||
|
// where a flexbox felt like putting them.
|
||||||
|
|
||||||
|
var SUIT_ART = {
|
||||||
|
"♠": '<path d="M50 6C50 6 16 34 16 58a17 17 0 0 0 28 13c-1 12-5 19-12 25h36c-7-6-11-13-12-25a17 17 0 0 0 28-13C84 34 50 6 50 6Z"/>',
|
||||||
|
"♥": '<path d="M50 96C50 96 8 66 8 38A22 22 0 0 1 50 24 22 22 0 0 1 92 38c0 28-42 58-42 58Z"/>',
|
||||||
|
"♦": '<path d="M50 4 88 50 50 96 12 50Z"/>',
|
||||||
|
"♣": '<g><circle cx="50" cy="28" r="19"/><circle cx="24" cy="62" r="19"/><circle cx="76" cy="62" r="19"/>' +
|
||||||
|
'<path d="M44 60h12l7 36H37Z"/></g>',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pip layouts, the way a real deck lays them out — which is not "N suits in a
|
||||||
|
// row". [x, y] on the 100×140 field. The seven canonical rows sit at y = 27,
|
||||||
|
// 41, 56, 70, 84, 99, 113; sevens, eights and tens carry a pip *between* two of
|
||||||
|
// them, which is the whole reason this is a table of coordinates and not a
|
||||||
|
// grid. Anything below the middle is printed upside down, so it is.
|
||||||
|
var R = [0, 27, 41.4, 55.7, 70, 84.3, 98.6, 113]; // 1-indexed, R[4] is the middle
|
||||||
|
var L = 30, C = 50, Rr = 70; // the three columns
|
||||||
|
|
||||||
|
var PIPS = {
|
||||||
|
"A": [[C, 70, 2.1]],
|
||||||
|
"2": [[C, R[1]], [C, R[7]]],
|
||||||
|
"3": [[C, R[1]], [C, R[4]], [C, R[7]]],
|
||||||
|
"4": [[L, R[1]], [Rr, R[1]], [L, R[7]], [Rr, R[7]]],
|
||||||
|
"5": [[L, R[1]], [Rr, R[1]], [C, R[4]], [L, R[7]], [Rr, R[7]]],
|
||||||
|
"6": [[L, R[1]], [Rr, R[1]], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
|
||||||
|
"7": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
|
||||||
|
"8": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
|
||||||
|
"9": [[L, R[1]], [Rr, R[1]], [L, R[3]], [Rr, R[3]], [C, R[4]], [L, R[5]], [Rr, R[5]], [L, R[7]], [Rr, R[7]]],
|
||||||
|
"10": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[3]], [Rr, R[3]], [L, R[5]], [Rr, R[5]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
|
||||||
|
};
|
||||||
|
|
||||||
|
var COURT = { "J": "Jack", "Q": "Queen", "K": "King" };
|
||||||
|
|
||||||
|
// One pip: the suit art, scaled and dropped at [x, y], turned over if it sits
|
||||||
|
// below the middle of the card.
|
||||||
|
function pipAt(suit, x, y, scale) {
|
||||||
|
var s = (scale || 1) * 0.17;
|
||||||
|
var turn = y > 70 ? " rotate(180 50 50)" : "";
|
||||||
|
return '<g transform="translate(' + x + ' ' + y + ') scale(' + s + ') translate(-50 -50)' + turn + '">' +
|
||||||
|
SUIT_ART[suit] + "</g>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The corner index: rank over suit. Printed in both corners, the second one
|
||||||
|
// upside down, which is what lets you read a card from a fanned hand.
|
||||||
|
function index(face) {
|
||||||
|
var g =
|
||||||
|
'<g>' +
|
||||||
|
'<text x="12" y="24" class="pete-card-idx">' + face.rank + "</text>" +
|
||||||
|
'<g transform="translate(12 36) scale(0.13) translate(-50 -50)">' + SUIT_ART[face.suit] + "</g>" +
|
||||||
|
"</g>";
|
||||||
|
return g + '<g transform="rotate(180 50 70)">' + g + "</g>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// paintFace draws the card. The dealer's cards and yours use the same face,
|
||||||
|
// because they came out of the same shoe.
|
||||||
|
function paintFace(front, face) {
|
||||||
|
front.dataset.red = face.red ? "1" : "0";
|
||||||
|
|
||||||
|
var body = "";
|
||||||
|
if (COURT[face.rank]) {
|
||||||
|
// Court cards: a framed panel, the suit above the letter and again below it
|
||||||
|
// the other way up. A real court mirrors a *figure*; mirroring a letter just
|
||||||
|
// stacks two of them into a blob, which is exactly what the first attempt
|
||||||
|
// did. No portrait either — a drawn king would fight the room, and this
|
||||||
|
// reads instantly at the size a card actually is.
|
||||||
|
body =
|
||||||
|
'<rect x="20" y="22" width="60" height="96" rx="6" class="pete-card-panel"/>' +
|
||||||
|
pipAt(face.suit, 50, 38, 0.95) +
|
||||||
|
'<text x="50" y="82" class="pete-card-court">' + face.rank + "</text>" +
|
||||||
|
pipAt(face.suit, 50, 102, 0.95);
|
||||||
|
} else {
|
||||||
|
var spots = PIPS[face.rank] || [];
|
||||||
|
for (var i = 0; i < spots.length; i++) {
|
||||||
|
body += pipAt(face.suit, spots[i][0], spots[i][1], spots[i][2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
front.innerHTML =
|
||||||
|
'<svg class="pete-card-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg" ' +
|
||||||
|
'role="img" aria-label="' + ariaFor(face) + '">' + index(face) + body + "</svg>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// "A♠" is not something a screen reader should be asked to pronounce.
|
||||||
|
function ariaFor(face) {
|
||||||
|
var SUITS = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" };
|
||||||
|
var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank);
|
||||||
|
var suit = SUITS[face.suit];
|
||||||
|
return suit ? name + " of " + suit : face.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// turnOver flips a face-down card up, now that we've been told what it is.
|
||||||
|
function turnOver(wrap, face) {
|
||||||
|
if (!wrap) return;
|
||||||
|
paintFace(wrap.querySelector(".pete-card-front"), face);
|
||||||
|
wrap.dataset.face = "up";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the money on the felt -------------------------------------------------
|
||||||
|
//
|
||||||
|
// `staked` is what the spot is holding. Every path that changes it also moves
|
||||||
|
// chips to say so, so the two can't come apart: renderStack draws the pile,
|
||||||
|
// and the fly* calls are what put it there.
|
||||||
|
|
||||||
|
function renderStack(amount) {
|
||||||
|
staked = amount || 0;
|
||||||
|
stackEl.innerHTML = "";
|
||||||
|
spotEl.dataset.live = staked > 0 ? "1" : "0";
|
||||||
|
|
||||||
|
if (!staked) {
|
||||||
|
spotTotalEl.classList.add("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
FX.chipsFor(staked).forEach(function (d, i) {
|
||||||
|
var c = FX.disc(d);
|
||||||
|
c.style.setProperty("--i", i);
|
||||||
|
c.style.setProperty("--spin", FX.jitter(i, 12).toFixed(1) + "deg");
|
||||||
|
c.style.animationDelay = pace(i * 40) + "ms";
|
||||||
|
stackEl.appendChild(c);
|
||||||
|
});
|
||||||
|
spotTotalEl.textContent = staked.toLocaleString();
|
||||||
|
spotTotalEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
// pour throws a run of chips from one place to another and grows the pile on
|
||||||
|
// the spot as each one lands — by the value of the chip that landed, so the
|
||||||
|
// total under the pile counts up the way the chips do. The last chip carries
|
||||||
|
// the remainder, because chipsFor caps how many chips it will make you watch
|
||||||
|
// and the pile still has to end on the real number.
|
||||||
|
function pour(from, to, amount, opts) {
|
||||||
|
if (amount <= 0) return Promise.resolve();
|
||||||
|
var base = staked;
|
||||||
|
var chips = FX.chipsFor(amount, 8);
|
||||||
|
var run = 0;
|
||||||
|
return FX.flyMany(from, to, chips, Object.assign({
|
||||||
|
onLand: function (d, i) {
|
||||||
|
run += d;
|
||||||
|
renderStack(base + (i === chips.length - 1 ? amount : run));
|
||||||
|
},
|
||||||
|
}, opts || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// stake moves chips from your pile onto the spot: the bet you build before a
|
||||||
|
// deal, and the second bet a double puts down beside it.
|
||||||
|
function stake(amount, from) {
|
||||||
|
return pour(from || purseEl, spotEl, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// moved: `bet` (already off your pile since the deal) and `payout` (what comes
|
||||||
|
// back — stake plus winnings less rake, or nothing at all).
|
||||||
|
function settleChips(final) {
|
||||||
|
var payout = final.payout || 0;
|
||||||
|
var back = payout - final.bet; // what the house is adding, if anything
|
||||||
|
|
||||||
|
if (payout <= 0) {
|
||||||
|
// The house takes it. The stack goes to the rack and doesn't come back.
|
||||||
|
var lost = FX.chipsFor(final.bet, 8);
|
||||||
|
var chain = FX.flyMany(spotEl, houseEl, lost, { gap: 45, lift: 0.6, fade: true });
|
||||||
|
renderStack(0);
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The house pays first, into the spot beside your stake, so you watch the
|
||||||
|
// winnings arrive on top of the bet that earned them.
|
||||||
|
var pay = pour(houseEl, spotEl, back, { gap: 60 });
|
||||||
|
|
||||||
|
// Paid, then swept up: the whole lot comes back to your pile, and only then
|
||||||
|
// does the number in the bar move.
|
||||||
|
return pay
|
||||||
|
.then(function () { return wait(back > 0 ? 380 : 200); })
|
||||||
|
.then(function () {
|
||||||
|
var home = FX.flyMany(spotEl, purseEl, FX.chipsFor(payout, 8), { gap: 40, lift: 0.8 });
|
||||||
|
renderStack(0);
|
||||||
|
return home;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function totals(v) {
|
||||||
|
if (v.total) {
|
||||||
|
pTotalEl.textContent = v.total + (v.soft ? " (soft)" : "");
|
||||||
|
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) {
|
||||||
|
dTotalEl.textContent = v.hole ? v.dealer_total + " showing" : String(v.dealer_total);
|
||||||
|
dTotalEl.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
dTotalEl.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// paint puts a hand on the felt with no animation. This is the resume path:
|
||||||
|
// you reloaded, or Pete restarted, and your cards are simply there — including
|
||||||
|
// the stake, which is still on the spot because the server still has it.
|
||||||
|
function paint(v) {
|
||||||
|
dealerEl.innerHTML = "";
|
||||||
|
playerEl.innerHTML = "";
|
||||||
|
if (!v) { setPhase(null); renderStack(0); return; }
|
||||||
|
|
||||||
|
v.player.forEach(function (c) { playerEl.appendChild(cardEl(c)); });
|
||||||
|
v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); });
|
||||||
|
if (v.hole) dealerEl.appendChild(cardEl(null));
|
||||||
|
renderStack(v.phase === "done" ? 0 : v.bet);
|
||||||
|
totals(v);
|
||||||
|
setPhase(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
var VERDICTS = {
|
||||||
|
blackjack: "Blackjack! 🎉",
|
||||||
|
win: "You win!",
|
||||||
|
dealer_bust: "Dealer busts. You win!",
|
||||||
|
lose: "Dealer takes it.",
|
||||||
|
bust: "Bust.",
|
||||||
|
push: "Push — your bet comes back.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function verdict(v) {
|
||||||
|
var text = VERDICTS[v.outcome] || "";
|
||||||
|
if (!text) { verdictEl.classList.add("hidden"); return; }
|
||||||
|
if (v.net > 0) text += " +" + v.net.toLocaleString();
|
||||||
|
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
||||||
|
verdictEl.textContent = text;
|
||||||
|
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
|
||||||
|
// 3:2, and if everything celebrated then nothing would.
|
||||||
|
if (v.outcome === "blackjack") FX.burst(verdictEl, { count: 34 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// setPhase swaps the controls: bet between hands, act during one.
|
||||||
|
function setPhase(v) {
|
||||||
|
hand = v;
|
||||||
|
var live = !!v && v.phase === "player";
|
||||||
|
betting.classList.toggle("hidden", live);
|
||||||
|
actions.classList.toggle("hidden", !live);
|
||||||
|
|
||||||
|
if (live) {
|
||||||
|
var dbl = actions.querySelector('[data-move="double"]');
|
||||||
|
if (dbl) dbl.disabled = !v.can_double;
|
||||||
|
}
|
||||||
|
if (!v || v.phase !== "player") verdictEl.classList.toggle("hidden", !(v && v.outcome));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the script -----------------------------------------------------------
|
||||||
|
|
||||||
|
// play walks the server's events, one card at a time. It is deliberately the
|
||||||
|
// only thing that renders during a hand: the final state is applied at the end,
|
||||||
|
// so what you watch and what the server says can't disagree halfway through.
|
||||||
|
//
|
||||||
|
// `money` is the one exception, and it's a deliberate one. On a hand that is
|
||||||
|
// still running, the chip bar is right immediately — your stake left your pile
|
||||||
|
// when you pressed Deal, and it's sitting on the spot where you can see it. On
|
||||||
|
// a hand that *settles*, the bar is left alone until the chips have physically
|
||||||
|
// come home, because a counter that pays you before the dealer has turned over
|
||||||
|
// is a counter that has told you the ending.
|
||||||
|
function play(view, money) {
|
||||||
|
var events = view.events || [];
|
||||||
|
var final = view.hand;
|
||||||
|
var settles = !!final && final.phase === "done";
|
||||||
|
var hole = null; // the face-down card element, once one has been dealt
|
||||||
|
var chain = Promise.resolve();
|
||||||
|
var drew = false; // has the dealer drawn since the reveal?
|
||||||
|
|
||||||
|
if (!settles) money();
|
||||||
|
|
||||||
|
// Whatever the server says the stake is, that's what has to be on the spot.
|
||||||
|
// Two things get here: a double, which puts a second bet down beside the
|
||||||
|
// first, and a deal whose bet was typed rather than stacked (you kept last
|
||||||
|
// hand's number and just pressed Deal). Either way the chips go down before
|
||||||
|
// the card they're buying does.
|
||||||
|
if (final && final.bet > staked) {
|
||||||
|
var extra = final.bet - staked;
|
||||||
|
chain = chain.then(function () { return stake(extra); });
|
||||||
|
}
|
||||||
|
|
||||||
|
events.forEach(function (e) {
|
||||||
|
chain = chain.then(function () {
|
||||||
|
switch (e.kind) {
|
||||||
|
case "deal":
|
||||||
|
dealerEl.innerHTML = "";
|
||||||
|
playerEl.innerHTML = "";
|
||||||
|
playerEl.dataset.won = "0";
|
||||||
|
verdictEl.classList.add("hidden");
|
||||||
|
return;
|
||||||
|
|
||||||
|
case "player_card":
|
||||||
|
playerEl.appendChild(cardEl(e.card));
|
||||||
|
return wait(DEAL_MS);
|
||||||
|
|
||||||
|
case "dealer_card":
|
||||||
|
// The dealer takes a moment before the first card they draw out.
|
||||||
|
// 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.
|
||||||
|
var beat = drew ? Promise.resolve() : think();
|
||||||
|
drew = true;
|
||||||
|
return beat.then(function () {
|
||||||
|
dealerEl.appendChild(cardEl(e.card));
|
||||||
|
return wait(DEAL_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
case "dealer_hole":
|
||||||
|
hole = cardEl(null);
|
||||||
|
dealerEl.appendChild(hole);
|
||||||
|
return wait(DEAL_MS);
|
||||||
|
|
||||||
|
case "reveal":
|
||||||
|
// 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.
|
||||||
|
if (!hole) hole = dealerEl.querySelector('.pete-card[data-face="down"]');
|
||||||
|
if (hole && final && final.dealer && final.dealer[1]) {
|
||||||
|
turnOver(hole, final.dealer[1]);
|
||||||
|
}
|
||||||
|
return wait(FLIP_MS);
|
||||||
|
|
||||||
|
case "settle":
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return chain.then(function () {
|
||||||
|
if (!final) { paint(null); money(); return; }
|
||||||
|
|
||||||
|
totals(final);
|
||||||
|
if (!settles) { setPhase(final); return; }
|
||||||
|
|
||||||
|
// The hand is over: nothing is on offer while the money is moving. Hit and
|
||||||
|
// Stand go now, and Deal comes back at the far end.
|
||||||
|
actions.classList.add("hidden");
|
||||||
|
verdict(final);
|
||||||
|
// 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
|
||||||
|
// over a table that is still being paid out invites a click the table then
|
||||||
|
// has to refuse.
|
||||||
|
return settleChips(final)
|
||||||
|
.then(money)
|
||||||
|
.then(function () { return standing(final.bet); })
|
||||||
|
.then(function () { setPhase(final); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// — chips on the spot are a proposal until you press Deal — and it's what keeps
|
||||||
|
// the number in the panel honest, because otherwise a settled hand leaves
|
||||||
|
// "your bet: 300" printed over an empty spot.
|
||||||
|
function standing(amount) {
|
||||||
|
var money = window.PeteGames.view();
|
||||||
|
if (!amount || !money || money.chips < amount) {
|
||||||
|
bet = 0;
|
||||||
|
showBet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bet = amount;
|
||||||
|
showBet();
|
||||||
|
return stake(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// think is the dealer's beat: a pause with something to look at, so it reads as
|
||||||
|
// deliberation rather than as the page having hung.
|
||||||
|
function think() {
|
||||||
|
if (reduced || !dLabelEl) return wait(0);
|
||||||
|
dLabelEl.classList.add("pete-dealer-think");
|
||||||
|
return wait(BEAT_MS).then(function () {
|
||||||
|
dLabelEl.classList.remove("pete-dealer-think");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- talking to the table -------------------------------------------------
|
||||||
|
|
||||||
|
function send(path, body) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
say("");
|
||||||
|
return window.PeteGames.post(path, body)
|
||||||
|
.then(function (view) {
|
||||||
|
// play() decides *when* the money lands; see the note on it.
|
||||||
|
return play(view, function () { window.PeteGames.apply(view); });
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
say(err.message, "bad");
|
||||||
|
// Whatever we thought was on the felt, the server is the authority on it.
|
||||||
|
return window.PeteGames.refresh().then(function (v) {
|
||||||
|
if (v && !v.hand) renderStack(0);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function () { busy = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- betting --------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// A bet is built by putting chips on the spot, one at a time, and it is those
|
||||||
|
// chips the deal then rides on — the number under the pile is a readout of the
|
||||||
|
// pile, not the other way round.
|
||||||
|
|
||||||
|
function showBet() {
|
||||||
|
betAmount.textContent = bet.toLocaleString();
|
||||||
|
var money = window.PeteGames.view();
|
||||||
|
if (dealBtn) dealBtn.disabled = bet <= 0 || !money || money.chips < bet;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.querySelectorAll("[data-chip]").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
if (busy) return;
|
||||||
|
var d = parseInt(btn.dataset.chip, 10);
|
||||||
|
var money = window.PeteGames.view();
|
||||||
|
if (money && bet + d > money.chips) {
|
||||||
|
say("You haven't got that many chips.", "bad");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bet += d;
|
||||||
|
showBet();
|
||||||
|
|
||||||
|
// The chip you clicked is the chip that flies: same colour, same size, off
|
||||||
|
// the button and onto the felt. The pile only grows once it gets there —
|
||||||
|
// but `staked` moves now, so a Deal pressed mid-flight still knows the chip
|
||||||
|
// is on its way and doesn't put a second one down.
|
||||||
|
var target = bet;
|
||||||
|
staked = bet;
|
||||||
|
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
||||||
|
if (bet >= target) renderStack(target); // unless Clear got there first
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var clearBtn = root.querySelector("[data-bet-clear]");
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener("click", function () {
|
||||||
|
if (busy || !staked) { bet = 0; showBet(); return; }
|
||||||
|
FX.flyMany(spotEl, purseEl, FX.chipsFor(staked, 8), { gap: 40, lift: 0.7 });
|
||||||
|
bet = 0;
|
||||||
|
renderStack(0);
|
||||||
|
showBet();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dealBtn) {
|
||||||
|
dealBtn.addEventListener("click", function () {
|
||||||
|
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
|
||||||
|
send("/api/games/blackjack/deal", { bet: bet });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
root.querySelectorAll("[data-move]").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
send("/api/games/blackjack/move", { move: btn.dataset.move });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
if (/^(input|textarea|select)$/i.test((e.target.tagName || ""))) return;
|
||||||
|
if (!hand || hand.phase !== "player" || busy) return;
|
||||||
|
|
||||||
|
var move = { h: "hit", s: "stand", d: "double" }[e.key.toLowerCase()];
|
||||||
|
if (!move) return;
|
||||||
|
if (move === "double" && !hand.can_double) return;
|
||||||
|
e.preventDefault();
|
||||||
|
send("/api/games/blackjack/move", { move: move });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
var resumed = false;
|
||||||
|
window.PeteGames.onUpdate(function (v) {
|
||||||
|
if (!resumed) {
|
||||||
|
resumed = true;
|
||||||
|
if (v.hand) paint(v.hand);
|
||||||
|
if (v.hand && v.hand.phase === "done") verdict(v.hand);
|
||||||
|
}
|
||||||
|
showBet();
|
||||||
|
});
|
||||||
|
})();
|
||||||
249
internal/web/static/js/casino-fx.js
Normal file
249
internal/web/static/js/casino-fx.js
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
// The moving parts of the room.
|
||||||
|
//
|
||||||
|
// A casino is mostly one gesture repeated: something of value is picked up here
|
||||||
|
// and put down there, in front of you, slowly enough that you can object. So the
|
||||||
|
// only real idea in this file is fly() — take an element's place on screen, take
|
||||||
|
// another's, and move a chip between them on an arc that a hand would make.
|
||||||
|
//
|
||||||
|
// Everything flies in a fixed overlay pinned to the viewport, not inside the
|
||||||
|
// felt, because a chip's journey starts on a button *outside* the felt and any
|
||||||
|
// container in between would clip it halfway. Coordinates come from
|
||||||
|
// getBoundingClientRect, so the overlay needs no knowledge of the layout at all
|
||||||
|
// and nothing has to be positioned relative to anything else.
|
||||||
|
//
|
||||||
|
// Exposed as window.PeteFX. Nothing in here knows what blackjack is.
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var reduced =
|
||||||
|
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
var layer = null;
|
||||||
|
function stage() {
|
||||||
|
if (!layer) {
|
||||||
|
layer = document.createElement("div");
|
||||||
|
layer.className = "pete-fly-layer";
|
||||||
|
layer.setAttribute("aria-hidden", "true");
|
||||||
|
document.body.appendChild(layer);
|
||||||
|
}
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where a thing is, in viewport coordinates: the middle of it.
|
||||||
|
function centre(target) {
|
||||||
|
if (!target) return { x: 0, y: 0 };
|
||||||
|
if (typeof target.x === "number") return target;
|
||||||
|
var r = target.getBoundingClientRect();
|
||||||
|
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// DENOMS, biggest first — the order you break an amount down in.
|
||||||
|
var DENOMS = [500, 100, 25, 5];
|
||||||
|
|
||||||
|
// chipsFor turns an amount into the chips a dealer would actually push across:
|
||||||
|
// as few as possible, biggest first. Capped, because a €10,000 bet is 20 purple
|
||||||
|
// chips and nobody wants to watch 20 of anything fly. Past the cap the stack
|
||||||
|
// just gets shorter than the number under it, which is what a real tall stack
|
||||||
|
// looks like from across a table anyway.
|
||||||
|
function chipsFor(amount, cap) {
|
||||||
|
var out = [];
|
||||||
|
var left = Math.max(0, Math.round(amount || 0));
|
||||||
|
for (var i = 0; i < DENOMS.length && left > 0; i++) {
|
||||||
|
while (left >= DENOMS[i]) {
|
||||||
|
out.push(DENOMS[i]);
|
||||||
|
left -= DENOMS[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (left > 0) out.push(5); // a remainder below the smallest chip still shows up
|
||||||
|
var max = cap || 12;
|
||||||
|
return out.length > max ? out.slice(0, max) : out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disc(denom) {
|
||||||
|
var el = document.createElement("div");
|
||||||
|
el.className = "pete-disc";
|
||||||
|
el.dataset.chip = String(denom);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deterministic wobble per index, so a stack looks hand-placed but doesn't
|
||||||
|
// reshuffle itself every time the page repaints.
|
||||||
|
function jitter(i, spread) {
|
||||||
|
var n = Math.sin((i + 1) * 12.9898) * 43758.5453;
|
||||||
|
return ((n - Math.floor(n)) * 2 - 1) * (spread || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// fly moves one chip from one place to another and resolves when it lands.
|
||||||
|
//
|
||||||
|
// The arc is the point. A straight line between two rects reads as a UI
|
||||||
|
// transition; a chip that rises, travels and drops reads as a throw. WAAPI does
|
||||||
|
// this in one keyframe list because it can interpolate a midpoint — a CSS
|
||||||
|
// transition can't, which is why this isn't a class toggle.
|
||||||
|
function fly(from, to, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var a = centre(from);
|
||||||
|
var b = centre(to);
|
||||||
|
var el = disc(opts.denom || 25);
|
||||||
|
el.className += " pete-fly";
|
||||||
|
stage().appendChild(el);
|
||||||
|
|
||||||
|
var dur = opts.duration || 420;
|
||||||
|
if (reduced) dur = 1;
|
||||||
|
|
||||||
|
// How high it goes: further throws arc higher, and a lob can be forced.
|
||||||
|
var dist = Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
var lift = Math.min(120, 28 + dist * 0.18) * (opts.lift == null ? 1 : opts.lift);
|
||||||
|
var midX = (a.x + b.x) / 2;
|
||||||
|
var midY = (a.y + b.y) / 2 - lift;
|
||||||
|
var spin = opts.spin == null ? jitter(opts.index || 0, 90) : opts.spin;
|
||||||
|
|
||||||
|
var anim = el.animate(
|
||||||
|
[
|
||||||
|
{ transform: t(a.x, a.y, 0.85, 0), opacity: 1, offset: 0 },
|
||||||
|
{ transform: t(midX, midY, 1.12, spin * 0.6), opacity: 1, offset: 0.5 },
|
||||||
|
{ transform: t(b.x, b.y, 1, spin), opacity: opts.fade ? 0 : 1, offset: 1 },
|
||||||
|
],
|
||||||
|
{ duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: reduced ? 0 : opts.delay || 0, fill: "both" }
|
||||||
|
);
|
||||||
|
|
||||||
|
return anim.finished
|
||||||
|
.catch(function () {})
|
||||||
|
.then(function () {
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function t(x, y, scale, rot) {
|
||||||
|
return "translate(" + x + "px," + y + "px) scale(" + scale + ") rotate(" + rot + "deg)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// flyMany throws a whole bet across, one chip after another rather than all at
|
||||||
|
// once, because a pile of chips arriving as a single object is a progress bar.
|
||||||
|
// Resolves when the last one lands.
|
||||||
|
function flyMany(from, to, denoms, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var gap = reduced ? 0 : opts.gap == null ? 70 : opts.gap;
|
||||||
|
var each = denoms.map(function (d, i) {
|
||||||
|
return fly(from, to, {
|
||||||
|
denom: d,
|
||||||
|
index: i,
|
||||||
|
delay: (opts.delay || 0) + i * gap,
|
||||||
|
duration: opts.duration,
|
||||||
|
lift: opts.lift,
|
||||||
|
fade: opts.fade,
|
||||||
|
spin: opts.spin,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (opts.onLand && !reduced) {
|
||||||
|
denoms.forEach(function (d, i) {
|
||||||
|
setTimeout(opts.onLand, (opts.delay || 0) + i * gap + (opts.duration || 420), d, i);
|
||||||
|
});
|
||||||
|
} else if (opts.onLand) {
|
||||||
|
denoms.forEach(function (d, i) { opts.onLand(d, i); });
|
||||||
|
}
|
||||||
|
return Promise.all(each);
|
||||||
|
}
|
||||||
|
|
||||||
|
// burst: confetti out of a point. Saved for the things worth celebrating.
|
||||||
|
function burst(target, opts) {
|
||||||
|
if (reduced) return Promise.resolve();
|
||||||
|
opts = opts || {};
|
||||||
|
var c = centre(target);
|
||||||
|
var n = opts.count || 26;
|
||||||
|
var colours = opts.colours || ["#f2b53d", "#4caf7d", "#5aa9e6", "#b079d6", "#ffffff", "#cc3d4a"];
|
||||||
|
var done = [];
|
||||||
|
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
var bit = document.createElement("div");
|
||||||
|
bit.className = "pete-spark";
|
||||||
|
bit.style.background = colours[i % colours.length];
|
||||||
|
stage().appendChild(bit);
|
||||||
|
|
||||||
|
// Fired into a cone that mostly goes up, then let gravity have it.
|
||||||
|
var angle = (-Math.PI / 2) + jitter(i, 1) * (opts.spread || 1.15);
|
||||||
|
var speed = 120 + Math.abs(jitter(i + 7, 1)) * 190;
|
||||||
|
var vx = Math.cos(angle) * speed;
|
||||||
|
var vy = Math.sin(angle) * speed;
|
||||||
|
var dropTo = c.y + 220 + jitter(i + 3, 60);
|
||||||
|
|
||||||
|
done.push(
|
||||||
|
bit
|
||||||
|
.animate(
|
||||||
|
[
|
||||||
|
{ transform: t(c.x, c.y, 0.6, 0), opacity: 1, offset: 0 },
|
||||||
|
{
|
||||||
|
transform: t(c.x + vx * 0.45, c.y + vy * 0.45, 1, jitter(i, 180)),
|
||||||
|
opacity: 1,
|
||||||
|
offset: 0.4,
|
||||||
|
},
|
||||||
|
// Held at full colour most of the way down: a piece of confetti that
|
||||||
|
// starts fading the moment it's thrown reads as smoke.
|
||||||
|
{
|
||||||
|
transform: t(c.x + vx * 0.75, c.y + vy * 0.3 + 110, 0.95, jitter(i, 380)),
|
||||||
|
opacity: 1,
|
||||||
|
offset: 0.75,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transform: t(c.x + vx * 0.9, dropTo, 0.9, jitter(i, 540)),
|
||||||
|
opacity: 0,
|
||||||
|
offset: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration: 900 + Math.abs(jitter(i + 11, 1)) * 500,
|
||||||
|
easing: "cubic-bezier(0.18, 0.7, 0.4, 1)",
|
||||||
|
fill: "both",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.finished.catch(function () {})
|
||||||
|
.then(
|
||||||
|
(function (b) {
|
||||||
|
return function () { b.remove(); };
|
||||||
|
})(bit)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.all(done);
|
||||||
|
}
|
||||||
|
|
||||||
|
// count rolls a number to a new value instead of swapping it. A chip count that
|
||||||
|
// jumps is a variable; one that climbs is a payout.
|
||||||
|
function count(el, to, opts) {
|
||||||
|
if (!el) return;
|
||||||
|
opts = opts || {};
|
||||||
|
var from = parseInt(String(el.textContent).replace(/[^0-9-]/g, ""), 10);
|
||||||
|
if (isNaN(from) || reduced || from === to) {
|
||||||
|
el.textContent = (to || 0).toLocaleString();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dur = opts.duration || 520;
|
||||||
|
var t0 = null;
|
||||||
|
if (el._petePop) el._petePop.cancel();
|
||||||
|
el._petePop = el.animate(
|
||||||
|
[{ transform: "scale(1)" }, { transform: "scale(1.12)" }, { transform: "scale(1)" }],
|
||||||
|
{ duration: dur, easing: "ease-out" }
|
||||||
|
);
|
||||||
|
|
||||||
|
function step(ts) {
|
||||||
|
if (t0 === null) t0 = ts;
|
||||||
|
var p = Math.min(1, (ts - t0) / dur);
|
||||||
|
var eased = 1 - Math.pow(1 - p, 3);
|
||||||
|
el.textContent = Math.round(from + (to - from) * eased).toLocaleString();
|
||||||
|
if (p < 1) requestAnimationFrame(step);
|
||||||
|
else el.textContent = (to || 0).toLocaleString();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.PeteFX = {
|
||||||
|
reduced: reduced,
|
||||||
|
chipsFor: chipsFor,
|
||||||
|
disc: disc,
|
||||||
|
jitter: jitter,
|
||||||
|
fly: fly,
|
||||||
|
flyMany: flyMany,
|
||||||
|
burst: burst,
|
||||||
|
count: count,
|
||||||
|
centre: centre,
|
||||||
|
};
|
||||||
|
})();
|
||||||
168
internal/web/static/js/games.js
Normal file
168
internal/web/static/js/games.js
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
// The money bar: chips, wallet, buying in, cashing out.
|
||||||
|
//
|
||||||
|
// Buying chips is not instant and cannot be. gogobee owns the euros and has no
|
||||||
|
// inbound API, so it polls Pete for the crossing, moves the money on its side,
|
||||||
|
// and pushes the verdict back — a few seconds, end to end. So the button does
|
||||||
|
// not lie about it: the chips show as "buying" until they are real, and this
|
||||||
|
// file polls until they are. Nothing spendable appears until gogobee has said
|
||||||
|
// it took the euros.
|
||||||
|
//
|
||||||
|
// Exposed as window.PeteGames so the table (blackjack.js) shares one view of
|
||||||
|
// the money rather than keeping a second copy that drifts from this one.
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var bar = document.querySelector("[data-chipbar]");
|
||||||
|
if (!bar) return;
|
||||||
|
|
||||||
|
var chipsEl = bar.querySelector("[data-chips]");
|
||||||
|
var pendingEl = bar.querySelector("[data-pending]");
|
||||||
|
var eurosEl = bar.querySelector("[data-euros]");
|
||||||
|
var amountEl = bar.querySelector("[data-buyin-amount]");
|
||||||
|
var buyBtn = bar.querySelector("[data-buyin]");
|
||||||
|
var cashBtn = bar.querySelector("[data-cashout]");
|
||||||
|
var msgEl = bar.querySelector("[data-chipbar-msg]");
|
||||||
|
|
||||||
|
var listeners = [];
|
||||||
|
var view = null;
|
||||||
|
var pollTimer = null;
|
||||||
|
var pollUntil = 0;
|
||||||
|
|
||||||
|
function money(n) {
|
||||||
|
return (n || 0).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function say(text, tone) {
|
||||||
|
if (!msgEl) return;
|
||||||
|
if (!text) { msgEl.classList.add("hidden"); return; }
|
||||||
|
msgEl.textContent = text;
|
||||||
|
msgEl.classList.remove("hidden");
|
||||||
|
msgEl.style.color = tone === "bad" ? "#cc3d4a" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function paint(v) {
|
||||||
|
var first = view === null;
|
||||||
|
view = v;
|
||||||
|
// The chip count rolls to its new value rather than jumping to it — the table
|
||||||
|
// times this to land with the chips that caused it, so a payout reads as the
|
||||||
|
// number catching up with the felt. On the first paint there's nothing to
|
||||||
|
// catch up with, so it just sets.
|
||||||
|
if (chipsEl) {
|
||||||
|
if (first || !window.PeteFX) chipsEl.textContent = money(v.chips);
|
||||||
|
else window.PeteFX.count(chipsEl, v.chips);
|
||||||
|
}
|
||||||
|
if (eurosEl) eurosEl.textContent = (v.euros || 0).toFixed(2);
|
||||||
|
|
||||||
|
if (pendingEl) {
|
||||||
|
if (v.pending > 0) {
|
||||||
|
pendingEl.textContent = "+" + money(v.pending) + " buying…";
|
||||||
|
pendingEl.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
pendingEl.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// You cannot cash out mid-hand: the stake is already on the table.
|
||||||
|
if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.hand;
|
||||||
|
if (buyBtn) buyBtn.disabled = v.chips + v.pending >= v.cap;
|
||||||
|
|
||||||
|
listeners.forEach(function (fn) { fn(v); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollPending keeps asking while a buy-in is in flight, and stops the moment
|
||||||
|
// it lands — or is refused, which looks the same from here (pending drops to
|
||||||
|
// zero) and is told apart by whether the chips arrived.
|
||||||
|
function pollPending() {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
if (Date.now() > pollUntil) {
|
||||||
|
say("gogobee hasn't answered yet. Your euros are safe — give it a moment and reload.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pollTimer = setTimeout(function () {
|
||||||
|
get().then(function (v) {
|
||||||
|
if (!v) return;
|
||||||
|
if (v.pending > 0) { pollPending(); return; }
|
||||||
|
if (v.chips > (pollPending.was || 0)) {
|
||||||
|
say("Chips are yours. Good luck!");
|
||||||
|
} else {
|
||||||
|
say("gogobee wouldn't cover that — not enough euros in your wallet.", "bad");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(path, body) {
|
||||||
|
return fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body || {}),
|
||||||
|
}).then(function (res) {
|
||||||
|
return res.json().catch(function () { return {}; }).then(function (data) {
|
||||||
|
if (!res.ok) throw new Error(data.error || "that didn't work");
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function get() {
|
||||||
|
return fetch("/api/games/table", { headers: { "Accept": "application/json" } })
|
||||||
|
.then(function (res) { return res.ok ? res.json() : null; })
|
||||||
|
.then(function (v) { if (v) paint(v); return v; })
|
||||||
|
.catch(function () { return null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buyBtn) {
|
||||||
|
buyBtn.addEventListener("click", function () {
|
||||||
|
var amount = parseInt(amountEl && amountEl.value, 10);
|
||||||
|
if (!(amount > 0)) { say("How many chips?", "bad"); return; }
|
||||||
|
|
||||||
|
buyBtn.disabled = true;
|
||||||
|
say("Asking gogobee for " + money(amount) + " euros…");
|
||||||
|
pollPending.was = view ? view.chips : 0;
|
||||||
|
|
||||||
|
request("/api/games/buyin", { amount: amount })
|
||||||
|
.then(function (v) {
|
||||||
|
paint(v);
|
||||||
|
pollUntil = Date.now() + 60000;
|
||||||
|
pollPending();
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
say(err.message, "bad");
|
||||||
|
buyBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cashBtn) {
|
||||||
|
cashBtn.addEventListener("click", function () {
|
||||||
|
cashBtn.disabled = true;
|
||||||
|
say("Cashing you out…");
|
||||||
|
request("/api/games/cashout", { amount: 0 })
|
||||||
|
.then(function (v) {
|
||||||
|
paint(v);
|
||||||
|
say("Chips are on their way back to euros. They'll show in your wallet shortly.");
|
||||||
|
// The euro balance Pete shows is whatever gogobee last told it, so it
|
||||||
|
// only moves once the credit has actually gone through over there.
|
||||||
|
setTimeout(get, 4000);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
say(err.message, "bad");
|
||||||
|
cashBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.PeteGames = {
|
||||||
|
// onUpdate registers a listener called on every fresh view of the money.
|
||||||
|
onUpdate: function (fn) { listeners.push(fn); if (view) fn(view); },
|
||||||
|
// apply pushes a view the table already fetched (a deal answers with one),
|
||||||
|
// so playing a hand doesn't need a second round-trip to refresh the chips.
|
||||||
|
apply: paint,
|
||||||
|
refresh: get,
|
||||||
|
post: request,
|
||||||
|
say: say,
|
||||||
|
view: function () { return view; },
|
||||||
|
};
|
||||||
|
|
||||||
|
get();
|
||||||
|
})();
|
||||||
45
internal/web/templates/_chipbar.html
Normal file
45
internal/web/templates/_chipbar.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{{define "_chipbar"}}
|
||||||
|
<section data-chipbar
|
||||||
|
class="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-x-6 gap-y-4">
|
||||||
|
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Chips in front of you</div>
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<span data-chips class="font-display text-3xl font-bold tabular-nums">—</span>
|
||||||
|
<span data-pending class="hidden text-sm font-semibold text-[color:var(--ink)]/60 animate-pulse"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your wallet</div>
|
||||||
|
<div class="flex items-baseline gap-1.5">
|
||||||
|
<span data-euros class="font-display text-2xl font-bold tabular-nums text-[color:var(--ink)]/70">—</span>
|
||||||
|
<span class="text-sm text-[color:var(--ink)]/40" title="Pete reads this off the game box every couple of minutes, so it lags a little.">€, give or take</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||||
|
<div class="flex items-center gap-1.5 rounded-full bg-[color:var(--ink)]/5 p-1.5">
|
||||||
|
<label for="buyin-amount" class="sr-only">How many chips?</label>
|
||||||
|
<input id="buyin-amount" data-buyin-amount type="number" min="1" step="1" value="100"
|
||||||
|
inputmode="numeric" enterkeyhint="go"
|
||||||
|
class="w-24 rounded-full bg-[color:var(--card)] px-3 py-1.5 text-sm font-semibold tabular-nums
|
||||||
|
border-2 border-[color:var(--ink)]/10 focus:outline-none focus:border-[color:var(--accent)]">
|
||||||
|
<button type="button" data-buyin
|
||||||
|
class="rounded-full bg-[color:var(--accent)] px-4 py-1.5 text-sm font-bold text-white shadow-pete
|
||||||
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Buy chips
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" data-cashout
|
||||||
|
class="rounded-full bg-[color:var(--card)] px-4 py-2 text-sm 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">
|
||||||
|
Cash out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p data-chipbar-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
132
internal/web/templates/blackjack.html
Normal file
132
internal/web/templates/blackjack.html
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
{{define "title"}}Blackjack · {{.Room.Name}}{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<div class="space-y-6" data-blackjack>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
|
||||||
|
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Back to the casino</span>
|
||||||
|
</a>
|
||||||
|
<h1 class="font-display text-3xl font-bold">Blackjack</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/50">Six decks · pays 3:2 · dealer hits soft 17</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "_chipbar" .}}
|
||||||
|
|
||||||
|
<!-- The felt. The two things on it that aren't cards are the shoe the cards
|
||||||
|
come out of and the rack the money comes out of — a chip on this table is
|
||||||
|
always travelling between one of those and the spot in front of you. -->
|
||||||
|
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
|
||||||
|
|
||||||
|
<div class="pete-rack" data-house aria-hidden="true">
|
||||||
|
<span data-chip="500" style="--stack: 5"></span>
|
||||||
|
<span data-chip="100" style="--stack: 7"></span>
|
||||||
|
<span data-chip="25" style="--stack: 4"></span>
|
||||||
|
<span data-chip="5" style="--stack: 6"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The shoe every card flies out of. -->
|
||||||
|
<div class="pete-shoe" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="relative space-y-8">
|
||||||
|
|
||||||
|
<!-- Dealer -->
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<span data-dealer-label class="text-xs font-bold uppercase tracking-wider text-white/60">Dealer</span>
|
||||||
|
<span data-dealer-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 data-dealer class="pete-hand" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- What just happened. -->
|
||||||
|
<div class="flex min-h-[2.75rem] items-center justify-center">
|
||||||
|
<!-- The pill is white, so its text is ink — not var(--ink), which is the
|
||||||
|
room's cream and would be white on white. -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Player: the bet sits in front of the cards it's riding on. -->
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<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 class="flex items-center gap-5">
|
||||||
|
<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 data-player class="pete-hand flex-1" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Betting: shown between hands. -->
|
||||||
|
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-6 gap-y-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
|
||||||
|
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
{{range .Denominations}}
|
||||||
|
<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-deal
|
||||||
|
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">
|
||||||
|
Deal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Playing: shown while a hand is live. -->
|
||||||
|
<section data-actions 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 justify-center gap-3">
|
||||||
|
<button type="button" data-move="hit"
|
||||||
|
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">
|
||||||
|
Hit
|
||||||
|
</button>
|
||||||
|
<button type="button" data-move="stand"
|
||||||
|
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">
|
||||||
|
Stand
|
||||||
|
</button>
|
||||||
|
<button type="button" data-move="double"
|
||||||
|
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">
|
||||||
|
Double
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<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
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script src="/static/js/casino-fx.js" defer></script>
|
||||||
|
<script src="/static/js/games.js" defer></script>
|
||||||
|
<script src="/static/js/blackjack.js" defer></script>
|
||||||
|
{{end}}
|
||||||
@@ -13,6 +13,81 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{{if .ShowRoster}}
|
||||||
|
<section class="mb-10" id="roster" data-stale="{{.RosterStale}}">
|
||||||
|
<div class="flex items-baseline justify-between mb-3">
|
||||||
|
<h2 class="font-display text-2xl font-bold">Out there right now</h2>
|
||||||
|
<p class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50" id="roster-status">
|
||||||
|
{{if .RosterStale}}last known — the wire's gone quiet{{else}}live{{end}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{{range .Roster}}
|
||||||
|
<li class="flex items-center gap-4 px-5 py-3">
|
||||||
|
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
|
||||||
|
<span class="font-semibold">{{.Name}}</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}}">
|
||||||
|
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{{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>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// The board is state, not a story: an open tab should keep telling the truth
|
||||||
|
// without a reload. Cheap poll — a handful of rows, no auth, no cache.
|
||||||
|
(function () {
|
||||||
|
var list = document.getElementById('roster-list');
|
||||||
|
var status = document.getElementById('roster-status');
|
||||||
|
var card = document.getElementById('roster-card');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = s == null ? '' : String(s);
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(a) {
|
||||||
|
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">' +
|
||||||
|
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
|
||||||
|
'<span class="font-semibold">' + esc(a.Name) + '</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') + '">' +
|
||||||
|
esc(a.Where) + idle + '</span></li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
fetch('/api/roster', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data) return;
|
||||||
|
var rows = data.adventurers || [];
|
||||||
|
list.innerHTML = rows.length
|
||||||
|
? rows.map(row).join('')
|
||||||
|
: '<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>';
|
||||||
|
status.textContent = data.stale ? "last known — the wire's gone quiet" : 'live';
|
||||||
|
card.classList.toggle('opacity-60', !!data.stale);
|
||||||
|
})
|
||||||
|
.catch(function () { /* transient — the next tick will do */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(refresh, 60000);
|
||||||
|
document.addEventListener('visibilitychange', function () {
|
||||||
|
if (!document.hidden) refresh();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .Stories}}
|
{{if .Stories}}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
{{range .Stories}}
|
{{range .Stories}}
|
||||||
|
|||||||
75
internal/web/templates/games.html
Normal file
75
internal/web/templates/games.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{{define "title"}}{{.Room.Name}}{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<div class="space-y-8">
|
||||||
|
|
||||||
|
<section class="rounded-3xl bg-[color:var(--card)] p-6 sm:p-8 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="font-display text-3xl sm:text-4xl font-bold">Welcome in 🎲</h1>
|
||||||
|
<p class="mt-2 max-w-xl text-[color:var(--ink)]/70">
|
||||||
|
Real euros, from the same wallet as everything else. Chips are one euro each,
|
||||||
|
and whatever you don't spend goes straight back when you cash out.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="hidden shrink-0 sm:flex items-end gap-1.5" aria-hidden="true">
|
||||||
|
<span class="cs-stack" data-chip="5"></span>
|
||||||
|
<span class="cs-stack" data-chip="100" style="--stack:3"></span>
|
||||||
|
<span class="cs-stack" data-chip="25" style="--stack:2"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{template "_chipbar" .}}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="font-display text-2xl font-bold mb-4">The tables</h2>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
|
||||||
|
<a href="/games/blackjack"
|
||||||
|
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">🃏</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="font-display text-xl font-bold">Blackjack</h3>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60">Six decks, blackjack pays 3:2.</p>
|
||||||
|
</div>
|
||||||
|
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
|
||||||
|
The dealer stands on 17 and hits a soft one. House takes {{.RakePct}}% of what you win,
|
||||||
|
and nothing at all when you lose or push.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{range .Soon}}
|
||||||
|
<div class="rounded-3xl bg-[color:var(--card)]/60 p-6 shadow-pete border-2 border-dashed border-[color:var(--ink)]/15">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--ink)]/5 text-2xl grayscale">{{.Emoji}}</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="font-display text-xl font-bold text-[color:var(--ink)]/50">{{.Name}}</h3>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/40">{{.Blurb}}</p>
|
||||||
|
</div>
|
||||||
|
<span class="ml-auto shrink-0 rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 text-xs font-bold uppercase tracking-wider text-[color:var(--ink)]/40">Soon</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
<h2 class="font-display text-xl font-bold mb-2">House rules</h2>
|
||||||
|
<ul class="space-y-1.5 text-sm text-[color:var(--ink)]/70">
|
||||||
|
<li>· A chip is a euro. Nothing is worth more here than it is out there.</li>
|
||||||
|
<li>· You can have {{.Cap}} chips in front of you at most. That's the limit for one sitting.</li>
|
||||||
|
<li>· The house takes {{.RakePct}}% of your winnings. Never your stake: a push hands your bet straight back.</li>
|
||||||
|
<li>· Walk away for half an hour and the house cashes you out for you, so your euros never sit in limbo.</li>
|
||||||
|
<li>· Every hand is logged with the seed it was dealt from, so any hand can be dealt again exactly as it fell.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}<script src="/static/js/games.js" defer></script>{{end}}
|
||||||
91
internal/web/templates/games_layout.html
Normal file
91
internal/web/templates/games_layout.html
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
{{define "layout"}}<!doctype html>
|
||||||
|
<html lang="en" data-room="{{.Room.Slug}}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{block "title" .}}{{.Room.Name}}{{end}}</title>
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<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 rel="stylesheet" href="/static/css/output.css">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,3 27,9.5 27,22.5 16,29 5,22.5 5,9.5' fill='%23f2b53d' stroke='%232b2118' stroke-width='2.5' stroke-linejoin='round'/%3E%3C/svg%3E" type="image/svg+xml">
|
||||||
|
<meta name="theme-color" content="#17231d">
|
||||||
|
<script>
|
||||||
|
// Which room you're standing in, from your clock rather than the server's:
|
||||||
|
// Casinopolis in daylight, Casino Night Zone once the lights come on at six.
|
||||||
|
// Same rule as roomAt() in games_pages.go — keep the two in step.
|
||||||
|
//
|
||||||
|
// The server already painted its best guess into the markup, so this only
|
||||||
|
// corrects a player whose evening isn't the server's.
|
||||||
|
(function () {
|
||||||
|
var ROOMS = {
|
||||||
|
"casinopolis": "Casinopolis",
|
||||||
|
"casino-night": "Casino Night Zone",
|
||||||
|
};
|
||||||
|
function roomAt(h) { return (h >= 6 && h < 18) ? "casinopolis" : "casino-night"; }
|
||||||
|
function apply() {
|
||||||
|
var slug = roomAt(new Date().getHours());
|
||||||
|
// The palette can be set from <head> — the sign can't: this script runs
|
||||||
|
// before the header exists. So set the attribute now (no flash of the
|
||||||
|
// wrong room) and always re-stamp the name, which is a no-op until the
|
||||||
|
// header is there to stamp. Bailing out early when the slug already
|
||||||
|
// matches would leave the server's name under the browser's palette.
|
||||||
|
document.documentElement.dataset.room = slug;
|
||||||
|
document.querySelectorAll("[data-room-name]").forEach(function (el) {
|
||||||
|
el.textContent = ROOMS[slug];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
apply();
|
||||||
|
document.addEventListener("DOMContentLoaded", apply);
|
||||||
|
setInterval(apply, 60000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-[color:var(--bg)] text-[color:var(--ink)]">
|
||||||
|
<div class="pointer-events-none fixed inset-0 -z-10 cs-room" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<header class="mx-auto max-w-5xl px-4 pt-6 pb-4 sm:pt-10">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<a href="/games" class="group flex min-w-0 items-center gap-3">
|
||||||
|
{{template "_comb" .}}
|
||||||
|
<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 class="mt-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[color:var(--ink)]/45">Chips are euros</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{if .User}}
|
||||||
|
<a href="/auth/logout" title="Signed in as {{.User.Display}} — sign out"
|
||||||
|
class="inline-flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-2.5 py-1.5 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
||||||
|
<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>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="mx-auto max-w-5xl px-4 pb-20">
|
||||||
|
{{block "main" .}}{{end}}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="mx-auto max-w-5xl px-4 pb-10 text-center text-xs text-[color:var(--ink)]/40">
|
||||||
|
Play for what you can lose. Cash out whenever you like.
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{{block "scripts" .}}{{end}}
|
||||||
|
</body>
|
||||||
|
</html>{{end}}
|
||||||
|
|
||||||
|
{{/* The house mark: a honeycomb cell struck like a chip. Stands in for a logo,
|
||||||
|
and deliberately isn't a face — nobody is watching you play. */}}
|
||||||
|
{{define "_comb"}}
|
||||||
|
<span class="cs-comb grid h-11 w-11 shrink-0 place-items-center transition-transform group-hover:-rotate-6" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 32 32" class="h-11 w-11">
|
||||||
|
<polygon points="16,2.5 27.5,9.25 27.5,22.75 16,29.5 4.5,22.75 4.5,9.25"
|
||||||
|
fill="var(--accent)" stroke="var(--ink)" stroke-width="2.5" stroke-linejoin="round"/>
|
||||||
|
<polygon points="16,9 21.5,12.25 21.5,18.75 16,22 10.5,18.75 10.5,12.25"
|
||||||
|
fill="none" stroke="var(--ink)" stroke-width="1.75" stroke-linejoin="round" opacity="0.5"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
@@ -322,5 +322,6 @@
|
|||||||
<script src="/static/js/settings.js" defer></script>
|
<script src="/static/js/settings.js" defer></script>
|
||||||
<script src="/static/js/reader.js" defer></script>
|
<script src="/static/js/reader.js" defer></script>
|
||||||
<script src="/static/js/pwa.js" defer></script>
|
<script src="/static/js/pwa.js" defer></script>
|
||||||
|
{{block "scripts" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>{{end}}
|
</html>{{end}}
|
||||||
|
|||||||
80
internal/web/zz_drive_test.go
Normal file
80
internal/web/zz_drive_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/config"
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDrive is not a test. It is a hand crank: it stands the casino up on a real
|
||||||
|
// port with one funded player so a browser can be pointed at it. Skipped unless
|
||||||
|
// PETE_DRIVE is set. Delete it if it ever stops earning its keep.
|
||||||
|
func TestDrive(t *testing.T) {
|
||||||
|
if os.Getenv("PETE_DRIVE") == "" {
|
||||||
|
t.Skip("set PETE_DRIVE=1 to hand-drive the casino")
|
||||||
|
}
|
||||||
|
// Built through New() with the tables already open, because the /games routes
|
||||||
|
// are registered at construction — newCasino() switches them on afterwards,
|
||||||
|
// which is fine for handler tests and useless for a browser.
|
||||||
|
storage.Close()
|
||||||
|
if err := storage.Init(filepath.Join(t.TempDir(), "drive.db")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer storage.Close()
|
||||||
|
|
||||||
|
s, err := New(config.WebConfig{
|
||||||
|
SiteTitle: "Pete",
|
||||||
|
ListenAddr: ":0",
|
||||||
|
BaseURL: "http://127.0.0.1",
|
||||||
|
Games: config.GamesConfig{Enabled: true, MatrixServer: "parodia.dev"},
|
||||||
|
}, nil, true, config.AdventureConfig{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// New() only mounts the casino when auth is live, and auth means an OIDC
|
||||||
|
// discovery call. Sign the cookie by hand and mount the routes here instead —
|
||||||
|
// the handshake is not what a browser is here to look at.
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
fund(t, 5000)
|
||||||
|
|
||||||
|
static, err := fs.Sub(staticFS, "static")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
|
||||||
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
|
||||||
|
mux.HandleFunc("GET /games", s.handleLobby)
|
||||||
|
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
|
||||||
|
mux.HandleFunc("GET /api/games/table", s.handleTable)
|
||||||
|
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
|
||||||
|
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
|
||||||
|
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
|
||||||
|
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
payload, _ := json.Marshal(SessionUser{
|
||||||
|
Sub: "sub-1", Username: "reala", Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
fmt.Printf("DRIVE_URL=%s\nDRIVE_COOKIE=%s=%s\n", srv.URL, sessionCookie, s.auth.sign(payload))
|
||||||
|
|
||||||
|
// Long enough for a browser to buy in, deal a few hands, and be screenshotted.
|
||||||
|
deadline := time.Now().Add(3 * time.Minute)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if _, err := http.Get(srv.URL + "/healthz"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
main.go
13
main.go
@@ -267,11 +267,16 @@ func main() {
|
|||||||
|
|
||||||
// Adapter: the web ingest handler posts priority adventure beats live to
|
// Adapter: the web ingest handler posts priority adventure beats live to
|
||||||
// Matrix through the same queue everything else uses (bypasses pacing).
|
// Matrix through the same queue everything else uses (bypasses pacing).
|
||||||
// PostNow doesn't consult posting.enabled, so gate here: with posting off,
|
//
|
||||||
// adventure stays website-only like every other channel (nil poster also
|
// Adventure carries its own enable switch and is push-based: facts arrive
|
||||||
// keeps the digest loop from starting).
|
// from gogobee at ingest, they are not polled, classified or paced, and they
|
||||||
|
// never enter the round-robin rotation. So it is gated on [adventure] alone,
|
||||||
|
// NOT on posting.enabled — that flag governs the RSS pipeline's chatter, and
|
||||||
|
// an operator running "news on the web only, adventure live in the games
|
||||||
|
// room" is a legitimate configuration. A nil poster (adventure off, or no
|
||||||
|
// channel) still keeps both the live beats and the digest loop shut.
|
||||||
var advPost web.PriorityPoster
|
var advPost web.PriorityPoster
|
||||||
if postingEnabled {
|
if cfg.Adventure.Enabled && cfg.Adventure.Channel != "" {
|
||||||
advPost = func(p web.AdvPost) {
|
advPost = func(p web.AdvPost) {
|
||||||
queue.PostNow(poster.QueueItem{
|
queue.PostNow(poster.QueueItem{
|
||||||
GUID: p.GUID,
|
GUID: p.GUID,
|
||||||
|
|||||||
520
pete_games_plan.md
Normal file
520
pete_games_plan.md
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
# Pete Games — games.parodia.dev
|
||||||
|
|
||||||
|
A web casino/arcade on Pete, authenticated by Authentik, playing for gogobee euros.
|
||||||
|
Blackjack, Texas Hold'em, UNO (normal + no-mercy), Hangman, Trivia.
|
||||||
|
|
||||||
|
Companion to `gogobee_mischief_plan.md`, which already established the web↔game seam.
|
||||||
|
This plan reuses that seam wholesale and does not invent a second one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Progress — last updated 2026-07-14
|
||||||
|
|
||||||
|
A multi-session build. This section is the handover; read it before anything else.
|
||||||
|
|
||||||
|
### Decisions taken (these close §9's open questions)
|
||||||
|
|
||||||
|
- **Chips are 1:1 with euros.** No second denomination.
|
||||||
|
- **Session buy-in cap: €10,000**, enforced against chips held *plus* buy-ins still
|
||||||
|
in flight, so it can't be cleared by firing several requests at once.
|
||||||
|
- **A house rake**, 5% in blackjack's `DefaultRules`, taken from *winnings only* —
|
||||||
|
never the stake. A push returns the bet untouched; a loss is never charged a fee.
|
||||||
|
- **The site shares Pete's design, not Pete's shell.** *(Revised 2026-07-13 — this
|
||||||
|
replaces the earlier "the site must look like Pete", which meant `layout.html`
|
||||||
|
itself.)* The casino is its own place. It takes the design language — Fredoka/
|
||||||
|
Nunito, the four palette vars, `rounded-3xl`, `shadow-pete`, the bubbly weight of
|
||||||
|
everything — and takes none of the furniture: no Pete avatar, no channel nav, no
|
||||||
|
search, no reader, no settings, no weather canvas, no PWA. It has its own layout
|
||||||
|
(`games_layout.html`), its own header, its own footer, its own scripts. Still not
|
||||||
|
an SPA; still server-rendered `html/template`.
|
||||||
|
- **It has two names, on a clock.** Casinopolis by day, Casino Night Zone from six
|
||||||
|
in the evening — palette, felt and the sign over the door all change together.
|
||||||
|
This is the news app's phase system pointed at a joke: one `data-room` attribute,
|
||||||
|
two palette blocks, and a rule shared between `roomAt()` in Go (first paint) and
|
||||||
|
the same rule in JS (the player's own clock, so a player abroad gets their own
|
||||||
|
evening).
|
||||||
|
- **Dealing is animated.** Cards visibly dealt and flipped, chips that move. This is
|
||||||
|
a requirement, not polish to drop when the clock runs out.
|
||||||
|
|
||||||
|
### Done
|
||||||
|
|
||||||
|
- **Phase 0 — euro idempotency (gogobee).** `euro_transactions.external_id` + a
|
||||||
|
partial unique index, and `CreditIdem`/`DebitIdem` in `internal/plugin/euro.go`:
|
||||||
|
balance mutation and transaction log in one tx, keyed by the escrow GUID. A replay
|
||||||
|
reports success without moving money again; a rejection writes nothing, so the same
|
||||||
|
GUID stays retryable once the player is good for it. Six tests, including eight
|
||||||
|
goroutines racing one GUID. *(gogobee `ab2bcf0`)*
|
||||||
|
- **`pete/internal/games/cards`** — the shared deck gogobee never had. RNG is
|
||||||
|
threaded, never the package global, so a hand is reproducible from its seed.
|
||||||
|
*(pete `8310b30`)*
|
||||||
|
- **`pete/internal/games/blackjack`** — pure reducer,
|
||||||
|
`ApplyMove(state, move) (state, []Event, error)`, where an error means the move was
|
||||||
|
illegal and nothing else. State is a plain value: it serializes, so a hand survives
|
||||||
|
a redeploy, and it replays. Six decks, 3:2, dealer hits soft 17, plus the rake.
|
||||||
|
*(pete `8310b30`)*
|
||||||
|
- **`pete/internal/storage/games.go`** — the euro/chip border. `game_chips`,
|
||||||
|
`game_escrow`, `game_hands`. Chips appear only once gogobee confirms it took the
|
||||||
|
euros; chips are destroyed the moment a cash-out opens (so they can't be bet while
|
||||||
|
their euros are in flight) and come back if the credit fails. Table cap, 30-minute
|
||||||
|
reaper, per-hand audit log with seeds. 17 tests. *(pete `f9a98f7`)*
|
||||||
|
|
||||||
|
- **The wire protocol.** Pete serves `GET /api/games/escrow/pending`, `POST …/claim`,
|
||||||
|
`POST …/settled` (`internal/web/games.go`), bearer-authed on the adventure ingest
|
||||||
|
token. gogobee polls every 3s (`internal/plugin/pete_games.go`), claims a row,
|
||||||
|
calls `DebitIdem`/`CreditIdem` against the escrow GUID, and pushes the verdict
|
||||||
|
back through `pete_emit_queue` — which grew a `path` column so escrow verdicts
|
||||||
|
ride the same durable queue as adventure facts rather than getting a second one.
|
||||||
|
`peteclient.Flush` sends the verdict immediately instead of waiting out the 15s
|
||||||
|
sender tick, because a player is watching a spinner. A row re-offered after a
|
||||||
|
gogobee crash replays as a no-op: 13 tests across both repos, including a fake
|
||||||
|
Pete that offers the same row three times and a player who is charged once.
|
||||||
|
|
||||||
|
- **Identity.** `preferred_username` now rides in the signed session, and
|
||||||
|
`SessionUser.MatrixUser(server)` maps it to `@user:parodia.dev`. The session cookie
|
||||||
|
takes an opt-in `web.auth.cookie_domain`, so a sign-in on news is a sign-in on games;
|
||||||
|
the OAuth round-trip cookie deliberately stays host-only, and the redirect_uri is
|
||||||
|
derived per-request so a login that starts on games comes back to games. A Host we
|
||||||
|
don't own is never echoed into a redirect. *(pete `cb84e1d`)*
|
||||||
|
- **Blackjack, playable end to end.** `game_live_hands` (the hand in progress,
|
||||||
|
engine state and all, so a redeploy mid-hand is survivable), the session-authed play
|
||||||
|
surface (`internal/web/games_play.go`), the lobby and table pages, and the dealing
|
||||||
|
animation. Driven in a real browser: chips staked before the deal, hole card withheld
|
||||||
|
from the payload until the reveal, payout settled back into the stack.
|
||||||
|
|
||||||
|
- **The casino moved out.** Its own layout (`games_layout.html`), parsed as its own
|
||||||
|
template set alongside the news one; `gamesPage` no longer embeds the news
|
||||||
|
`pageData`, which is what stops the old furniture drifting back one convenient
|
||||||
|
field at a time. Two rooms on a clock (above), the felt reupholstered from the
|
||||||
|
room's vars, and a house mark that is a honeycomb chip rather than a face.
|
||||||
|
- **The cards are cards.** Corner indices in both corners (the bottom one upside
|
||||||
|
down, as printed), pips laid out on the three-by-seven grid a real deck uses,
|
||||||
|
bottom-half pips inverted, courts as a letter with the suit over each shoulder,
|
||||||
|
and a screen-reader label that says "Queen of hearts" instead of "Q♥".
|
||||||
|
|
||||||
|
- **The money moves.** The felt grew the two things it was missing: a **bet spot**
|
||||||
|
in front of you and the **house's rack** beside the shoe, so every chip on the
|
||||||
|
table is always travelling between one of those and the other. A bet is *built*
|
||||||
|
by throwing chips onto the spot (the chip you clicked is the chip that flies);
|
||||||
|
the stake sits there through the hand; the house pays out of its rack into the
|
||||||
|
spot; the whole pile is then swept back to your pile. A loss goes to the rack and
|
||||||
|
doesn't come back. `casino-fx.js` is the shared engine — `fly`/`flyMany` (WAAPI,
|
||||||
|
on an arc, out of a fixed overlay so nothing clips them), `chipsFor` (an amount
|
||||||
|
broken into the fewest chips, capped at what's worth watching), `burst`, `count`.
|
||||||
|
|
||||||
|
Two rules hold it together, and both are load-bearing:
|
||||||
|
|
||||||
|
1. **The number under the pile is a readout of the pile**, never the other way
|
||||||
|
round. So the bet starts at zero rather than at a default nobody put down, and
|
||||||
|
a settled hand puts your stake *back on the spot* as a standing bet — otherwise
|
||||||
|
the panel prints "your bet: 300" over an empty circle.
|
||||||
|
2. **The chip bar does not move until the chips that justify it have landed.** On
|
||||||
|
a live hand the money applies immediately (your stake left your pile and is
|
||||||
|
visibly on the spot); on a settling hand `play()` holds the apply until the
|
||||||
|
payout has swept home. A counter that pays you before the dealer turns over is
|
||||||
|
a counter that has told you the ending.
|
||||||
|
|
||||||
|
Also: cards land with weight (overshoot, a shadow that takes the hit, a degree or
|
||||||
|
two of resting tilt each), the dealer takes a beat before drawing out, and a
|
||||||
|
natural gets confetti — the only thing in the room that does.
|
||||||
|
|
||||||
|
- **A way to actually look at it.** `internal/web/devcasino_test.go` is the casino on
|
||||||
|
a port with one signed-in, funded player: `PETE_DEV_CASINO=:7788 go test
|
||||||
|
./internal/web -run TestDevCasino -timeout 0`. Skipped without the env var. It
|
||||||
|
wires its own routes because `New()` decides whether the casino exists at the
|
||||||
|
moment it builds the mux, and the test rig signs the player in afterwards. **Drive
|
||||||
|
the table in a real browser before believing anything about it** — this pass found
|
||||||
|
a white-on-white verdict pill, a rack that collided with the dealer, and Hit still
|
||||||
|
being offered over a table that was being paid out, none of which a Go test can see.
|
||||||
|
|
||||||
|
### Next, in order
|
||||||
|
|
||||||
|
1. **Deploy.** Add the `games.parodia.dev` redirect URI to the `pete` app in Authentik,
|
||||||
|
point Caddy at the same port, set `[web.games]` + `web.auth.cookie_domain` on the
|
||||||
|
server. Nothing else is host-specific.
|
||||||
|
2. Then Phase 2 (trivia, hangman), 3 (UNO), 4 (hold'em) as below.
|
||||||
|
|
||||||
|
Still open on the table itself, none of it blocking: **split** isn't implemented (the
|
||||||
|
engine has no move for it), the felt is roomy at desktop widths with only one seat on
|
||||||
|
it, and the chip animations are tuned for one player — a second seat would need the
|
||||||
|
spot to be per-seat rather than the singleton it is now.
|
||||||
|
|
||||||
|
### How the browser half fits together
|
||||||
|
|
||||||
|
- `GET /games` (lobby), `GET /games/blackjack` (table) — signed-in only. On the games
|
||||||
|
host, the mux prefixes `/games` onto the path, so the lobby is that host's `/`. Shared
|
||||||
|
paths (`/api/`, `/auth/`, `/static/`) mean the same thing on every host and are left
|
||||||
|
alone.
|
||||||
|
- `GET /api/games/table`, `POST /api/games/{buyin,cashout}`,
|
||||||
|
`POST /api/games/blackjack/{deal,move}` — session-authed, JSON, all returning the same
|
||||||
|
`tableView` so the money and the felt can never disagree.
|
||||||
|
- **The browser never sees the shoe.** The dealer's hole card is *absent* from the
|
||||||
|
payload — not flagged hidden — until the reveal, and the deck lives only in
|
||||||
|
`game_live_hands`. The response carries the engine's events (one per card off the
|
||||||
|
shoe), which is what the table plays back as an animation.
|
||||||
|
- Money order-of-operations: stake leaves the stack *before* the hand is dealt, in the
|
||||||
|
same statement that checks it's there; the hand is *seated* (a plain INSERT on the
|
||||||
|
primary key) before it can settle, which is what makes a double-clicked Deal a 409 with
|
||||||
|
the stake refunded rather than a silently overwritten hand.
|
||||||
|
|
||||||
|
### Notes for whoever picks this up
|
||||||
|
|
||||||
|
- SQLite runs at `MaxOpenConns(1)` in *both* repos. Any `db.Get().Exec` inside an
|
||||||
|
open transaction deadlocks against itself. Do the pre-work before `Begin`.
|
||||||
|
- **A buy-in can currently take a player into debt.** `DebitIdem` inherits
|
||||||
|
`BLACKJACK_DEBT_LIMIT` (default −1000), so someone with an empty wallet can buy
|
||||||
|
€1,000 of chips, win, and cash out while still €1,000 down. That is exactly what
|
||||||
|
gogobee's Matrix blackjack already allows, so it is consistent rather than a bug —
|
||||||
|
but a web casino runs far more hands, and this is the knob to turn if the economy
|
||||||
|
starts leaking. A buy-in-specific floor of 0 is a two-line change.
|
||||||
|
- gogobee's blackjack taxes 5% of the *gross* payout into a community pot
|
||||||
|
(`communityTax`). Pete's rake takes 5% of the *profit*. Deliberately different, and
|
||||||
|
gentler; don't "fix" one to match the other without deciding which is right.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The three constraints everything else follows from
|
||||||
|
|
||||||
|
**gogobee owns the euros.** The ledger is `euro_balances` / `euro_transactions`
|
||||||
|
(gogobee `internal/db/db.go:1316,1324`), tied to the wider economy — adventure, shop,
|
||||||
|
lottery, mischief. Pete does not get a second wallet. Pete never writes a balance.
|
||||||
|
|
||||||
|
**gogobee has no inbound API and isn't getting one.** The only listening socket in the
|
||||||
|
whole repo is the Matrix appservice transaction endpoint (`internal/bot/appservice.go:255`).
|
||||||
|
Pete's own source says it plainly (`internal/web/roster.go:23`):
|
||||||
|
|
||||||
|
> Direction of travel is gogobee → Pete ... Pete has no route back into the game box's
|
||||||
|
> network and we are not opening one.
|
||||||
|
|
||||||
|
So gogobee stays the only initiator. It **polls** Pete for work and **pushes** results
|
||||||
|
back through the existing durable queue. Same as mischief (`gogobee_mischief_plan.md:191-197`).
|
||||||
|
|
||||||
|
**One binary.** Games live in the Pete process. gogobee already runs ~50 plugins and six
|
||||||
|
games in one process with in-memory table state and it's fine. Caddy points
|
||||||
|
`games.parodia.dev` at the same port; the mux branches on Host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Identity — free, no link codes
|
||||||
|
|
||||||
|
MAS imports the Authentik `preferred_username` as the Matrix localpart
|
||||||
|
(`gogobee_mischief_plan.md:176-186`). So an Authentik session on Pete maps to a Matrix
|
||||||
|
user deterministically:
|
||||||
|
|
||||||
|
```
|
||||||
|
OIDC preferred_username -> strings.ToLower(u) -> @<u>:parodia.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Pete's `SessionUser` (`internal/web/auth.go`) carries `Sub`/`Name`/`Email` today. Add
|
||||||
|
`PreferredUsername` to the claims struct and the signed cookie payload. That is the whole
|
||||||
|
identity story.
|
||||||
|
|
||||||
|
Note the existing precedent: `email_nag.go:52` already asserts "Authentik usernames ==
|
||||||
|
Matrix localparts".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Money — session escrow, not per-hand settlement
|
||||||
|
|
||||||
|
### Why not settle each hand
|
||||||
|
|
||||||
|
Mischief is fire-and-forget: place an order, gogobee claims it within 30s, nobody cares.
|
||||||
|
A blackjack hand cannot work that way. If every bet round-trips through a poll loop you
|
||||||
|
wait half a minute to be dealt, and again for the payout.
|
||||||
|
|
||||||
|
### The model
|
||||||
|
|
||||||
|
Borrow the semantics hold'em already uses — `euro.Debit(..., "holdem_buyin")`
|
||||||
|
(`holdem.go:319`), `euro.Credit(..., "holdem_cashout")` (`holdem.go:371`) — and apply it
|
||||||
|
to the whole web casino:
|
||||||
|
|
||||||
|
1. **Buy in.** You convert euros to *chips* for a games session. One debit. Tolerates poll latency.
|
||||||
|
2. **Play.** Blackjack, UNO, hold'em, all at full speed against chips held in Pete's SQLite.
|
||||||
|
Zero economy calls in the hot path.
|
||||||
|
3. **Cash out.** Chips convert back to euros. One credit. Tolerates the same latency.
|
||||||
|
|
||||||
|
Two economy touches per *session* instead of two per *hand*. Poll latency stops mattering.
|
||||||
|
|
||||||
|
### The invariant
|
||||||
|
|
||||||
|
> A euro is either in gogobee's `euro_balances` or in Pete's chip escrow. Never both.
|
||||||
|
> It moves between them only via a GUID-idempotent claim.
|
||||||
|
|
||||||
|
Pete's balance display is advisory only, sourced from the roster push and up to 2 minutes
|
||||||
|
stale. The authoritative check is `euro.Debit` at claim time. This preserves
|
||||||
|
`gogobee_mischief_plan.md:198-202` — *"Pete never writes a balance, so no double-spend surface."*
|
||||||
|
|
||||||
|
### The prerequisite: euro idempotency (BLOCKING)
|
||||||
|
|
||||||
|
`euro_transactions` (`db.go:1324`) has **no external id and no unique constraint**. `Debit`
|
||||||
|
is an atomic conditional UPDATE, but calling it twice debits twice. That is safe today only
|
||||||
|
because every caller is a Matrix message that arrives once. A *retrying poll loop* breaks
|
||||||
|
that: a claim that succeeds but whose ack is lost on the wire gets retried, and the player
|
||||||
|
pays twice.
|
||||||
|
|
||||||
|
**Before any of this ships:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE euro_transactions ADD COLUMN external_id TEXT;
|
||||||
|
CREATE UNIQUE INDEX idx_euro_tx_external ON euro_transactions(external_id)
|
||||||
|
WHERE external_id IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
plus `CreditIdem(userID, amount, reason, externalID)` / `DebitIdem(...)` in `euro.go` that
|
||||||
|
do the balance mutation and the transaction insert **in one tx**, and treat a unique-violation
|
||||||
|
on `external_id` as success-already-applied. Everything web-initiated goes through these.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The wire protocol
|
||||||
|
|
||||||
|
All new Pete endpoints are bearer-authed with the existing ingest token
|
||||||
|
(`internal/web/adventure.go:307` `bearerOK`). gogobee grows its first GET path in
|
||||||
|
`internal/peteclient/client.go` — the poll loop the mischief plan already calls for.
|
||||||
|
|
||||||
|
### Pete serves (gogobee polls, ~3s interval)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/games/escrow/pending -> [{guid, matrix_user, kind: buyin|cashout, amount}]
|
||||||
|
POST /api/games/escrow/claim <- {guid} idempotent, marks claimed
|
||||||
|
```
|
||||||
|
|
||||||
|
### gogobee pushes (existing peteclient queue, guid-idempotent)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/games/escrow/settled -> {guid, ok: bool, reason?: "insufficient_funds", balance_after}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reuse `pete_emit_queue` (`client.go:121-125`, `INSERT OR IGNORE` on `guid` PK) — it already
|
||||||
|
does durability, backoff and parking. Don't build a second queue.
|
||||||
|
|
||||||
|
### State machine (Pete side, table `game_escrow`)
|
||||||
|
|
||||||
|
```
|
||||||
|
requested -> claimed -> funded (buyin ok; chips become spendable)
|
||||||
|
-> rejected (insufficient funds; nothing spendable)
|
||||||
|
requested -> claimed -> settled (cashout ok; chips destroyed, euros credited)
|
||||||
|
```
|
||||||
|
|
||||||
|
Poll interval 3s, not 30s: a player waiting to be dealt is watching a spinner. 3s of
|
||||||
|
"buying chips…" is acceptable; 30s is not.
|
||||||
|
|
||||||
|
### The reaper
|
||||||
|
|
||||||
|
Chips left in an abandoned session are euros in limbo. Auto-cash-out any session idle for
|
||||||
|
30 minutes. A crashed Pete must reconcile on boot: any `claimed` escrow with no `settled`
|
||||||
|
push gets re-polled by GUID.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Code reuse — copy, don't share
|
||||||
|
|
||||||
|
Separate modules, both mine, and the shells diverge (Matrix vs HTTP). Copy the pure cores
|
||||||
|
into `pete/internal/games/`, let them drift, no shared module.
|
||||||
|
|
||||||
|
### Verdict per game
|
||||||
|
|
||||||
|
| Game | Copy | Rewrite | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Hold'em** | ~2,700 LOC | the shell | The crown jewel. Take it all. |
|
||||||
|
| **UNO** | ~1,400 LOC | the turn engine | Great primitives, unshippable engine. |
|
||||||
|
| **Hangman** | ~250 LOC | loading/persistence | Clean rune-safe state machine. |
|
||||||
|
| **Blackjack** | ~95 LOC | everything else | 95 lines is the entire core. |
|
||||||
|
| **Trivia** | ~80 LOC | everything else | **No question bank exists.** |
|
||||||
|
|
||||||
|
### Hold'em — take almost all of it
|
||||||
|
|
||||||
|
Already mautrix-free, verified by import check:
|
||||||
|
|
||||||
|
- `holdem_cfr.go` (1,285) — full CFR trainer + NPC policy runtime, info-set packing into a
|
||||||
|
`uint64`, regret pruning, board-texture/SPR/equity bucketing. Plus the trained
|
||||||
|
`data/policy.gob` (3.4 MB) and `cmd/holdem-train`, `cmd/holdem-seed`. **This is the single
|
||||||
|
highest-value asset in either repo.**
|
||||||
|
- `holdem_equity.go` + `holdem_equity_range.go` (548) — Monte-Carlo equity, equity-vs-range,
|
||||||
|
draw/out detection. 100% pure, well tested.
|
||||||
|
- `holdem_betting.go` (383) — side pots, min-raise, all-in, street completion. The fiddly
|
||||||
|
poker rules you do not want to rewrite. **Untested in gogobee — write tests as you port.**
|
||||||
|
- `holdem_game.go`, `holdem_eval.go`, `holdem_render.go`.
|
||||||
|
|
||||||
|
Entanglements to break (mechanical):
|
||||||
|
1. `id.UserID` → `PlayerID string` (`holdem_betting.go:283,314`; `holdem_eval.go` winnings maps).
|
||||||
|
2. Delete `RoomID`/`DMRoomID` from `HoldemGame` — table identity belongs to the shell.
|
||||||
|
3. Hoist the four `*time.Timer` fields out of `HoldemGame` (`holdem_game.go:92-95`).
|
||||||
|
4. `LoadPolicy(path)` does `os.Open` → `LoadPolicyFrom(io.Reader)`, so the policy can be `embed.FS`'d.
|
||||||
|
|
||||||
|
Hand evaluation is **not** homegrown — `holdem_eval.go:12` wraps `poker.Evaluate`. Just take
|
||||||
|
the `github.com/chehsunliu/poker` dependency.
|
||||||
|
|
||||||
|
### UNO — lift the primitives, rewrite the engine
|
||||||
|
|
||||||
|
Copy verbatim (already unit-tested in `uno_test.go`):
|
||||||
|
- `unoCard`/`unoColor`/`unoValue`, `canPlayOn`, `newUnoDeck`, draw/reshuffle (`uno.go:21-364`)
|
||||||
|
- The bot AI as free functions: `botPickCard`, `botPickNormal`, `botPickAggressive`,
|
||||||
|
`botPickColor` (`uno.go:1465-1585`)
|
||||||
|
- `uno_nomercy.go` is ~90% pure: scoring, stacking rules, no-mercy deck, second bot.
|
||||||
|
|
||||||
|
**Rewrite the turn engine.** In gogobee the engine *is* the message sender —
|
||||||
|
`executeMultiTurn`, `applyAndAnnounce`, `handlePlayerPlay` mutate state and call
|
||||||
|
`p.SendReply(...)` mid-turn, and their `error` returns mean "send failed", not "illegal move".
|
||||||
|
There is no `ApplyMove(game, move) (Result, error)` seam anywhere. Disentangling that costs
|
||||||
|
more than rewriting it against the (good) primitives. One near-seam worth keeping:
|
||||||
|
`applyCardEffects` (`uno_multi.go:1459`) already returns a struct instead of sending.
|
||||||
|
|
||||||
|
### Hangman — take the struct
|
||||||
|
|
||||||
|
`hangmanGame` + `guessLetter`/`guessSolution`/`displayPhrase` + the `gallows [7]string` ASCII
|
||||||
|
art (`hangman.go:26-274`). Strip three fields (`participants`, `solvedBy`, `threadID`). Copy
|
||||||
|
`hangman_phrases.txt` (237 lines) and `embed` it instead of `os.Getenv("HANGMAN_PHRASE_FILE")`.
|
||||||
|
Drop the dreamclient translation path for v1.
|
||||||
|
|
||||||
|
### Blackjack — retype it
|
||||||
|
|
||||||
|
`handValue` (correct soft-ace demotion), `isBlackjack`, and their tests. That's it — 95 lines.
|
||||||
|
The rest is `bjTable` keyed by `id.RoomID` with timers embedded, and raw `db.Exec` SQL at
|
||||||
|
`blackjack.go:867`.
|
||||||
|
|
||||||
|
### Trivia — the question bank does not exist
|
||||||
|
|
||||||
|
`trivia.go:288` fetches from OpenTDB live, one question per round:
|
||||||
|
|
||||||
|
```go
|
||||||
|
apiURL := "https://opentdb.com/api.php?amount=1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Reuse the category map (`trivia.go:24-53`) and `calculateScore` (time-decay, `:536`). For the
|
||||||
|
web version, **pre-fetch and cache a bank locally** — a per-question HTTP call in a web game
|
||||||
|
loop is a latency and rate-limit problem gogobee never had to care about at Matrix pace. Route
|
||||||
|
outbound fetches through Pete's `internal/safehttp` (SSRF guard).
|
||||||
|
|
||||||
|
Trivia has **no euro coupling today** (points only). Keep it that way in v1 — it's the one
|
||||||
|
game that can ship with zero escrow risk.
|
||||||
|
|
||||||
|
### Two things that apply to every copied engine
|
||||||
|
|
||||||
|
**Thread the RNG.** Every card game uses the `math/rand/v2` package global —
|
||||||
|
`blackjack.go:60`, `uno.go:186,277`, `holdem_game.go:102`, and throughout the CFR/Monte-Carlo
|
||||||
|
code. Nothing is seedable, which is why `TestBotPickCard_*` can only assert weak properties.
|
||||||
|
The adventure half of gogobee already does this right (`dnd_zone_combat.go:361` threads an
|
||||||
|
explicit `*rand.Rand` via `rand.NewPCG`). The card games never adopted it. **Threading
|
||||||
|
`rng *rand.Rand` through the deck constructors is mandatory, not optional** — ~20 call sites,
|
||||||
|
and it's the difference between a testable engine and one you can only smoke-test. It also
|
||||||
|
gives you a reproducible shuffle for dispute resolution.
|
||||||
|
|
||||||
|
**Hoist the timers.** `bjTable.joinTimer/turnTimer/reminderTimers`, `unoGame.idleTimer/warningTimer`,
|
||||||
|
`HoldemGame.actionTimer/warningTimer/idleTimer/idleWarningTimer` — all live inside the game
|
||||||
|
structs today. Timers are a shell concern. Game state must be a plain value you can serialize,
|
||||||
|
which is also what makes restart-mid-hand survivable.
|
||||||
|
|
||||||
|
### Build a `cards` package while you're at it
|
||||||
|
|
||||||
|
There is **no shared cards package in gogobee** — blackjack has its own deck
|
||||||
|
(`blackjack.go:32-75`), UNO has its own (`uno.go:130-189`), hold'em uses the third-party lib.
|
||||||
|
Consolidate into `pete/internal/games/cards` during the port rather than importing the
|
||||||
|
duplication.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Architecture in Pete
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/games/
|
||||||
|
cards/ shared deck primitives (new; consolidates gogobee's duplicates)
|
||||||
|
blackjack/ pure engine — ApplyMove(state, move) (state, events, error)
|
||||||
|
holdem/ pure engine + cfr/ (copied) + policy.gob (embedded)
|
||||||
|
uno/ pure engine (rewritten) over copied primitives + bots
|
||||||
|
hangman/ pure engine (copied) + phrases.txt (embedded)
|
||||||
|
trivia/ pure engine (new) + cached question bank
|
||||||
|
escrow/ chip ledger, the gogobee poll/push seam
|
||||||
|
table/ session, seating, turn clocks, reconnect — the shell
|
||||||
|
```
|
||||||
|
|
||||||
|
**Server-authoritative, always.** The browser sends intents and never sees the deck. Any
|
||||||
|
game with money attached cannot trust a client-reported result. This is why the engines have
|
||||||
|
to be Go on Pete's side rather than ported to JS.
|
||||||
|
|
||||||
|
**Every engine is a pure reducer**: `ApplyMove(state, move) (newState, []Event, error)`.
|
||||||
|
Timers, sockets and persistence all live in `table/`. That's the seam gogobee never had, and
|
||||||
|
it's what buys testability, replay, and surviving a redeploy.
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
|
||||||
|
- **Blackjack, Hangman, Trivia, UNO-solo** — request/response over `fetch`. No sockets.
|
||||||
|
- **Hold'em, UNO-multi** — WebSocket. Lobby, seating, presence, turn clocks, reconnect-mid-hand,
|
||||||
|
spectators. This is the bulk of the total effort, and it is the only genuinely new
|
||||||
|
infrastructure in the project.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Pete has **no SPA and no bundler** today — server-rendered `html/template` + `embed.FS`, plain
|
||||||
|
`<script defer>` tags, npm present only to run the Tailwind CLI. games.parodia.dev is the first
|
||||||
|
real client-side app in the repo.
|
||||||
|
|
||||||
|
Precedent says this is survivable: `weather-gl.js` is 1,028 lines of hand-written WebGL2 with
|
||||||
|
no build step. Do the same here — vanilla JS per game, no framework, no bundler, Tailwind for
|
||||||
|
layout. Revisit only if it actually hurts.
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
Session cookie is host-only today — `auth.go:151` sets `Path` but no `Domain`, so a
|
||||||
|
`news.parodia.dev` session will not travel to `games.parodia.dev`. Set `Domain: ".parodia.dev"`.
|
||||||
|
Note this widens the cookie to every parodia.dev host including the landing site — a deliberate
|
||||||
|
loosening, fine here, but not a freebie. Add the `games.parodia.dev` redirect URI to the `pete`
|
||||||
|
app in Authentik.
|
||||||
|
|
||||||
|
Games require login. No anonymous play — there's money in it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Ship order
|
||||||
|
|
||||||
|
**Phase 0 — euro idempotency (gogobee).** `external_id` column + unique index +
|
||||||
|
`CreditIdem`/`DebitIdem`. Blocking; nothing else is safe without it.
|
||||||
|
|
||||||
|
**Phase 1 — escrow + Blackjack.** The full money loop against the simplest possible game
|
||||||
|
(95 lines of logic). Buy in, play, cash out. This proves cross-subdomain auth, the identity
|
||||||
|
mapping, the poll loop, the escrow state machine, the reaper, and the frontend shape — all
|
||||||
|
against a game where the *game* cannot be what's broken.
|
||||||
|
|
||||||
|
**Phase 2 — Trivia + Hangman.** No escrow (trivia has no euro coupling; keep hangman's
|
||||||
|
collaborative credit out of v1). Pure frontend and content work. Cheap wins, and they make the
|
||||||
|
site feel like a place rather than a demo.
|
||||||
|
|
||||||
|
**Phase 3 — UNO.** Solo first (single-player vs bot, no sockets). Then multi, which is where
|
||||||
|
the WebSocket infrastructure gets built. Forgiving latency, simple turn model — the right place
|
||||||
|
to learn multiplayer.
|
||||||
|
|
||||||
|
**Phase 4 — Hold'em.** Last. It's the hardest engine (side pots, all-ins, split pots), the
|
||||||
|
biggest port, and the one where collusion is a real threat rather than a theoretical one. Do it
|
||||||
|
when the multiplayer plumbing has already survived contact with real players.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Risks
|
||||||
|
|
||||||
|
**Economy inflation.** A web casino runs orders of magnitude more hands per hour than
|
||||||
|
Matrix-paced games ever did. Whatever the house edge is, it now compounds far faster in both
|
||||||
|
directions. Before Phase 1 ships, decide: session buy-in caps, a daily net-win/loss ceiling, or
|
||||||
|
a rake. This is the risk most likely to be discovered too late.
|
||||||
|
|
||||||
|
**Restart mid-hand.** Game state is in memory, so a Pete redeploy kills live tables — the same
|
||||||
|
property gogobee has today, and it redeploys far less often than Pete does. Mitigate with
|
||||||
|
serializable state (which the pure-reducer design gives for free) plus a drain-before-restart,
|
||||||
|
not a second process.
|
||||||
|
|
||||||
|
**Collusion in hold'em.** Two browsers, one person, one table. Not solvable in v1; at minimum
|
||||||
|
log seat/IP/session overlap so it's *detectable* after the fact.
|
||||||
|
|
||||||
|
**The gogobee contract is cross-repo.** `roster_test.go` already guards it: an unknown
|
||||||
|
`event_type` is a 400 that makes gogobee's sender park the row. Add the same guard on the new
|
||||||
|
escrow endpoints, and keep the payload structs in step across both repos by hand — that's the
|
||||||
|
cost of copying instead of sharing, and it's the right trade here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Open questions
|
||||||
|
|
||||||
|
- **Chips 1:1 with euros, or a separate denomination?** 1:1 is simpler and honest. A separate
|
||||||
|
denomination gives you a knob for the inflation problem.
|
||||||
|
- **Do web results feed the Matrix room?** Pete already has a priority poster
|
||||||
|
(`adventure.go:151`). "Reala just took a 12k pot" is a good bulletin, and this is nearly free.
|
||||||
|
- **NPC opponents at the web tables?** The CFR bot is right there and it's good. It also means
|
||||||
|
a table never sits empty, which matters a lot for a small community.
|
||||||
Reference in New Issue
Block a user