Compare commits
23 Commits
adventure-
...
3e9b93af55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e9b93af55 | ||
|
|
2d653bf439 | ||
|
|
c62d736223 | ||
|
|
feb353f789 | ||
|
|
5ca056bf20 | ||
|
|
fe2195e85f | ||
|
|
d29a311eff | ||
|
|
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
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ type WebConfig struct {
|
|||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
400
internal/games/hangman/hangman.go
Normal file
400
internal/games/hangman/hangman.go
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
// Package hangman is a pure hangman engine, played for chips.
|
||||||
|
//
|
||||||
|
// Same seam as blackjack: ApplyMove(state, move) (state, events, error), where
|
||||||
|
// an error means the move was illegal and nothing else. No HTTP, no timers, no
|
||||||
|
// player names. The state is a plain value, so a game survives a redeploy and
|
||||||
|
// replays from its seed.
|
||||||
|
//
|
||||||
|
// The casino version differs from the one gogobee plays in Matrix in one way
|
||||||
|
// that matters: there is money on it. That makes the gallows a payout meter as
|
||||||
|
// well as a death clock — every wrong guess takes a tenth off what a win is
|
||||||
|
// worth. You can still win from five wrong, you just won't win much.
|
||||||
|
package hangman
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Errors an illegal move can produce.
|
||||||
|
var (
|
||||||
|
ErrGameOver = errors.New("hangman: the game is already over")
|
||||||
|
ErrNotALetter = errors.New("hangman: that is not a letter")
|
||||||
|
ErrAlreadyTried = errors.New("hangman: that letter has been tried")
|
||||||
|
ErrUnknownMove = errors.New("hangman: unknown move")
|
||||||
|
ErrBadBet = errors.New("hangman: bet must be positive")
|
||||||
|
ErrEmptySolution = errors.New("hangman: guess something")
|
||||||
|
ErrUnknownTier = errors.New("hangman: no such tier")
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxWrong is how many wrong guesses the gallows holds: head, body, two arms,
|
||||||
|
// two legs. It is not a tier setting — the drawing has six parts, so the game
|
||||||
|
// has six lives, and the tiers vary what a win pays instead.
|
||||||
|
const MaxWrong = 6
|
||||||
|
|
||||||
|
// Decay is what one wrong guess costs, as a fraction of the tier's base
|
||||||
|
// multiple. Six wrong is death, so the worst a living player can be is 50% off.
|
||||||
|
const Decay = 0.10
|
||||||
|
|
||||||
|
// Tier is a difficulty, chosen before the bet. Short phrases pay the most:
|
||||||
|
// there is less of them to read, and fewer distinct letters to hit by accident.
|
||||||
|
// A long phrase mostly reveals itself.
|
||||||
|
type Tier struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Min int `json:"min"` // phrase length, in characters
|
||||||
|
Max int `json:"max"` // inclusive
|
||||||
|
Base float64 `json:"base"` // what a win pays, before any wrong guesses
|
||||||
|
Blurb string `json:"blurb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiers are the three tables. The bank has 74 short phrases, 67 medium and 64
|
||||||
|
// long, so none of them runs thin.
|
||||||
|
var Tiers = []Tier{
|
||||||
|
{Slug: "short", Name: "Short", Min: 8, Max: 20, Base: 2.6,
|
||||||
|
Blurb: "A handful of letters and nothing to go on."},
|
||||||
|
{Slug: "medium", Name: "Medium", Min: 21, Max: 40, Base: 2.0,
|
||||||
|
Blurb: "Long enough to read once it starts falling open."},
|
||||||
|
{Slug: "long", Name: "Long", Min: 41, Max: 9999, Base: 1.6,
|
||||||
|
Blurb: "It gives itself away. It also pays the least."},
|
||||||
|
}
|
||||||
|
|
||||||
|
// TierBySlug finds a tier by the name the browser sent.
|
||||||
|
func TierBySlug(slug string) (Tier, error) {
|
||||||
|
for _, t := range Tiers {
|
||||||
|
if t.Slug == slug {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Tier{}, ErrUnknownTier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase is where the game is.
|
||||||
|
type Phase string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PhasePlaying Phase = "playing"
|
||||||
|
PhaseDone Phase = "done"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Outcome is how it ended.
|
||||||
|
type Outcome string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutcomeNone Outcome = ""
|
||||||
|
OutcomeSolved Outcome = "solved" // guessed the phrase outright
|
||||||
|
OutcomeFilled Outcome = "filled" // revealed the last letter
|
||||||
|
OutcomeHung Outcome = "hung" // six wrong
|
||||||
|
)
|
||||||
|
|
||||||
|
// Won reports whether this outcome pays.
|
||||||
|
func (o Outcome) Won() bool { return o == OutcomeSolved || o == OutcomeFilled }
|
||||||
|
|
||||||
|
// State is one game. The phrase is in here, which is exactly why this value
|
||||||
|
// never leaves the server — the browser gets a Masked() view of it instead.
|
||||||
|
type State struct {
|
||||||
|
Tier Tier `json:"tier"`
|
||||||
|
Phrase string `json:"phrase"`
|
||||||
|
Runes []rune `json:"runes"` // the phrase, indexable safely
|
||||||
|
Shown []bool `json:"shown"` // one per rune: is it face up
|
||||||
|
Tried []rune `json:"tried"` // every letter guessed, in order, right or wrong
|
||||||
|
Wrong []rune `json:"wrong"` // just the ones that missed
|
||||||
|
RakePct float64 `json:"rake_pct"`
|
||||||
|
|
||||||
|
Bet int64 `json:"bet"`
|
||||||
|
Phase Phase `json:"phase"`
|
||||||
|
Outcome Outcome `json:"outcome"`
|
||||||
|
Payout int64 `json:"payout"`
|
||||||
|
Rake int64 `json:"rake"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is something the table animates: a letter turning over, a limb being
|
||||||
|
// drawn. The engine emits them rather than drawing anything itself.
|
||||||
|
type Event struct {
|
||||||
|
Kind string `json:"kind"` // "start" | "hit" | "miss" | "solve" | "settle"
|
||||||
|
Letter string `json:"letter,omitempty"` // the letter guessed
|
||||||
|
At []int `json:"at,omitempty"` // which rune positions it turned over
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move is a player action: a single letter, or the whole phrase.
|
||||||
|
type Move struct {
|
||||||
|
Letter string `json:"letter"`
|
||||||
|
Solve string `json:"solve"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// New starts a game on a phrase drawn from the tier's shelf of the bank.
|
||||||
|
func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
|
||||||
|
if bet <= 0 {
|
||||||
|
return State{}, nil, ErrBadBet
|
||||||
|
}
|
||||||
|
phrase, err := drawPhrase(t, rng)
|
||||||
|
if err != nil {
|
||||||
|
return State{}, nil, err
|
||||||
|
}
|
||||||
|
return start(bet, t, phrase, rakePct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// start builds the opening state for a known phrase. Split out from New so a
|
||||||
|
// test can pin the phrase instead of the seed.
|
||||||
|
func start(bet int64, t Tier, phrase string, rakePct float64) (State, []Event, error) {
|
||||||
|
if bet <= 0 {
|
||||||
|
return State{}, nil, ErrBadBet
|
||||||
|
}
|
||||||
|
rs := []rune(phrase)
|
||||||
|
s := State{
|
||||||
|
Tier: t, Phrase: phrase, Runes: rs,
|
||||||
|
Shown: make([]bool, len(rs)),
|
||||||
|
RakePct: rakePct,
|
||||||
|
Bet: bet, Phase: PhasePlaying,
|
||||||
|
}
|
||||||
|
// Spaces and punctuation are never guessed — they start face up, because a
|
||||||
|
// row of blanks with the word breaks hidden is a puzzle about typography.
|
||||||
|
for i, r := range rs {
|
||||||
|
if !Guessable(r) {
|
||||||
|
s.Shown[i] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, []Event{{Kind: "start"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guessable reports whether a rune is one you'd guess: a letter or a digit.
|
||||||
|
// Everything else is scaffolding and is shown from the start.
|
||||||
|
//
|
||||||
|
// Exported because the renderer needs the same answer — a rune you'd guess is a
|
||||||
|
// rune that gets a tile to guess it into. Asking the drawing side to decide that
|
||||||
|
// for itself is how you get a board with no tile for a letter the engine is
|
||||||
|
// waiting on, and a phrase that cannot be finished.
|
||||||
|
func Guessable(r rune) bool {
|
||||||
|
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyMove is the engine. A legal move in, the new state and what happened out.
|
||||||
|
// An error means the move was illegal and the caller's state is untouched.
|
||||||
|
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||||
|
if s.Phase == PhaseDone {
|
||||||
|
return s, nil, ErrGameOver
|
||||||
|
}
|
||||||
|
s = s.clone()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case m.Solve != "":
|
||||||
|
return applySolve(s, m.Solve)
|
||||||
|
case m.Letter != "":
|
||||||
|
return applyLetter(s, m.Letter)
|
||||||
|
default:
|
||||||
|
return s, nil, ErrUnknownMove
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyLetter guesses one letter.
|
||||||
|
func applyLetter(s State, letter string) (State, []Event, error) {
|
||||||
|
rs := []rune(strings.ToLower(strings.TrimSpace(letter)))
|
||||||
|
if len(rs) != 1 || !Guessable(rs[0]) {
|
||||||
|
return s, nil, ErrNotALetter
|
||||||
|
}
|
||||||
|
g := rs[0]
|
||||||
|
if containsRune(s.Tried, g) {
|
||||||
|
// Not a miss — a no-op. Charging a life for a letter the board already
|
||||||
|
// shows you tried is punishing a mis-click, not a bad guess.
|
||||||
|
return s, nil, ErrAlreadyTried
|
||||||
|
}
|
||||||
|
s.Tried = append(s.Tried, g)
|
||||||
|
|
||||||
|
var at []int
|
||||||
|
for i, r := range s.Runes {
|
||||||
|
if !s.Shown[i] && foldEq(r, g) {
|
||||||
|
s.Shown[i] = true
|
||||||
|
at = append(at, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
evs := []Event{}
|
||||||
|
if len(at) > 0 {
|
||||||
|
evs = append(evs, Event{Kind: "hit", Letter: string(g), At: at})
|
||||||
|
if s.filled() {
|
||||||
|
s.settle(OutcomeFilled, &evs)
|
||||||
|
}
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Wrong = append(s.Wrong, g)
|
||||||
|
evs = append(evs, Event{Kind: "miss", Letter: string(g)})
|
||||||
|
if len(s.Wrong) >= MaxWrong {
|
||||||
|
s.settle(OutcomeHung, &evs)
|
||||||
|
}
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySolve guesses the whole phrase. Right, and it's over at the multiple you
|
||||||
|
// still hold. Wrong, and it costs a life like any other bad guess — otherwise
|
||||||
|
// solving would be a free roll you could spam until it landed.
|
||||||
|
func applySolve(s State, attempt string) (State, []Event, error) {
|
||||||
|
if strings.TrimSpace(attempt) == "" {
|
||||||
|
return s, nil, ErrEmptySolution
|
||||||
|
}
|
||||||
|
evs := []Event{{Kind: "solve", Text: attempt}}
|
||||||
|
|
||||||
|
if normalize(attempt) == normalize(s.Phrase) {
|
||||||
|
for i := range s.Shown {
|
||||||
|
s.Shown[i] = true
|
||||||
|
}
|
||||||
|
s.settle(OutcomeSolved, &evs)
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wrong solve is a miss with no letter attached to it.
|
||||||
|
s.Wrong = append(s.Wrong, '·')
|
||||||
|
evs = append(evs, Event{Kind: "miss", Text: attempt})
|
||||||
|
if len(s.Wrong) >= MaxWrong {
|
||||||
|
s.settle(OutcomeHung, &evs)
|
||||||
|
}
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple is what a win is worth right now: the tier's base, less a tenth of
|
||||||
|
// it for every wrong guess so far. Floored at 1, so a win never hands back less
|
||||||
|
// than the stake — a player who fought through five wrong guesses and got there
|
||||||
|
// has not earned a loss.
|
||||||
|
func (s State) Multiple() float64 {
|
||||||
|
m := s.Tier.Base * (1 - Decay*float64(len(s.Wrong)))
|
||||||
|
if m < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pays is what a win *right now* would actually put back on the player's stack:
|
||||||
|
// the stake, plus the winnings, less the house's cut of the winnings.
|
||||||
|
//
|
||||||
|
// It exists because the felt shows this number while the game is still running,
|
||||||
|
// and settle() is the only other thing that computes it. If the two ever
|
||||||
|
// disagreed the table would be quoting a payout it doesn't honour — so settle
|
||||||
|
// calls this rather than doing the sum a second time.
|
||||||
|
func (s State) Pays() int64 {
|
||||||
|
total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
|
||||||
|
if total < s.Bet {
|
||||||
|
total = s.Bet // a win never hands back less than the stake
|
||||||
|
}
|
||||||
|
profit := total - s.Bet
|
||||||
|
if profit > 0 {
|
||||||
|
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||||
|
if rake < 0 {
|
||||||
|
rake = 0
|
||||||
|
}
|
||||||
|
profit -= rake
|
||||||
|
}
|
||||||
|
return s.Bet + profit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rake taken on a win right now — the other half of what Pays works out.
|
||||||
|
func (s State) rakeNow() int64 {
|
||||||
|
total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
|
||||||
|
if total < s.Bet {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
profit := total - s.Bet
|
||||||
|
if profit <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||||
|
if rake < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return rake
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lives is how many wrong guesses are left.
|
||||||
|
func (s State) Lives() int { return MaxWrong - len(s.Wrong) }
|
||||||
|
|
||||||
|
// filled reports whether every guessable rune is face up.
|
||||||
|
func (s State) filled() bool {
|
||||||
|
for _, up := range s.Shown {
|
||||||
|
if !up {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// settle decides the payout. Same rule as blackjack: the rake comes out of
|
||||||
|
// winnings, never out of the stake. A loss is never charged a fee.
|
||||||
|
func (s *State) settle(o Outcome, evs *[]Event) {
|
||||||
|
s.Outcome = o
|
||||||
|
s.Phase = PhaseDone
|
||||||
|
|
||||||
|
if o.Won() {
|
||||||
|
s.Payout = s.Pays()
|
||||||
|
s.Rake = s.rakeNow()
|
||||||
|
} else {
|
||||||
|
s.Payout = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// The phrase goes face up when it's over, win or lose. Losing without ever
|
||||||
|
// being told the answer is the one thing hangman must never do.
|
||||||
|
for i := range s.Shown {
|
||||||
|
s.Shown[i] = true
|
||||||
|
}
|
||||||
|
*evs = append(*evs, Event{Kind: "settle", Text: string(o)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Net is what the game did to the player's stack.
|
||||||
|
func (s State) Net() int64 {
|
||||||
|
if s.Phase != PhaseDone {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return s.Payout - s.Bet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Masked is the phrase as the player may see it: revealed runes as themselves,
|
||||||
|
// hidden ones as an underscore. This — not Phrase — is what crosses the wire.
|
||||||
|
func (s State) Masked() string {
|
||||||
|
var b strings.Builder
|
||||||
|
for i, r := range s.Runes {
|
||||||
|
if s.Shown[i] {
|
||||||
|
b.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
b.WriteRune('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// clone deep-copies the slices, so a derived state shares no backing array with
|
||||||
|
// the one it came from and a state can be replayed freely.
|
||||||
|
func (s State) clone() State {
|
||||||
|
s.Runes = append([]rune(nil), s.Runes...)
|
||||||
|
s.Shown = append([]bool(nil), s.Shown...)
|
||||||
|
s.Tried = append([]rune(nil), s.Tried...)
|
||||||
|
s.Wrong = append([]rune(nil), s.Wrong...)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalize flattens a phrase for comparison: case, spacing and punctuation all
|
||||||
|
// stop mattering. "How are you gentlemen!!" is solved by "how are you gentlemen".
|
||||||
|
func normalize(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(s) {
|
||||||
|
if Guessable(r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// foldEq compares two runes the way a guess should: case-insensitively.
|
||||||
|
func foldEq(a, b rune) bool {
|
||||||
|
return unicode.ToLower(a) == unicode.ToLower(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsRune(rs []rune, r rune) bool {
|
||||||
|
for _, x := range rs {
|
||||||
|
if foldEq(x, r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
369
internal/games/hangman/hangman_test.go
Normal file
369
internal/games/hangman/hangman_test.go
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
package hangman
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tierShort is the tier most tests play on: base 2.6.
|
||||||
|
func tierShort(t *testing.T) Tier {
|
||||||
|
t.Helper()
|
||||||
|
tr, err := TierBySlug("short")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
// play runs a sequence of single-letter guesses against a pinned phrase.
|
||||||
|
func play(t *testing.T, phrase string, bet int64, rake float64, guesses ...string) State {
|
||||||
|
t.Helper()
|
||||||
|
s, _, err := start(bet, tierShort(t), phrase, rake)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, g := range guesses {
|
||||||
|
next, _, err := ApplyMove(s, Move{Letter: g})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("guess %q: %v", g, err)
|
||||||
|
}
|
||||||
|
s = next
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartShowsScaffoldingOnly(t *testing.T) {
|
||||||
|
s, evs, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := s.Masked(), "______ ____"; got != want {
|
||||||
|
t.Errorf("masked = %q, want %q — the space is scaffolding and shows, the letters don't", got, want)
|
||||||
|
}
|
||||||
|
if len(evs) != 1 || evs[0].Kind != "start" {
|
||||||
|
t.Errorf("events = %+v, want one start", evs)
|
||||||
|
}
|
||||||
|
if s.Lives() != MaxWrong {
|
||||||
|
t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPunctuationAndDigitsAreScaffoldingOrNot(t *testing.T) {
|
||||||
|
// Punctuation shows from the start; a digit is guessable like a letter.
|
||||||
|
s, _, err := start(100, tierShort(t), "Level 9!", 0.05)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := s.Masked(), "_____ _!"; got != want {
|
||||||
|
t.Fatalf("masked = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
next, _, err := ApplyMove(s, Move{Letter: "9"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("guessing a digit: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := next.Masked(), "_____ 9!"; got != want {
|
||||||
|
t.Errorf("masked = %q, want %q — a digit is a guess", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHitRevealsEveryOccurrence(t *testing.T) {
|
||||||
|
// "Blue Shell" has an l at 1, 8 and 9. One guess turns over all three.
|
||||||
|
s := play(t, "Blue Shell", 100, 0.05, "l")
|
||||||
|
if got, want := s.Masked(), "_l__ ___ll"; got != want {
|
||||||
|
t.Errorf("masked = %q, want %q — every l turns over, not just the first", got, want)
|
||||||
|
}
|
||||||
|
if s.Lives() != MaxWrong {
|
||||||
|
t.Errorf("a hit cost a life: lives = %d", s.Lives())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissCostsALifeAndTheMultiple(t *testing.T) {
|
||||||
|
tr := tierShort(t)
|
||||||
|
s := play(t, "Insert Coin", 100, 0.05, "z")
|
||||||
|
if s.Lives() != MaxWrong-1 {
|
||||||
|
t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong-1)
|
||||||
|
}
|
||||||
|
want := tr.Base * 0.9
|
||||||
|
if got := s.Multiple(); got != want {
|
||||||
|
t.Errorf("multiple = %v, want %v — one wrong guess is a tenth of the base", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepeatedLetterIsRefusedNotPunished(t *testing.T) {
|
||||||
|
s := play(t, "Insert Coin", 100, 0.05, "z")
|
||||||
|
_, _, err := ApplyMove(s, Move{Letter: "z"})
|
||||||
|
if err != ErrAlreadyTried {
|
||||||
|
t.Fatalf("err = %v, want ErrAlreadyTried", err)
|
||||||
|
}
|
||||||
|
// And the state the caller holds is untouched: a mis-click is not a life.
|
||||||
|
if s.Lives() != MaxWrong-1 {
|
||||||
|
t.Errorf("the refused move moved the state: lives = %d", s.Lives())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSixWrongHangsYouAndPaysNothing(t *testing.T) {
|
||||||
|
s := play(t, "Insert Coin", 100, 0.05, "z", "x", "q", "y", "w", "k")
|
||||||
|
if s.Phase != PhaseDone || s.Outcome != OutcomeHung {
|
||||||
|
t.Fatalf("phase/outcome = %s/%s, want done/hung", s.Phase, s.Outcome)
|
||||||
|
}
|
||||||
|
if s.Payout != 0 {
|
||||||
|
t.Errorf("payout = %d, want 0", s.Payout)
|
||||||
|
}
|
||||||
|
if s.Net() != -100 {
|
||||||
|
t.Errorf("net = %d, want -100", s.Net())
|
||||||
|
}
|
||||||
|
if strings.Contains(s.Masked(), "_") {
|
||||||
|
t.Error("a lost game must still show the phrase — being hung without being told the answer is the one thing hangman can't do")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFillingTheLastLetterWinsCleanAtFullMultiple(t *testing.T) {
|
||||||
|
// "Blue Shell" — every distinct letter, no misses.
|
||||||
|
s := play(t, "Blue Shell", 100, 0, "b", "l", "u", "e", "s", "h")
|
||||||
|
if s.Outcome != OutcomeFilled {
|
||||||
|
t.Fatalf("outcome = %s, want filled", s.Outcome)
|
||||||
|
}
|
||||||
|
// Base 2.6, no wrong guesses, no rake: 100 -> 260.
|
||||||
|
if s.Payout != 260 {
|
||||||
|
t.Errorf("payout = %d, want 260", s.Payout)
|
||||||
|
}
|
||||||
|
if s.Net() != 160 {
|
||||||
|
t.Errorf("net = %d, want 160", s.Net())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolveOutrightWinsAtTheMultipleYouStillHold(t *testing.T) {
|
||||||
|
s, _, err := start(100, tierShort(t), "Insert Coin", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Two wrong first: 2.6 -> 2.08.
|
||||||
|
for _, g := range []string{"z", "x"} {
|
||||||
|
s, _, err = ApplyMove(s, Move{Letter: g})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s, _, err = ApplyMove(s, Move{Solve: "insert coin"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Outcome != OutcomeSolved {
|
||||||
|
t.Fatalf("outcome = %s, want solved", s.Outcome)
|
||||||
|
}
|
||||||
|
if s.Payout != 208 {
|
||||||
|
t.Errorf("payout = %d, want 208 — solving pays the multiple you still hold, not the base", s.Payout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolveIgnoresCaseAndPunctuation(t *testing.T) {
|
||||||
|
s, _, err := start(100, tierShort(t), "How are you gentlemen!!", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, _, err = ApplyMove(s, Move{Solve: "HOW ARE YOU GENTLEMEN"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Outcome != OutcomeSolved {
|
||||||
|
t.Errorf("outcome = %s — a solve shouldn't turn on shouting or the exclamation marks", s.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrongSolveCostsALife(t *testing.T) {
|
||||||
|
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, _, err = ApplyMove(s, Move{Solve: "insert quarter"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Lives() != MaxWrong-1 {
|
||||||
|
t.Errorf("lives = %d, want %d — a free solve is a solve you spam until it lands", s.Lives(), MaxWrong-1)
|
||||||
|
}
|
||||||
|
if s.Phase != PhasePlaying {
|
||||||
|
t.Errorf("phase = %s, want playing", s.Phase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWinNeverReturnsLessThanTheStake(t *testing.T) {
|
||||||
|
// Long pays 1.6, and five wrong guesses would take it to 0.8 — under water.
|
||||||
|
long, err := TierBySlug("long")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, _, err := start(100, long, "the quick brown fox jumps over the lazy dog and keeps going", 0.05)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Five clean misses. Every letter in the pangram is in the pangram, so the
|
||||||
|
// only things guaranteed to miss it are digits.
|
||||||
|
for _, g := range []string{"1", "2", "3", "4", "5"} {
|
||||||
|
s, _, err = ApplyMove(s, Move{Letter: g})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := s.Multiple(); got != 1 {
|
||||||
|
t.Fatalf("multiple = %v, want 1 — the floor", got)
|
||||||
|
}
|
||||||
|
s, _, err = ApplyMove(s, Move{Solve: "the quick brown fox jumps over the lazy dog and keeps going"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Payout != 100 {
|
||||||
|
t.Errorf("payout = %d, want 100 — a win hands back the stake at worst, never less", s.Payout)
|
||||||
|
}
|
||||||
|
if s.Rake != 0 {
|
||||||
|
t.Errorf("rake = %d, want 0 — there was no profit to take a cut of", s.Rake)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRakeComesOutOfWinningsNeverTheStake(t *testing.T) {
|
||||||
|
s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h")
|
||||||
|
// Total 260, profit 160, rake 5% = 8, so 252 comes back.
|
||||||
|
if s.Rake != 8 {
|
||||||
|
t.Errorf("rake = %d, want 8 (5%% of the 160 profit)", s.Rake)
|
||||||
|
}
|
||||||
|
if s.Payout != 252 {
|
||||||
|
t.Errorf("payout = %d, want 252", s.Payout)
|
||||||
|
}
|
||||||
|
if s.Payout < s.Bet {
|
||||||
|
t.Error("the rake ate into the stake")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhatTheFeltQuotesIsWhatTheHousePays(t *testing.T) {
|
||||||
|
// The table shows Pays() while the game is still running. If that number and
|
||||||
|
// the one settle() lands on ever came apart, the felt would be advertising a
|
||||||
|
// payout the house doesn't honour. Walk a game and check they agree at every
|
||||||
|
// step — including after a miss, which is where they'd drift.
|
||||||
|
for _, wrong := range []int{0, 1, 2, 3} {
|
||||||
|
s, _, err := start(200, tierShort(t), "Blue Shell", 0.05)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
misses := []string{"z", "x", "q", "y"}
|
||||||
|
for i := 0; i < wrong; i++ {
|
||||||
|
s, _, err = ApplyMove(s, Move{Letter: misses[i]})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quoted := s.Pays() // what the felt is telling the player right now
|
||||||
|
|
||||||
|
s, _, err = ApplyMove(s, Move{Solve: "blue shell"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Payout != quoted {
|
||||||
|
t.Errorf("%d wrong: felt quoted %d, house paid %d", wrong, quoted, s.Payout)
|
||||||
|
}
|
||||||
|
if s.Payout != s.Bet+s.Net() {
|
||||||
|
t.Errorf("%d wrong: payout %d doesn't square with net %d on a %d bet", wrong, s.Payout, s.Net(), s.Bet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveOnAFinishedGameIsRefused(t *testing.T) {
|
||||||
|
s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h")
|
||||||
|
if _, _, err := ApplyMove(s, Move{Letter: "z"}); err != ErrGameOver {
|
||||||
|
t.Errorf("err = %v, want ErrGameOver", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGarbageGuessesAreRefused(t *testing.T) {
|
||||||
|
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range []Move{{Letter: "ab"}, {Letter: "!"}, {Letter: " "}, {}} {
|
||||||
|
if _, _, err := ApplyMove(s, m); err == nil {
|
||||||
|
t.Errorf("move %+v was accepted", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyMoveDoesNotTouchTheCallersState(t *testing.T) {
|
||||||
|
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := s.Masked()
|
||||||
|
if _, _, err := ApplyMove(s, Move{Letter: "i"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Masked() != before {
|
||||||
|
t.Errorf("applying a move mutated the state it was given: %q -> %q", before, s.Masked())
|
||||||
|
}
|
||||||
|
if len(s.Tried) != 0 {
|
||||||
|
t.Errorf("tried = %v, want empty — the caller's state was written through", s.Tried)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateSurvivesASerializationRoundTrip(t *testing.T) {
|
||||||
|
// A redeploy mid-game is a JSON round-trip. It has to come back playable.
|
||||||
|
s := play(t, "Insert Coin", 100, 0.05, "i", "z")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if back.Masked() != s.Masked() || back.Lives() != s.Lives() || back.Multiple() != s.Multiple() {
|
||||||
|
t.Fatalf("round trip changed the game: %+v", back)
|
||||||
|
}
|
||||||
|
next, _, err := ApplyMove(back, Move{Solve: "insert coin"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if next.Outcome != OutcomeSolved {
|
||||||
|
t.Errorf("a game restored from JSON couldn't be finished: %s", next.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewIsReproducibleFromItsSeed(t *testing.T) {
|
||||||
|
// The seed is in the audit log so a disputed game can be replayed. That is
|
||||||
|
// only true if the phrase comes back the same.
|
||||||
|
one, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
two, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if one.Phrase != two.Phrase {
|
||||||
|
t.Errorf("same seed dealt different phrases: %q vs %q", one.Phrase, two.Phrase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewDrawsFromTheRightShelf(t *testing.T) {
|
||||||
|
rng := rand.New(rand.NewPCG(1, 2))
|
||||||
|
for _, tr := range Tiers {
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
s, _, err := New(100, tr, 0.05, rng)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", tr.Slug, err)
|
||||||
|
}
|
||||||
|
if n := len([]rune(s.Phrase)); n < tr.Min || n > tr.Max {
|
||||||
|
t.Fatalf("%s drew %q (%d chars), outside %d-%d", tr.Slug, s.Phrase, n, tr.Min, tr.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEveryTierHasAShelfWorthPlaying(t *testing.T) {
|
||||||
|
// If someone edits phrases.txt and empties a tier, the game 500s at the
|
||||||
|
// table rather than here. Catch it here.
|
||||||
|
for _, tr := range Tiers {
|
||||||
|
if n := Shelf(tr.Slug); n < 20 {
|
||||||
|
t.Errorf("tier %s has %d phrases — too few to not repeat", tr.Slug, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
internal/games/hangman/phrases.go
Normal file
68
internal/games/hangman/phrases.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package hangman
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
_ "embed"
|
||||||
|
"errors"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The bank. gogobee kept its phrases in a file it read at boot out of a path in
|
||||||
|
// an env var, which meant the game was one missing file away from not existing.
|
||||||
|
// Embedding it means the casino cannot start without its phrases, which is the
|
||||||
|
// correct relationship between a game and the thing it is about.
|
||||||
|
//
|
||||||
|
//go:embed phrases.txt
|
||||||
|
var phrasesTxt string
|
||||||
|
|
||||||
|
// ErrNoPhrases means the bank has nothing at this length. It can only happen if
|
||||||
|
// someone edits phrases.txt down past a tier, and it is a programming error
|
||||||
|
// rather than anything a player did — but it's an error, not a panic, because a
|
||||||
|
// casino that won't boot is worse than one game being shut.
|
||||||
|
var ErrNoPhrases = errors.New("hangman: no phrases in that tier")
|
||||||
|
|
||||||
|
var (
|
||||||
|
shelvesOnce sync.Once
|
||||||
|
shelves map[string][]string // tier slug -> the phrases that fit it
|
||||||
|
)
|
||||||
|
|
||||||
|
// load sorts the bank onto one shelf per tier, once. Comments and blank lines
|
||||||
|
// are dropped, and so is anything too short to be a game — the tiers' own Min
|
||||||
|
// is the floor.
|
||||||
|
func load() {
|
||||||
|
shelves = make(map[string][]string, len(Tiers))
|
||||||
|
sc := bufio.NewScanner(strings.NewReader(phrasesTxt))
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n := len([]rune(line))
|
||||||
|
for _, t := range Tiers {
|
||||||
|
if n >= t.Min && n <= t.Max {
|
||||||
|
shelves[t.Slug] = append(shelves[t.Slug], line)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shelf is how many phrases a tier has. Exists so a test can assert the bank
|
||||||
|
// hasn't been edited out from under a tier.
|
||||||
|
func Shelf(slug string) int {
|
||||||
|
shelvesOnce.Do(load)
|
||||||
|
return len(shelves[slug])
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawPhrase picks one phrase from a tier's shelf. The rng is threaded, never
|
||||||
|
// the package global, so a game replays exactly from the seed in its audit row.
|
||||||
|
func drawPhrase(t Tier, rng *rand.Rand) (string, error) {
|
||||||
|
shelvesOnce.Do(load)
|
||||||
|
shelf := shelves[t.Slug]
|
||||||
|
if len(shelf) == 0 {
|
||||||
|
return "", ErrNoPhrases
|
||||||
|
}
|
||||||
|
return shelf[rng.IntN(len(shelf))], nil
|
||||||
|
}
|
||||||
237
internal/games/hangman/phrases.txt
Normal file
237
internal/games/hangman/phrases.txt
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# GogoBee Hangman Seed Phrases -- Video Game Edition
|
||||||
|
#
|
||||||
|
# Tiers are assigned automatically by character count at load time.
|
||||||
|
# Section headers below are for human readability only -- the bot ignores them.
|
||||||
|
#
|
||||||
|
# Easy: 8-20 characters
|
||||||
|
# Medium: 21-40 characters
|
||||||
|
# Hard: 41-80 characters
|
||||||
|
#
|
||||||
|
# Add community phrases via: !hangman submit [phrase]
|
||||||
|
# All submissions require LLM approval before entering the pool.
|
||||||
|
# This file can be edited directly. Bot reloads on restart.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# EASY (8-20 characters)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Finish Him!
|
||||||
|
Game Over
|
||||||
|
Insert Coin
|
||||||
|
Hadouken!
|
||||||
|
Fatality!
|
||||||
|
Big Boss
|
||||||
|
Konami Code
|
||||||
|
Warp Zone
|
||||||
|
Blue Shell
|
||||||
|
God Mode
|
||||||
|
BFG 9000
|
||||||
|
Cacodemon
|
||||||
|
Quad Damage
|
||||||
|
Samus Aran
|
||||||
|
Morph Ball
|
||||||
|
Mother Brain
|
||||||
|
Dracula!
|
||||||
|
Simon Belmont
|
||||||
|
Ecclesia
|
||||||
|
Outer Heaven
|
||||||
|
Spread Gun
|
||||||
|
Power Pellet
|
||||||
|
Checkpoint
|
||||||
|
Rocket Jump
|
||||||
|
Mushroom Kingdom
|
||||||
|
Princess Peach
|
||||||
|
Bowser's Castle
|
||||||
|
Fire Flower
|
||||||
|
Varia Suit
|
||||||
|
Space Jump
|
||||||
|
Vampire Killer
|
||||||
|
Holy Water
|
||||||
|
Trevor Belmont
|
||||||
|
Soma Cruz
|
||||||
|
Julius Belmont
|
||||||
|
Waluigi!
|
||||||
|
Richter!
|
||||||
|
Phantoon
|
||||||
|
Speed Run
|
||||||
|
High Score
|
||||||
|
Continue?
|
||||||
|
Press Start
|
||||||
|
Ryu Hayabusa
|
||||||
|
Plasma Gun
|
||||||
|
What is a man?
|
||||||
|
Serious Sam
|
||||||
|
Shoryuken!
|
||||||
|
Duck Hunt
|
||||||
|
The cake is a lie
|
||||||
|
War never changes
|
||||||
|
Would you kindly?
|
||||||
|
Do a barrel roll!
|
||||||
|
Praise the Sun!
|
||||||
|
A winner is you
|
||||||
|
You're pretty good
|
||||||
|
La-Li-Lu-Le-Lo
|
||||||
|
I am error
|
||||||
|
Leeroy Jenkins!
|
||||||
|
For the Horde!
|
||||||
|
For the Alliance!
|
||||||
|
Frostmourne hungers
|
||||||
|
Falcon Punch!
|
||||||
|
Rip and tear!
|
||||||
|
Vic Viper
|
||||||
|
Salamander!
|
||||||
|
Parodius Da!
|
||||||
|
We'll bang, okay?
|
||||||
|
What you say!!
|
||||||
|
You spoony bard!
|
||||||
|
One-Winged Angel
|
||||||
|
Morning Star
|
||||||
|
Glyph Union
|
||||||
|
TwinBee, scramble!
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MEDIUM (21-40 characters)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Stay a while and listen
|
||||||
|
It's dangerous to go alone!
|
||||||
|
Kept you waiting, huh?
|
||||||
|
A man chooses, a slave obeys
|
||||||
|
Metal Gear?! Metal Gear!!
|
||||||
|
We're not tools of the government
|
||||||
|
The winds of destruction
|
||||||
|
Who are the Patriots?
|
||||||
|
This is good, isn't it?
|
||||||
|
You have died of dysentery
|
||||||
|
The Triforce of Courage
|
||||||
|
Ganon has broken the seal!
|
||||||
|
May the wind guide you home
|
||||||
|
Dodongo dislikes smoke
|
||||||
|
The right man in the wrong place
|
||||||
|
Nothing is true, everything is permitted
|
||||||
|
I used to be an adventurer like you
|
||||||
|
You can't hide from the Grim Reaper
|
||||||
|
All your base are belong to us!
|
||||||
|
Somebody set up us the bomb!
|
||||||
|
For great justice, take off every Zig!
|
||||||
|
How are you gentlemen!!
|
||||||
|
Kain has betrayed us!
|
||||||
|
Garland will knock you all down!
|
||||||
|
You are not prepared!
|
||||||
|
I am Uther the Lightbringer!
|
||||||
|
Order of Ecclesia calls
|
||||||
|
The Dark Lord rises again!
|
||||||
|
Shanoa, bearer of glyphs
|
||||||
|
In this world, it's kill or be killed
|
||||||
|
Despite everything, it's still you
|
||||||
|
The Underground is your home now
|
||||||
|
Papyrus demands a battle!
|
||||||
|
I'm going to make spaghetti!
|
||||||
|
Toriel will protect you
|
||||||
|
Estus Flask replenished
|
||||||
|
The age of fire fades
|
||||||
|
Prepare to die, undead one
|
||||||
|
Can't let you do that, Star Fox!
|
||||||
|
Andross' empire spans the Lylat system!
|
||||||
|
Captain Falcon, show me your moves!
|
||||||
|
OBJECTION! That testimony is a lie!
|
||||||
|
Hold it! I have new evidence!
|
||||||
|
Phoenix Wright, attorney at law!
|
||||||
|
Does this unit have a soul?
|
||||||
|
Shepard, the Reapers are coming!
|
||||||
|
Tali'Zorah vas Normandy!
|
||||||
|
Gruntilda shall not be defeated!
|
||||||
|
K. Rool has stolen the banana hoard!
|
||||||
|
Kirby, hero of Dream Land!
|
||||||
|
Meta Knight awaits your challenge!
|
||||||
|
Congraturation! This story is happy end.
|
||||||
|
Cecil has become a Paladin
|
||||||
|
Cloud Strife, SOLDIER First Class
|
||||||
|
Time compression is inevitable
|
||||||
|
You require more vespene gas
|
||||||
|
Nuclear launch detected
|
||||||
|
You must construct additional pylons
|
||||||
|
I'm Commander Shepard!
|
||||||
|
War... War has changed.
|
||||||
|
Rip and tear until it is done!
|
||||||
|
Dawn of Sorrow awaits
|
||||||
|
Do you feel like a hero yet?
|
||||||
|
You're a monster. You know that, Walker?
|
||||||
|
Halo... it's not a natural formation.
|
||||||
|
Whip it good, Belmont!
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HARD (41-80 characters)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
What is a man? A miserable pile of secrets!
|
||||||
|
Die monster! You don't belong in this world!
|
||||||
|
My name is Dracula, and I bid you welcome.
|
||||||
|
You have no chance to survive make your time!
|
||||||
|
You've met with a terrible fate, haven't you?
|
||||||
|
Fear the old blood... and welcome, good hunter.
|
||||||
|
Welcome to the Liandri Grand Tournament!
|
||||||
|
I am the very model of a scientist Salarian!
|
||||||
|
Metroid Prime has escaped into the impact crater!
|
||||||
|
I want to be the very best, like no one ever was
|
||||||
|
The world needs only one Big Boss... and one Snake.
|
||||||
|
Snake, do you think love can bloom on a battlefield?
|
||||||
|
Thank you Mario, but our princess is in another castle!
|
||||||
|
You must gather your party before venturing forth
|
||||||
|
Snake, we're not tools of the government, or anyone else
|
||||||
|
Did I ever tell you what the definition of insanity is?
|
||||||
|
They're everywhere! The demons... they won't stop coming!
|
||||||
|
Dracula! Your time has come! The Vampire Killer strikes!
|
||||||
|
Link... I'm Navi, your fairy companion! Listen!
|
||||||
|
Hero of Time, your destiny awaits in the Sacred Realm
|
||||||
|
Master Chief, finish the fight. Earth is counting on you.
|
||||||
|
Outer Heaven... a place where warriors can find purpose
|
||||||
|
We passed the point of no return a long time ago, Snake
|
||||||
|
Liquid! I was the one who was meant to be the successor!
|
||||||
|
Revolver Ocelot is a triple agent working for the Patriots
|
||||||
|
Phazon corruption detected! Seek immediate medical attention!
|
||||||
|
Dark Samus has absorbed the Phazon and grown more powerful!
|
||||||
|
I'm the Doom Slayer, and I'm here to kill every last one of you!
|
||||||
|
Praise the Chosen Undead, for they shall link the fire!
|
||||||
|
Ganon is the evil king who stole the Triforce of Power!
|
||||||
|
The legendary soldier who defied his genes... Big Boss
|
||||||
|
Killing spree! Monster kill! Godlike! Unstoppable!
|
||||||
|
Humanity restored! The bonfire blazes with newfound strength!
|
||||||
|
You are the last line of defense against an infinite demonic army
|
||||||
|
Liquid Snake... your dominant genes... give you the edge in battle!
|
||||||
|
All we did was give meaning to the nuclear age by using nukes!
|
||||||
|
I'm a soldier who's been betrayed, abandoned... I fight alone now
|
||||||
|
Samus Aran, the last of the Chozo warriors, descends into the unknown
|
||||||
|
You are the Chosen Undead, fated to succeed where so many have failed
|
||||||
|
The price of living in the past is a slow death in the present
|
||||||
|
A sword wields no strength unless the hands that hold it have courage
|
||||||
|
The flow of time is always cruel, its speed seems different for each person
|
||||||
|
Hey! Listen! There's something important you need to know!
|
||||||
|
We are born of the blood, made men by the blood, undone by the blood
|
||||||
|
War has changed. It's no longer about nations, ideologies, or ethnicity
|
||||||
|
Mental has sent his armies, and I am all that stands between them and Earth!
|
||||||
|
I'm no hero. Never was. Never will be. I'm just an old killer.
|
||||||
|
What is a man? A miserable pile of secrets! But enough talk... have at you!
|
||||||
|
I used to be an adventurer like you, then I took an arrow in the knee
|
||||||
|
I'm not the only one who's responsible. The humans are just as guilty!
|
||||||
|
The Skaarj have invaded, and you must fight your way to freedom
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# EXTREME (81+ characters) -- Full quotes. No mercy.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
For you, the day Bison graced your village was the most important day of your life. But for me... it was Tuesday.
|
||||||
|
What is a man? A miserable pile of secrets! But enough talk... have at you!
|
||||||
|
Die monster! You don't belong in this world! It was not by my hand that I am once again given flesh!
|
||||||
|
You can't keep a good man down, and that goes double for a soldier who's been fighting his whole life.
|
||||||
|
They say the definition of insanity is doing the same thing over and over and expecting different results... did I ever tell you that?
|
||||||
|
I've been waiting for this... a professional killer. I'm so excited I may be sick!
|
||||||
|
Kept you waiting, huh? Don't worry though. I'll take you somewhere warm. Back to the battlefield.
|
||||||
|
It's easy to forget what a sin is in the middle of a battlefield. Especially when you're killing to stay alive.
|
||||||
|
We are not tools of the government or anyone else. Fighting was the only thing, the only thing I was good at. But at least I always fought for what I believed in.
|
||||||
|
In the 21st century, the battlefield will once again be a symbol of the glory of nations. I am a weapon. A human weapon.
|
||||||
|
A man's dreams can be his greatest asset... or his most dangerous enemy. The question is: what are you willing to sacrifice to see them through?
|
||||||
|
I need scissors! 61!
|
||||||
|
Outer Heaven, a place where soldiers need not justify their actions -- where warriors can be free of political manipulation.
|
||||||
|
Nothing happened to me. I happened. I'm the one who knocks, the one who causes all the trouble... that is my purpose.
|
||||||
|
Sun Tzu said that. I think he meant it as a metaphor, but he also said never leave home without a healthy supply of rations, so what does he know?
|
||||||
684
internal/games/klondike/klondike.go
Normal file
684
internal/games/klondike/klondike.go
Normal file
@@ -0,0 +1,684 @@
|
|||||||
|
// Package klondike is a pure Klondike solitaire engine, played for chips.
|
||||||
|
//
|
||||||
|
// Same seam as blackjack and hangman: ApplyMove(state, move) (state, events,
|
||||||
|
// error), where an error means the move was illegal and nothing else. The state
|
||||||
|
// is a plain value, so a game survives a redeploy and replays from its seed.
|
||||||
|
//
|
||||||
|
// The casino version is Vegas scoring, which is the only way solitaire has ever
|
||||||
|
// been a gambling game and the only shape that makes sense with money on it.
|
||||||
|
// You do not win or lose the deal. You *buy the deck* for your stake, and every
|
||||||
|
// card you get home to a foundation pays a slice of it back. Fifty-two cards
|
||||||
|
// home pays the tier's full multiple; nothing home pays nothing. You can stop
|
||||||
|
// whenever you like and keep what you have banked, which is what makes a game
|
||||||
|
// that has gone dead a decision rather than a wall.
|
||||||
|
//
|
||||||
|
// There is no undo. The stake is spent the moment the deck is bought, so an undo
|
||||||
|
// would be a way to walk a losing board backwards until it wins.
|
||||||
|
package klondike
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"pete/internal/games/cards"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Errors an illegal move can produce.
|
||||||
|
var (
|
||||||
|
ErrGameOver = errors.New("klondike: the game is already over")
|
||||||
|
ErrUnknownMove = errors.New("klondike: unknown move")
|
||||||
|
ErrBadBet = errors.New("klondike: bet must be positive")
|
||||||
|
ErrUnknownTier = errors.New("klondike: no such tier")
|
||||||
|
ErrBadPile = errors.New("klondike: no such pile")
|
||||||
|
ErrEmptyPile = errors.New("klondike: there is nothing there to move")
|
||||||
|
ErrNotASequence = errors.New("klondike: those cards aren't a run you can lift")
|
||||||
|
ErrWontGo = errors.New("klondike: that card doesn't go there")
|
||||||
|
ErrNoDraw = errors.New("klondike: there is nothing left to turn over")
|
||||||
|
ErrNoPasses = errors.New("klondike: you've used your passes through the stock")
|
||||||
|
ErrNothingHome = errors.New("klondike: nothing can go home right now")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Piles is the number of tableau columns. Foundations is one per suit.
|
||||||
|
const (
|
||||||
|
Piles = 7
|
||||||
|
Foundations = 4
|
||||||
|
FullDeck = 52
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tier is a difficulty, chosen with the bet. The two dials are how many cards
|
||||||
|
// the stock turns over at a time and how many times you may go through it —
|
||||||
|
// which between them are the whole difficulty of Klondike. Turning three at a
|
||||||
|
// time hides two of every three cards behind a card you may never reach; a
|
||||||
|
// single pass means the ones you leave behind are gone for good.
|
||||||
|
//
|
||||||
|
// The multiple pays for that. Cutthroat is the cruellest deal in the room and
|
||||||
|
// pays 3.4×, which means you are ahead from sixteen cards home even though most
|
||||||
|
// of those boards never clear.
|
||||||
|
type Tier struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Draw int `json:"draw"` // cards turned over per pull on the stock
|
||||||
|
Passes int `json:"passes"` // times through the stock; 0 means unlimited
|
||||||
|
Base float64 `json:"base"` // what a full 52 cards home pays, as a multiple of the stake
|
||||||
|
Blurb string `json:"blurb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BreakEven is how many cards have to reach the foundations before the player is
|
||||||
|
// square with the house. It's the number the felt actually quotes, because
|
||||||
|
// "1.4×" tells a player nothing about a game where the multiple is paid per card.
|
||||||
|
func (t Tier) BreakEven() int {
|
||||||
|
if t.Base <= 0 {
|
||||||
|
return FullDeck
|
||||||
|
}
|
||||||
|
n := int(math.Ceil(float64(FullDeck) / t.Base))
|
||||||
|
if n > FullDeck {
|
||||||
|
return FullDeck
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiers are the three deals.
|
||||||
|
var Tiers = []Tier{
|
||||||
|
{Slug: "patient", Name: "Patient", Draw: 1, Passes: 0, Base: 1.4,
|
||||||
|
Blurb: "One card at a time, through the stock as often as you like."},
|
||||||
|
{Slug: "vegas", Name: "Vegas", Draw: 3, Passes: 3, Base: 2.2,
|
||||||
|
Blurb: "Three at a time, three times round. The house game."},
|
||||||
|
{Slug: "cutthroat", Name: "Cutthroat", Draw: 3, Passes: 1, Base: 3.4,
|
||||||
|
Blurb: "Three at a time, one pass. What you leave behind is gone."},
|
||||||
|
}
|
||||||
|
|
||||||
|
// TierBySlug finds a tier by the name the browser sent.
|
||||||
|
func TierBySlug(slug string) (Tier, error) {
|
||||||
|
for _, t := range Tiers {
|
||||||
|
if t.Slug == slug {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Tier{}, ErrUnknownTier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase is where the game is.
|
||||||
|
type Phase string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PhasePlaying Phase = "playing"
|
||||||
|
PhaseDone Phase = "done"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Outcome is how it ended. Note there is no "lost": a board that goes dead is
|
||||||
|
// cashed, for whatever it made. Solitaire's failure mode is a board you can't
|
||||||
|
// improve, and the honest thing to do with one is pay out what's on it.
|
||||||
|
type Outcome string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutcomeNone Outcome = ""
|
||||||
|
OutcomeCleared Outcome = "cleared" // all 52 home
|
||||||
|
OutcomeCashed Outcome = "cashed" // the player stopped and took the board
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pile is one tableau column: a face-down stack with a face-up run on top of it.
|
||||||
|
// Down is the part the browser never sees.
|
||||||
|
type Pile struct {
|
||||||
|
Down []cards.Card `json:"down"`
|
||||||
|
Up []cards.Card `json:"up"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is one game. The stock and every Down card are in here, which is exactly
|
||||||
|
// why this value never leaves the server.
|
||||||
|
type State struct {
|
||||||
|
Tier Tier `json:"tier"`
|
||||||
|
Stock cards.Deck `json:"stock"`
|
||||||
|
Waste []cards.Card `json:"waste"`
|
||||||
|
Table [Piles]Pile `json:"table"`
|
||||||
|
Found [Foundations][]cards.Card `json:"found"` // indexed by suit
|
||||||
|
Recycles int `json:"recycles"` // times the waste has gone back under
|
||||||
|
Moves int `json:"moves"`
|
||||||
|
RakePct float64 `json:"rake_pct"`
|
||||||
|
|
||||||
|
Bet int64 `json:"bet"`
|
||||||
|
Phase Phase `json:"phase"`
|
||||||
|
Outcome Outcome `json:"outcome"`
|
||||||
|
Payout int64 `json:"payout"`
|
||||||
|
Rake int64 `json:"rake"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is something the table animates. The engine emits them rather than
|
||||||
|
// leaving the browser to diff two boards and guess what moved — a card that
|
||||||
|
// slides from a column to a foundation and a card that was simply redrawn there
|
||||||
|
// are the same diff and very different things to watch.
|
||||||
|
//
|
||||||
|
// Home and Pays ride on every event, so the meter on the felt is always quoting
|
||||||
|
// a number the engine worked out. The browser never does this arithmetic: it did
|
||||||
|
// once, and the felt advertised a payout the house didn't honour.
|
||||||
|
type Event struct {
|
||||||
|
Kind string `json:"kind"` // "deal" | "draw" | "recycle" | "move" | "home" | "flip" | "settle"
|
||||||
|
Cards []cards.Card `json:"cards,omitempty"`
|
||||||
|
From string `json:"from,omitempty"`
|
||||||
|
To string `json:"to,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Home int `json:"home"`
|
||||||
|
Pays int64 `json:"pays"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move is a player action.
|
||||||
|
//
|
||||||
|
// Home is its own kind rather than a Move To a foundation the player picked,
|
||||||
|
// because there is only ever one foundation a card can go to and asking the
|
||||||
|
// player to name it would be a quiz about suit ordering. The browser sends
|
||||||
|
// "this card, home"; the engine finds the pile.
|
||||||
|
type Move struct {
|
||||||
|
Kind string `json:"kind"` // "draw" | "move" | "home" | "auto" | "concede"
|
||||||
|
From string `json:"from"` // "waste" | "t0".."t6" | "f0".."f3"
|
||||||
|
To string `json:"to"` // "t0".."t6" | "f0".."f3"
|
||||||
|
Count int `json:"count"` // how many cards off the end of a tableau run; 0 means 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// New deals a game.
|
||||||
|
func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
|
||||||
|
if bet <= 0 {
|
||||||
|
return State{}, nil, ErrBadBet
|
||||||
|
}
|
||||||
|
if t.Draw < 1 {
|
||||||
|
return State{}, nil, ErrUnknownTier
|
||||||
|
}
|
||||||
|
d := cards.NewDeck(1)
|
||||||
|
d.Shuffle(rng)
|
||||||
|
return deal(bet, t, d, rakePct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deal lays the board out. Split out from New so a test can pin the deck
|
||||||
|
// instead of the seed.
|
||||||
|
func deal(bet int64, t Tier, d cards.Deck, rakePct float64) (State, []Event, error) {
|
||||||
|
if bet <= 0 {
|
||||||
|
return State{}, nil, ErrBadBet
|
||||||
|
}
|
||||||
|
if len(d) != FullDeck {
|
||||||
|
return State{}, nil, errors.New("klondike: a solitaire deck is 52 cards")
|
||||||
|
}
|
||||||
|
s := State{Tier: t, Bet: bet, RakePct: rakePct, Phase: PhasePlaying}
|
||||||
|
|
||||||
|
// The classic lay-out: column i gets i+1 cards, the last of them face up.
|
||||||
|
for i := 0; i < Piles; i++ {
|
||||||
|
for j := 0; j <= i; j++ {
|
||||||
|
c, _ := d.Draw()
|
||||||
|
if j == i {
|
||||||
|
s.Table[i].Up = append(s.Table[i].Up, c)
|
||||||
|
} else {
|
||||||
|
s.Table[i].Down = append(s.Table[i].Down, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Stock = d
|
||||||
|
return s, []Event{s.event("deal", nil, "", "")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyMove is the engine. A legal move in, the new board and what happened out.
|
||||||
|
// An error means the move was illegal and the caller's state is untouched.
|
||||||
|
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||||
|
if s.Phase == PhaseDone {
|
||||||
|
return s, nil, ErrGameOver
|
||||||
|
}
|
||||||
|
// The move is played against a copy, and an illegal one hands the original
|
||||||
|
// back untouched. Nothing below mutates before it has decided the move is
|
||||||
|
// legal — but "nothing below mutates early" is an invariant seven functions
|
||||||
|
// have to keep, and this is one line that doesn't need them to.
|
||||||
|
orig := s
|
||||||
|
s = s.clone()
|
||||||
|
|
||||||
|
var evs []Event
|
||||||
|
var err error
|
||||||
|
switch m.Kind {
|
||||||
|
case "draw":
|
||||||
|
evs, err = s.draw()
|
||||||
|
case "move":
|
||||||
|
evs, err = s.move(m.From, m.To, m.Count)
|
||||||
|
case "home":
|
||||||
|
evs, err = s.home(m.From)
|
||||||
|
case "auto":
|
||||||
|
evs, err = s.auto()
|
||||||
|
case "concede":
|
||||||
|
s.settle(OutcomeCashed, &evs)
|
||||||
|
return s, evs, nil
|
||||||
|
default:
|
||||||
|
return orig, nil, ErrUnknownMove
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return orig, nil, err
|
||||||
|
}
|
||||||
|
s.Moves++
|
||||||
|
|
||||||
|
// A cleared board settles itself. Nothing else does: a board with no move left
|
||||||
|
// on it is not something the engine gets to decide, because "no move left" in
|
||||||
|
// Klondike depends on cards nobody has turned over yet.
|
||||||
|
if s.cleared() {
|
||||||
|
s.settle(OutcomeCleared, &evs)
|
||||||
|
}
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the moves -------------------------------------------------------------
|
||||||
|
|
||||||
|
// draw turns cards off the stock, or puts the waste back under it if the stock
|
||||||
|
// is spent and the tier still owes a pass.
|
||||||
|
func (s *State) draw() ([]Event, error) {
|
||||||
|
if len(s.Stock) == 0 {
|
||||||
|
if len(s.Waste) == 0 {
|
||||||
|
return nil, ErrNoDraw
|
||||||
|
}
|
||||||
|
// Passes is how many times you may go *through* the stock, so the number of
|
||||||
|
// times you may turn it back over is one less than that. Zero means unlimited.
|
||||||
|
if s.Tier.Passes > 0 && s.Recycles >= s.Tier.Passes-1 {
|
||||||
|
return nil, ErrNoPasses
|
||||||
|
}
|
||||||
|
// The waste is turned over as a block, not reshuffled — so the card that
|
||||||
|
// comes out first on the next pass is the one that came out first on this
|
||||||
|
// one. Which means no reversal: the waste's *bottom* card is the one your
|
||||||
|
// hand lands on when you flip the pile, and the bottom card is the one that
|
||||||
|
// was drawn first. Reversing here would deal a different game on every pass
|
||||||
|
// and quietly break the seed in the audit log.
|
||||||
|
s.Stock = cards.Deck(s.Waste)
|
||||||
|
s.Waste = nil
|
||||||
|
s.Recycles++
|
||||||
|
return []Event{s.event("recycle", nil, "waste", "stock")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n := s.Tier.Draw
|
||||||
|
if n > len(s.Stock) {
|
||||||
|
n = len(s.Stock)
|
||||||
|
}
|
||||||
|
drawn := make([]cards.Card, 0, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
c, _ := s.Stock.Draw()
|
||||||
|
drawn = append(drawn, c)
|
||||||
|
s.Waste = append(s.Waste, c)
|
||||||
|
}
|
||||||
|
return []Event{s.event("draw", drawn, "stock", "waste")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// move takes cards from one pile and puts them on another.
|
||||||
|
func (s *State) move(from, to string, count int) ([]Event, error) {
|
||||||
|
if count < 1 {
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
lifted, err := s.peek(from, count)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !s.accepts(to, lifted) {
|
||||||
|
return nil, ErrWontGo
|
||||||
|
}
|
||||||
|
if err := s.take(from, count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.put(to, lifted)
|
||||||
|
|
||||||
|
kind := "move"
|
||||||
|
if isFoundation(to) {
|
||||||
|
kind = "home"
|
||||||
|
}
|
||||||
|
evs := []Event{s.event(kind, lifted, from, to)}
|
||||||
|
return s.withFlip(from, evs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// home sends the top card of a pile to the foundation that will take it. There
|
||||||
|
// is only ever one, so the player doesn't have to say which.
|
||||||
|
func (s *State) home(from string) ([]Event, error) {
|
||||||
|
top, err := s.peek(from, 1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
to := "f" + strconv.Itoa(int(top[0].Suit))
|
||||||
|
if !s.accepts(to, top) {
|
||||||
|
return nil, ErrWontGo
|
||||||
|
}
|
||||||
|
return s.move(from, to, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// auto sends everything that can go home, home, and keeps doing it until nothing
|
||||||
|
// else can. It is the finish button, and it is also the shortcut for the tail of
|
||||||
|
// a board that is already decided.
|
||||||
|
//
|
||||||
|
// It can cost you: a two you needed on the tableau is a two that has gone home.
|
||||||
|
// That is the player's call to make by pressing it, and it is the same call the
|
||||||
|
// button makes in every other solitaire ever written.
|
||||||
|
func (s *State) auto() ([]Event, error) {
|
||||||
|
var evs []Event
|
||||||
|
for {
|
||||||
|
moved := false
|
||||||
|
for _, from := range sources() {
|
||||||
|
top, err := s.peek(from, 1)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
to := "f" + strconv.Itoa(int(top[0].Suit))
|
||||||
|
if !s.accepts(to, top) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
one, err := s.move(from, to, 1)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
evs = append(evs, one...)
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
if !moved {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(evs) == 0 {
|
||||||
|
return nil, ErrNothingHome
|
||||||
|
}
|
||||||
|
return evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sources are the piles auto() will lift a card off, in the order it tries them.
|
||||||
|
func sources() []string {
|
||||||
|
out := make([]string, 0, Piles+1)
|
||||||
|
out = append(out, "waste")
|
||||||
|
for i := 0; i < Piles; i++ {
|
||||||
|
out = append(out, "t"+strconv.Itoa(i))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// withFlip turns up the card a tableau column was hiding, if taking from it left
|
||||||
|
// its face-down stack exposed. This is the only thing in the game that reveals a
|
||||||
|
// card the player hadn't earned yet, so it is the only place it can happen.
|
||||||
|
func (s *State) withFlip(from string, evs []Event) []Event {
|
||||||
|
i, ok := tableauIndex(from)
|
||||||
|
if !ok {
|
||||||
|
return evs
|
||||||
|
}
|
||||||
|
p := &s.Table[i]
|
||||||
|
if len(p.Up) > 0 || len(p.Down) == 0 {
|
||||||
|
return evs
|
||||||
|
}
|
||||||
|
c := p.Down[len(p.Down)-1]
|
||||||
|
p.Down = p.Down[:len(p.Down)-1]
|
||||||
|
p.Up = append(p.Up, c)
|
||||||
|
return append(evs, s.event("flip", []cards.Card{c}, from, from))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- piles -----------------------------------------------------------------
|
||||||
|
|
||||||
|
// peek returns the top `count` cards of a pile without taking them, and refuses
|
||||||
|
// a run that isn't one you could lift: a tableau run has to descend in rank and
|
||||||
|
// alternate colour all the way down, exactly as it does on the felt.
|
||||||
|
func (s *State) peek(name string, count int) ([]cards.Card, error) {
|
||||||
|
switch {
|
||||||
|
case name == "waste":
|
||||||
|
if count != 1 {
|
||||||
|
return nil, ErrNotASequence // the waste is a pile, not a run: one card, the top one
|
||||||
|
}
|
||||||
|
if len(s.Waste) == 0 {
|
||||||
|
return nil, ErrEmptyPile
|
||||||
|
}
|
||||||
|
return []cards.Card{s.Waste[len(s.Waste)-1]}, nil
|
||||||
|
|
||||||
|
case isFoundation(name):
|
||||||
|
i, ok := foundationIndex(name)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrBadPile
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
return nil, ErrNotASequence
|
||||||
|
}
|
||||||
|
f := s.Found[i]
|
||||||
|
if len(f) == 0 {
|
||||||
|
return nil, ErrEmptyPile
|
||||||
|
}
|
||||||
|
return []cards.Card{f[len(f)-1]}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
i, ok := tableauIndex(name)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrBadPile
|
||||||
|
}
|
||||||
|
up := s.Table[i].Up
|
||||||
|
if len(up) == 0 {
|
||||||
|
return nil, ErrEmptyPile
|
||||||
|
}
|
||||||
|
if count > len(up) {
|
||||||
|
return nil, ErrNotASequence
|
||||||
|
}
|
||||||
|
run := up[len(up)-count:]
|
||||||
|
if !isRun(run) {
|
||||||
|
return nil, ErrNotASequence
|
||||||
|
}
|
||||||
|
return append([]cards.Card(nil), run...), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// take removes the top `count` cards. peek has already vetted them.
|
||||||
|
func (s *State) take(name string, count int) error {
|
||||||
|
switch {
|
||||||
|
case name == "waste":
|
||||||
|
s.Waste = s.Waste[:len(s.Waste)-count]
|
||||||
|
return nil
|
||||||
|
case isFoundation(name):
|
||||||
|
i, _ := foundationIndex(name)
|
||||||
|
s.Found[i] = s.Found[i][:len(s.Found[i])-count]
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
i, ok := tableauIndex(name)
|
||||||
|
if !ok {
|
||||||
|
return ErrBadPile
|
||||||
|
}
|
||||||
|
s.Table[i].Up = s.Table[i].Up[:len(s.Table[i].Up)-count]
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// put drops cards onto a pile. accepts has already vetted them.
|
||||||
|
func (s *State) put(name string, cs []cards.Card) {
|
||||||
|
if isFoundation(name) {
|
||||||
|
i, _ := foundationIndex(name)
|
||||||
|
s.Found[i] = append(s.Found[i], cs...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i, _ := tableauIndex(name)
|
||||||
|
s.Table[i].Up = append(s.Table[i].Up, cs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// accepts is the rule the whole game is made of: what may be put where.
|
||||||
|
//
|
||||||
|
// A foundation takes its own suit in order from the ace, one card at a time. A
|
||||||
|
// tableau column takes a run that descends by one and alternates colour from its
|
||||||
|
// top card, and an empty column takes a King and nothing else.
|
||||||
|
func (s *State) accepts(name string, cs []cards.Card) bool {
|
||||||
|
if len(cs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if isFoundation(name) {
|
||||||
|
i, ok := foundationIndex(name)
|
||||||
|
if !ok || len(cs) != 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c := cs[0]
|
||||||
|
return int(c.Suit) == i && int(c.Rank) == len(s.Found[i])+1
|
||||||
|
}
|
||||||
|
|
||||||
|
i, ok := tableauIndex(name)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !isRun(cs) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
up := s.Table[i].Up
|
||||||
|
if len(up) == 0 {
|
||||||
|
// An empty column is the most valuable thing on the board, so it costs a
|
||||||
|
// King to take one. A column with cards still face-down under it is not
|
||||||
|
// empty, and Up being empty there can't happen: withFlip turns one over.
|
||||||
|
return cs[0].Rank == cards.King && len(s.Table[i].Down) == 0
|
||||||
|
}
|
||||||
|
top := up[len(up)-1]
|
||||||
|
return int(cs[0].Rank) == int(top.Rank)-1 && cs[0].Red() != top.Red()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRun reports whether these cards, in this order, are a tableau sequence:
|
||||||
|
// descending by one, alternating colour.
|
||||||
|
func isRun(cs []cards.Card) bool {
|
||||||
|
for i := 1; i < len(cs); i++ {
|
||||||
|
if int(cs[i].Rank) != int(cs[i-1].Rank)-1 || cs[i].Red() == cs[i-1].Red() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFoundation(name string) bool { return strings.HasPrefix(name, "f") }
|
||||||
|
|
||||||
|
func tableauIndex(name string) (int, bool) { return pileIndex(name, "t", Piles) }
|
||||||
|
|
||||||
|
func foundationIndex(name string) (int, bool) { return pileIndex(name, "f", Foundations) }
|
||||||
|
|
||||||
|
func pileIndex(name, prefix string, n int) (int, bool) {
|
||||||
|
if !strings.HasPrefix(name, prefix) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
i, err := strconv.Atoi(name[len(prefix):])
|
||||||
|
if err != nil || i < 0 || i >= n {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return i, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the money -------------------------------------------------------------
|
||||||
|
|
||||||
|
// Home is how many cards have reached the foundations. It is the only number in
|
||||||
|
// this game that the payout depends on.
|
||||||
|
func (s State) Home() int {
|
||||||
|
n := 0
|
||||||
|
for _, f := range s.Found {
|
||||||
|
n += len(f)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// PerCard is what one card home is worth, before the rake. The felt quotes this
|
||||||
|
// because "2.2×" tells a player nothing about a game where the multiple is paid
|
||||||
|
// out a fifty-second at a time.
|
||||||
|
func (s State) PerCard() float64 {
|
||||||
|
return float64(s.Bet) * s.Tier.Base / float64(FullDeck)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Earned is the gross: what the cards home have bought back, before the house
|
||||||
|
// takes anything. Computed from the total rather than card by card, so 52 cards
|
||||||
|
// home pays the tier's multiple exactly instead of the multiple less 52 roundings.
|
||||||
|
func (s State) Earned() int64 {
|
||||||
|
return int64(math.Floor(float64(s.Bet) * s.Tier.Base * float64(s.Home()) / float64(FullDeck)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pays is what stopping *right now* would actually put back on the player's
|
||||||
|
// stack: the gross, less the house's cut of anything above the stake.
|
||||||
|
//
|
||||||
|
// The felt shows this number while the game is still running and settle() lands
|
||||||
|
// on it, and they are the same function for the reason hangman's are: the moment
|
||||||
|
// they are two sums, the table is quoting a payout it doesn't honour.
|
||||||
|
//
|
||||||
|
// Unlike the other games it can be less than the stake, and can be zero. That is
|
||||||
|
// the game — you bought the deck, and a deck that gives you nothing owes you
|
||||||
|
// nothing.
|
||||||
|
func (s State) Pays() int64 {
|
||||||
|
total := s.Earned()
|
||||||
|
profit := total - s.Bet
|
||||||
|
if profit > 0 {
|
||||||
|
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||||
|
if rake > 0 {
|
||||||
|
total -= rake
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// rakeNow is the house's cut if the board were cashed right now — the other half
|
||||||
|
// of what Pays works out.
|
||||||
|
func (s State) rakeNow() int64 {
|
||||||
|
profit := s.Earned() - s.Bet
|
||||||
|
if profit <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||||
|
if rake < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return rake
|
||||||
|
}
|
||||||
|
|
||||||
|
// Net is what the game did to the player's stack.
|
||||||
|
func (s State) Net() int64 {
|
||||||
|
if s.Phase != PhaseDone {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return s.Payout - s.Bet
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleared reports whether every card is home.
|
||||||
|
func (s State) cleared() bool { return s.Home() == FullDeck }
|
||||||
|
|
||||||
|
// CanAuto reports whether anything can go home at all — which is what greys the
|
||||||
|
// finish button out rather than letting it be pressed at a board that has nothing
|
||||||
|
// for it.
|
||||||
|
func (s State) CanAuto() bool {
|
||||||
|
for _, from := range sources() {
|
||||||
|
top, err := (&s).peek(from, 1)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (&s).accepts("f"+strconv.Itoa(int(top[0].Suit)), top) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PassesLeft is how many more times the player may go through the stock,
|
||||||
|
// counting the one they are in. -1 means unlimited.
|
||||||
|
func (s State) PassesLeft() int {
|
||||||
|
if s.Tier.Passes <= 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
left := s.Tier.Passes - s.Recycles
|
||||||
|
if left < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
// settle closes the game at whatever is on the board. Same rule as everywhere
|
||||||
|
// else in the room: the rake comes out of winnings, never out of the stake.
|
||||||
|
func (s *State) settle(o Outcome, evs *[]Event) {
|
||||||
|
s.Outcome = o
|
||||||
|
s.Phase = PhaseDone
|
||||||
|
s.Payout = s.Pays()
|
||||||
|
s.Rake = s.rakeNow()
|
||||||
|
*evs = append(*evs, s.event("settle", nil, "", string(o)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// event stamps an event with the two numbers the felt's meter reads off it, so
|
||||||
|
// the browser never has to work out what the board is worth.
|
||||||
|
func (s State) event(kind string, cs []cards.Card, from, to string) Event {
|
||||||
|
return Event{
|
||||||
|
Kind: kind, Cards: cs, From: from, To: to,
|
||||||
|
Home: s.Home(), Pays: s.Pays(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clone deep-copies everything with a backing array, so a derived state shares
|
||||||
|
// none of it with the one it came from and a board can be replayed freely.
|
||||||
|
func (s State) clone() State {
|
||||||
|
s.Stock = append(cards.Deck(nil), s.Stock...)
|
||||||
|
s.Waste = append([]cards.Card(nil), s.Waste...)
|
||||||
|
for i := range s.Table {
|
||||||
|
s.Table[i].Down = append([]cards.Card(nil), s.Table[i].Down...)
|
||||||
|
s.Table[i].Up = append([]cards.Card(nil), s.Table[i].Up...)
|
||||||
|
}
|
||||||
|
for i := range s.Found {
|
||||||
|
s.Found[i] = append([]cards.Card(nil), s.Found[i]...)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
730
internal/games/klondike/klondike_test.go
Normal file
730
internal/games/klondike/klondike_test.go
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
package klondike
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pete/internal/games/cards"
|
||||||
|
)
|
||||||
|
|
||||||
|
const rake = 0.05
|
||||||
|
|
||||||
|
func vegas() Tier { t, _ := TierBySlug("vegas"); return t }
|
||||||
|
func patient() Tier { t, _ := TierBySlug("patient"); return t }
|
||||||
|
func cut() Tier { t, _ := TierBySlug("cutthroat"); return t }
|
||||||
|
|
||||||
|
func card(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} }
|
||||||
|
|
||||||
|
// ordered builds the 52 cards in a fixed order — the deck deal() would get if
|
||||||
|
// the shuffle were the identity. Tests that care about the board build their own.
|
||||||
|
func ordered() cards.Deck { return cards.NewDeck(1) }
|
||||||
|
|
||||||
|
func mustDeal(t *testing.T, bet int64, tier Tier, d cards.Deck) State {
|
||||||
|
t.Helper()
|
||||||
|
s, evs, err := deal(bet, tier, d, rake)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deal: %v", err)
|
||||||
|
}
|
||||||
|
if len(evs) != 1 || evs[0].Kind != "deal" {
|
||||||
|
t.Fatalf("deal events = %+v, want one deal", evs)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(t *testing.T, s State, m Move) (State, []Event) {
|
||||||
|
t.Helper()
|
||||||
|
next, evs, err := ApplyMove(s, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyMove(%+v): %v", m, err)
|
||||||
|
}
|
||||||
|
return next, evs
|
||||||
|
}
|
||||||
|
|
||||||
|
func refuses(t *testing.T, s State, m Move, want error) {
|
||||||
|
t.Helper()
|
||||||
|
next, evs, err := ApplyMove(s, m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("ApplyMove(%+v) was allowed, want %v", m, want)
|
||||||
|
}
|
||||||
|
if want != nil && err != want {
|
||||||
|
t.Fatalf("ApplyMove(%+v) = %v, want %v", m, err, want)
|
||||||
|
}
|
||||||
|
if evs != nil {
|
||||||
|
t.Errorf("an illegal move emitted events: %+v", evs)
|
||||||
|
}
|
||||||
|
// The board an illegal move hands back must be the one it was given. This is
|
||||||
|
// the whole contract of the reducer, and it's cheap to check by value.
|
||||||
|
if !sameBoard(next, s) {
|
||||||
|
t.Errorf("an illegal move changed the board")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameBoard(a, b State) bool {
|
||||||
|
x, _ := json.Marshal(a)
|
||||||
|
y, _ := json.Marshal(b)
|
||||||
|
return string(x) == string(y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the deal --------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestDealLaysOutTheBoard(t *testing.T) {
|
||||||
|
s := mustDeal(t, 520, vegas(), ordered())
|
||||||
|
|
||||||
|
seen := 0
|
||||||
|
for i := 0; i < Piles; i++ {
|
||||||
|
p := s.Table[i]
|
||||||
|
if len(p.Up) != 1 {
|
||||||
|
t.Errorf("column %d has %d face up, want 1", i, len(p.Up))
|
||||||
|
}
|
||||||
|
if len(p.Down) != i {
|
||||||
|
t.Errorf("column %d has %d face down, want %d", i, len(p.Down), i)
|
||||||
|
}
|
||||||
|
seen += len(p.Up) + len(p.Down)
|
||||||
|
}
|
||||||
|
if seen != 28 {
|
||||||
|
t.Errorf("tableau holds %d cards, want 28", seen)
|
||||||
|
}
|
||||||
|
if len(s.Stock) != 24 {
|
||||||
|
t.Errorf("stock is %d, want 24", len(s.Stock))
|
||||||
|
}
|
||||||
|
if s.Home() != 0 || s.Pays() != 0 {
|
||||||
|
t.Errorf("a fresh board is worth %d from %d home, want nothing", s.Pays(), s.Home())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDealRefusesABadStake(t *testing.T) {
|
||||||
|
if _, _, err := deal(0, vegas(), ordered(), rake); err != ErrBadBet {
|
||||||
|
t.Fatalf("deal(0) = %v, want ErrBadBet", err)
|
||||||
|
}
|
||||||
|
if _, _, err := New(-5, vegas(), rake, cards.NewRNG(1, 2)); err != ErrBadBet {
|
||||||
|
t.Fatalf("New(-5) = %v, want ErrBadBet", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the stock -------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestDrawTurnsTheTiersCount(t *testing.T) {
|
||||||
|
for _, tier := range []Tier{patient(), vegas()} {
|
||||||
|
s := mustDeal(t, 100, tier, ordered())
|
||||||
|
next, evs := apply(t, s, Move{Kind: "draw"})
|
||||||
|
if len(next.Waste) != tier.Draw {
|
||||||
|
t.Errorf("%s: waste is %d after one draw, want %d", tier.Slug, len(next.Waste), tier.Draw)
|
||||||
|
}
|
||||||
|
if len(next.Stock) != 24-tier.Draw {
|
||||||
|
t.Errorf("%s: stock is %d, want %d", tier.Slug, len(next.Stock), 24-tier.Draw)
|
||||||
|
}
|
||||||
|
if len(evs) != 1 || evs[0].Kind != "draw" || len(evs[0].Cards) != tier.Draw {
|
||||||
|
t.Errorf("%s: draw events = %+v", tier.Slug, evs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last pull off a short stock turns over what's left rather than refusing.
|
||||||
|
func TestDrawTakesWhatIsLeft(t *testing.T) {
|
||||||
|
s := mustDeal(t, 100, vegas(), ordered()) // 24 in the stock, drawing 3
|
||||||
|
for i := 0; i < 7; i++ {
|
||||||
|
s, _ = apply(t, s, Move{Kind: "draw"}) // 21 drawn, 3 left
|
||||||
|
}
|
||||||
|
s, _ = apply(t, s, Move{Kind: "draw"})
|
||||||
|
if len(s.Stock) != 0 || len(s.Waste) != 24 {
|
||||||
|
t.Fatalf("stock %d waste %d, want 0 and 24", len(s.Stock), len(s.Waste))
|
||||||
|
}
|
||||||
|
refuses(t, drained(t, s), Move{Kind: "draw"}, ErrNoDraw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drained empties the waste too, so there is genuinely nothing to turn over.
|
||||||
|
func drained(t *testing.T, s State) State {
|
||||||
|
t.Helper()
|
||||||
|
s = s.clone()
|
||||||
|
s.Waste = nil
|
||||||
|
s.Stock = nil
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// The waste goes back under the stock in the order it came out — a recycle is a
|
||||||
|
// pile being turned over, not reshuffled. If this ever reshuffled, the seed in
|
||||||
|
// the audit log would stop replaying the game.
|
||||||
|
func TestRecycleTurnsTheWasteOverInOrder(t *testing.T) {
|
||||||
|
s := mustDeal(t, 100, patient(), ordered())
|
||||||
|
want := append(cards.Deck(nil), s.Stock...)
|
||||||
|
|
||||||
|
for i := 0; i < 24; i++ {
|
||||||
|
s, _ = apply(t, s, Move{Kind: "draw"})
|
||||||
|
}
|
||||||
|
next, evs := apply(t, s, Move{Kind: "draw"})
|
||||||
|
if len(evs) != 1 || evs[0].Kind != "recycle" {
|
||||||
|
t.Fatalf("events = %+v, want a recycle", evs)
|
||||||
|
}
|
||||||
|
if len(next.Waste) != 0 {
|
||||||
|
t.Errorf("waste is %d after a recycle, want empty", len(next.Waste))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if next.Stock[i] != want[i] {
|
||||||
|
t.Fatalf("stock[%d] = %v after recycle, want %v — the pile was reshuffled",
|
||||||
|
i, next.Stock[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next.Recycles != 1 {
|
||||||
|
t.Errorf("recycles = %d, want 1", next.Recycles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passes is how many times you may go *through* the stock, so it is one more
|
||||||
|
// than the number of times you may turn it back over.
|
||||||
|
func TestPassesRunOut(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
tier Tier
|
||||||
|
recycles int // how many turn-overs the tier should allow
|
||||||
|
}{
|
||||||
|
{cut(), 0}, // one pass: you never get to turn it back over
|
||||||
|
{vegas(), 2}, // three passes: two turn-overs
|
||||||
|
{patient(), -1}, // unlimited
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
s := mustDeal(t, 100, tc.tier, ordered())
|
||||||
|
if got := s.PassesLeft(); tc.recycles < 0 && got != -1 {
|
||||||
|
t.Errorf("%s: PassesLeft = %d, want -1 (unlimited)", tc.tier.Slug, got)
|
||||||
|
}
|
||||||
|
allowed := 0
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
// Empty the stock, then try to turn it over.
|
||||||
|
for len(s.Stock) > 0 {
|
||||||
|
s, _ = apply(t, s, Move{Kind: "draw"})
|
||||||
|
}
|
||||||
|
next, _, err := ApplyMove(s, Move{Kind: "draw"})
|
||||||
|
if err == ErrNoPasses {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", tc.tier.Slug, err)
|
||||||
|
}
|
||||||
|
s = next
|
||||||
|
allowed++
|
||||||
|
}
|
||||||
|
if tc.recycles < 0 {
|
||||||
|
if allowed != 5 {
|
||||||
|
t.Errorf("%s: only %d recycles allowed, want unlimited", tc.tier.Slug, allowed)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if allowed != tc.recycles {
|
||||||
|
t.Errorf("%s: %d recycles allowed, want %d", tc.tier.Slug, allowed, tc.recycles)
|
||||||
|
}
|
||||||
|
if s.PassesLeft() != 1 {
|
||||||
|
t.Errorf("%s: PassesLeft = %d on the last pass, want 1", tc.tier.Slug, s.PassesLeft())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the rules -------------------------------------------------------------
|
||||||
|
|
||||||
|
// board builds a State directly, so a rule can be tested against the position
|
||||||
|
// that exercises it rather than against whatever a shuffle happened to deal.
|
||||||
|
func board(tier Tier, bet int64) State {
|
||||||
|
return State{Tier: tier, Bet: bet, RakePct: rake, Phase: PhasePlaying}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableauTakesDescendingAlternatingColour(t *testing.T) {
|
||||||
|
s := board(vegas(), 520)
|
||||||
|
s.Table[0].Up = []cards.Card{card(8, cards.Spades)} // black 8
|
||||||
|
s.Table[1].Up = []cards.Card{card(7, cards.Hearts)} // red 7 — goes on the 8
|
||||||
|
s.Table[2].Up = []cards.Card{card(7, cards.Clubs)} // black 7 — does not
|
||||||
|
s.Table[3].Up = []cards.Card{card(6, cards.Hearts)} // red 6 — wrong rank for the 8
|
||||||
|
|
||||||
|
next, evs := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
|
||||||
|
if len(next.Table[0].Up) != 2 || next.Table[0].Up[1] != card(7, cards.Hearts) {
|
||||||
|
t.Fatalf("the red seven didn't land on the black eight: %+v", next.Table[0].Up)
|
||||||
|
}
|
||||||
|
if len(next.Table[1].Up) != 0 {
|
||||||
|
t.Errorf("the seven is still in its old column")
|
||||||
|
}
|
||||||
|
if len(evs) != 1 || evs[0].Kind != "move" {
|
||||||
|
t.Errorf("events = %+v, want one move", evs)
|
||||||
|
}
|
||||||
|
|
||||||
|
refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo) // same colour
|
||||||
|
refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrWontGo) // two below
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyAKingTakesAnEmptyColumn(t *testing.T) {
|
||||||
|
s := board(vegas(), 520)
|
||||||
|
// t0 is empty and has nothing under it.
|
||||||
|
s.Table[1].Up = []cards.Card{card(cards.King, cards.Hearts)}
|
||||||
|
s.Table[2].Up = []cards.Card{card(cards.Queen, cards.Spades)}
|
||||||
|
|
||||||
|
refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo)
|
||||||
|
|
||||||
|
next, _ := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
|
||||||
|
if len(next.Table[0].Up) != 1 || next.Table[0].Up[0].Rank != cards.King {
|
||||||
|
t.Fatalf("the king didn't take the empty column: %+v", next.Table[0].Up)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A run comes off the tableau as a block, and only if it is a run.
|
||||||
|
func TestLiftingARun(t *testing.T) {
|
||||||
|
s := board(vegas(), 520)
|
||||||
|
s.Table[0].Up = []cards.Card{
|
||||||
|
card(9, cards.Hearts), // red
|
||||||
|
card(8, cards.Spades), // black
|
||||||
|
card(7, cards.Diamonds), // red
|
||||||
|
}
|
||||||
|
s.Table[1].Up = []cards.Card{card(10, cards.Clubs)} // black 10 takes the red 9
|
||||||
|
|
||||||
|
next, _ := apply(t, s, Move{Kind: "move", From: "t0", To: "t1", Count: 3})
|
||||||
|
if len(next.Table[1].Up) != 4 || len(next.Table[0].Up) != 0 {
|
||||||
|
t.Fatalf("the run didn't move as a block: t0=%v t1=%v", next.Table[0].Up, next.Table[1].Up)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a run: same colour in the middle of it.
|
||||||
|
bad := board(vegas(), 520)
|
||||||
|
bad.Table[0].Up = []cards.Card{
|
||||||
|
card(9, cards.Hearts),
|
||||||
|
card(8, cards.Diamonds), // red on red
|
||||||
|
}
|
||||||
|
bad.Table[1].Up = []cards.Card{card(10, cards.Clubs)}
|
||||||
|
refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 2}, ErrNotASequence)
|
||||||
|
|
||||||
|
// And you can't lift more cards than the column has.
|
||||||
|
refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 9}, ErrNotASequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taking the last face-up card off a column turns the next one over. This is the
|
||||||
|
// only thing in the game that reveals a card, which is the point of the test.
|
||||||
|
func TestTakingTheLastCardFlipsTheNextOne(t *testing.T) {
|
||||||
|
s := board(vegas(), 520)
|
||||||
|
hidden := card(cards.Queen, cards.Clubs)
|
||||||
|
s.Table[0].Down = []cards.Card{card(2, cards.Spades), hidden}
|
||||||
|
s.Table[0].Up = []cards.Card{card(7, cards.Hearts)}
|
||||||
|
s.Table[1].Up = []cards.Card{card(8, cards.Spades)}
|
||||||
|
|
||||||
|
next, evs := apply(t, s, Move{Kind: "move", From: "t0", To: "t1"})
|
||||||
|
if len(next.Table[0].Up) != 1 || next.Table[0].Up[0] != hidden {
|
||||||
|
t.Fatalf("the hidden card didn't turn over: %+v", next.Table[0].Up)
|
||||||
|
}
|
||||||
|
if len(next.Table[0].Down) != 1 {
|
||||||
|
t.Errorf("face-down stack is %d, want 1", len(next.Table[0].Down))
|
||||||
|
}
|
||||||
|
if len(evs) != 2 || evs[1].Kind != "flip" || evs[1].Cards[0] != hidden {
|
||||||
|
t.Fatalf("events = %+v, want a move then a flip carrying the card", evs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoundationsBuildUpBySuitFromTheAce(t *testing.T) {
|
||||||
|
s := board(vegas(), 520)
|
||||||
|
s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
|
||||||
|
s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
|
||||||
|
s.Table[2].Up = []cards.Card{card(2, cards.Spades)}
|
||||||
|
s.Table[3].Up = []cards.Card{card(3, cards.Hearts)}
|
||||||
|
|
||||||
|
// A two can't start a foundation.
|
||||||
|
refuses(t, s, Move{Kind: "home", From: "t1"}, ErrWontGo)
|
||||||
|
|
||||||
|
s, evs := apply(t, s, Move{Kind: "home", From: "t0"})
|
||||||
|
if len(s.Found[cards.Hearts]) != 1 {
|
||||||
|
t.Fatalf("the ace didn't go home: %+v", s.Found)
|
||||||
|
}
|
||||||
|
if evs[0].Kind != "home" || evs[0].To != "f"+strconv.Itoa(int(cards.Hearts)) {
|
||||||
|
t.Fatalf("event = %+v, want a home to the hearts pile", evs[0])
|
||||||
|
}
|
||||||
|
if evs[0].Home != 1 {
|
||||||
|
t.Errorf("event carries Home=%d, want 1", evs[0].Home)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three can't jump the two, and the two of spades can't go on hearts.
|
||||||
|
refuses(t, s, Move{Kind: "home", From: "t3"}, ErrWontGo)
|
||||||
|
refuses(t, s, Move{Kind: "move", From: "t2", To: "f" + strconv.Itoa(int(cards.Hearts))}, ErrWontGo)
|
||||||
|
|
||||||
|
s, _ = apply(t, s, Move{Kind: "home", From: "t1"})
|
||||||
|
if s.Home() != 2 {
|
||||||
|
t.Errorf("Home = %d, want 2", s.Home())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A card can come back off a foundation — a real rule, and one that matters when
|
||||||
|
// you need a low card to move a column. The payout follows it back down, because
|
||||||
|
// the payout reads the board rather than counting events.
|
||||||
|
func TestACardComesBackOffAFoundation(t *testing.T) {
|
||||||
|
s := board(vegas(), 5200)
|
||||||
|
s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
|
||||||
|
s.Table[0].Up = []cards.Card{card(3, cards.Spades)}
|
||||||
|
|
||||||
|
before := s.Pays()
|
||||||
|
next, _ := apply(t, s, Move{Kind: "move", From: "f" + strconv.Itoa(int(cards.Hearts)), To: "t0"})
|
||||||
|
if len(next.Found[cards.Hearts]) != 1 || len(next.Table[0].Up) != 2 {
|
||||||
|
t.Fatalf("the two didn't come back down: found=%v t0=%v", next.Found[cards.Hearts], next.Table[0].Up)
|
||||||
|
}
|
||||||
|
if next.Home() != 1 {
|
||||||
|
t.Errorf("Home = %d after taking a card back, want 1", next.Home())
|
||||||
|
}
|
||||||
|
if next.Pays() >= before {
|
||||||
|
t.Errorf("Pays = %d after taking a card back, want less than %d", next.Pays(), before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWasteGivesUpItsTopCardOnly(t *testing.T) {
|
||||||
|
s := board(vegas(), 520)
|
||||||
|
s.Waste = []cards.Card{card(5, cards.Spades), card(7, cards.Hearts)}
|
||||||
|
s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
|
||||||
|
|
||||||
|
// The 5 is under the 7 and is not available, however much you'd like it.
|
||||||
|
refuses(t, s, Move{Kind: "move", From: "waste", To: "t0", Count: 2}, ErrNotASequence)
|
||||||
|
|
||||||
|
next, _ := apply(t, s, Move{Kind: "move", From: "waste", To: "t0"})
|
||||||
|
if len(next.Waste) != 1 || next.Waste[0] != card(5, cards.Spades) {
|
||||||
|
t.Fatalf("the wrong card left the waste: %+v", next.Waste)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyPilesAndNonsensePiles(t *testing.T) {
|
||||||
|
s := board(vegas(), 520)
|
||||||
|
s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
|
||||||
|
|
||||||
|
refuses(t, s, Move{Kind: "move", From: "waste", To: "t0"}, ErrEmptyPile)
|
||||||
|
refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrEmptyPile)
|
||||||
|
refuses(t, s, Move{Kind: "move", From: "t9", To: "t0"}, ErrBadPile)
|
||||||
|
refuses(t, s, Move{Kind: "move", From: "t0", To: "t9"}, ErrWontGo)
|
||||||
|
refuses(t, s, Move{Kind: "move", From: "banana", To: "t0"}, ErrBadPile)
|
||||||
|
refuses(t, s, Move{Kind: "sing"}, ErrUnknownMove)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- auto ------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestAutoSendsEverythingItCanHome(t *testing.T) {
|
||||||
|
s := board(vegas(), 5200)
|
||||||
|
// Two aces and the hearts two, sitting on top of three columns.
|
||||||
|
s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
|
||||||
|
s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
|
||||||
|
s.Table[2].Up = []cards.Card{card(cards.Ace, cards.Spades)}
|
||||||
|
s.Table[3].Up = []cards.Card{card(9, cards.Clubs)} // goes nowhere
|
||||||
|
|
||||||
|
next, evs := apply(t, s, Move{Kind: "auto"})
|
||||||
|
if next.Home() != 3 {
|
||||||
|
t.Fatalf("Home = %d after auto, want 3 (two aces and the two)", next.Home())
|
||||||
|
}
|
||||||
|
if len(next.Table[3].Up) != 1 {
|
||||||
|
t.Errorf("the nine went somewhere it couldn't go")
|
||||||
|
}
|
||||||
|
homes := 0
|
||||||
|
for _, e := range evs {
|
||||||
|
if e.Kind == "home" {
|
||||||
|
homes++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if homes != 3 {
|
||||||
|
t.Errorf("auto emitted %d home events, want 3 — the table has to animate each one", homes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing left to do: the button says so rather than doing nothing quietly.
|
||||||
|
if next.CanAuto() {
|
||||||
|
t.Errorf("CanAuto is true with only a nine on the board")
|
||||||
|
}
|
||||||
|
refuses(t, next, Move{Kind: "auto"}, ErrNothingHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the money -------------------------------------------------------------
|
||||||
|
|
||||||
|
// The number the felt quotes while you play and the number settle() lands on are
|
||||||
|
// the same function. Hangman had these as two sums once and the table advertised
|
||||||
|
// a payout the house didn't honour; this asserts they can't drift here.
|
||||||
|
func TestTheQuoteIsThePayout(t *testing.T) {
|
||||||
|
s := board(vegas(), 1000)
|
||||||
|
for home := 0; home <= FullDeck; home++ {
|
||||||
|
s.Found = [Foundations][]cards.Card{}
|
||||||
|
left := home
|
||||||
|
for suit := 0; suit < Foundations && left > 0; suit++ {
|
||||||
|
n := left
|
||||||
|
if n > 13 {
|
||||||
|
n = 13
|
||||||
|
}
|
||||||
|
for r := 1; r <= n; r++ {
|
||||||
|
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||||
|
}
|
||||||
|
left -= n
|
||||||
|
}
|
||||||
|
if s.Home() != home {
|
||||||
|
t.Fatalf("built a board with %d home, wanted %d", s.Home(), home)
|
||||||
|
}
|
||||||
|
|
||||||
|
quoted := s.Pays()
|
||||||
|
var evs []Event
|
||||||
|
done := s.clone()
|
||||||
|
done.settle(OutcomeCashed, &evs)
|
||||||
|
if done.Payout != quoted {
|
||||||
|
t.Fatalf("%d home: the felt quoted %d and settle paid %d", home, quoted, done.Payout)
|
||||||
|
}
|
||||||
|
if done.Payout+done.Rake != done.Earned() {
|
||||||
|
t.Fatalf("%d home: payout %d + rake %d != earned %d",
|
||||||
|
home, done.Payout, done.Rake, done.Earned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFullBoardPaysTheTiersMultiple(t *testing.T) {
|
||||||
|
for _, tier := range Tiers {
|
||||||
|
s := board(tier, 1000)
|
||||||
|
for suit := 0; suit < Foundations; suit++ {
|
||||||
|
for r := 1; r <= 13; r++ {
|
||||||
|
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Gross is the multiple exactly — computed from the total, not summed 52
|
||||||
|
// times, so it doesn't bleed a rounding per card.
|
||||||
|
want := int64(float64(s.Bet) * tier.Base)
|
||||||
|
if s.Earned() != want {
|
||||||
|
t.Errorf("%s: a cleared board earns %d, want %d", tier.Slug, s.Earned(), want)
|
||||||
|
}
|
||||||
|
// And the rake comes out of the winnings, never the stake.
|
||||||
|
profit := want - s.Bet
|
||||||
|
if s.Pays() != want-int64(float64(profit)*rake) {
|
||||||
|
t.Errorf("%s: pays %d, want %d less %v%% of the profit", tier.Slug, s.Pays(), want, rake*100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty board owes nothing, and is not charged a fee for owing nothing.
|
||||||
|
func TestNothingHomePaysNothing(t *testing.T) {
|
||||||
|
s := board(cut(), 500)
|
||||||
|
if s.Pays() != 0 || s.rakeNow() != 0 {
|
||||||
|
t.Fatalf("an empty board pays %d and rakes %d, want nothing either way", s.Pays(), s.rakeNow())
|
||||||
|
}
|
||||||
|
var evs []Event
|
||||||
|
s.settle(OutcomeCashed, &evs)
|
||||||
|
if s.Payout != 0 || s.Net() != -500 {
|
||||||
|
t.Errorf("payout %d net %d, want 0 and -500", s.Payout, s.Net())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Below break-even the player is down but is not raked: there is no profit to
|
||||||
|
// take a cut of.
|
||||||
|
func TestNoRakeBelowTheStake(t *testing.T) {
|
||||||
|
tier := vegas()
|
||||||
|
s := board(tier, 5200)
|
||||||
|
for i := 0; i < tier.BreakEven()-1; i++ {
|
||||||
|
suit, r := i/13, i%13+1
|
||||||
|
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||||
|
}
|
||||||
|
if s.Earned() > s.Bet {
|
||||||
|
t.Fatalf("break-even is meant to be the first card that gets you square, but %d earns %d on a %d stake",
|
||||||
|
s.Home(), s.Earned(), s.Bet)
|
||||||
|
}
|
||||||
|
if s.rakeNow() != 0 {
|
||||||
|
t.Errorf("raked %d off a losing board", s.rakeNow())
|
||||||
|
}
|
||||||
|
if s.Pays() != s.Earned() {
|
||||||
|
t.Errorf("pays %d, want the full %d — nothing to rake", s.Pays(), s.Earned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBreakEvenIsTheCardThatGetsYouSquare(t *testing.T) {
|
||||||
|
for _, tier := range Tiers {
|
||||||
|
s := board(tier, 5200)
|
||||||
|
for i := 0; i < tier.BreakEven(); i++ {
|
||||||
|
suit, r := i/13, i%13+1
|
||||||
|
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||||
|
}
|
||||||
|
if s.Earned() < s.Bet {
|
||||||
|
t.Errorf("%s: %d cards home earns %d on a %d stake — break-even is quoted too low",
|
||||||
|
tier.Slug, s.Home(), s.Earned(), s.Bet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- settling --------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestConcedeCashesTheBoard(t *testing.T) {
|
||||||
|
s := board(vegas(), 5200)
|
||||||
|
s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
|
||||||
|
want := s.Pays()
|
||||||
|
|
||||||
|
next, evs := apply(t, s, Move{Kind: "concede"})
|
||||||
|
if next.Phase != PhaseDone || next.Outcome != OutcomeCashed {
|
||||||
|
t.Fatalf("phase %q outcome %q, want done/cashed", next.Phase, next.Outcome)
|
||||||
|
}
|
||||||
|
if next.Payout != want {
|
||||||
|
t.Errorf("cashed for %d, want the %d the board was quoting", next.Payout, want)
|
||||||
|
}
|
||||||
|
if evs[len(evs)-1].Kind != "settle" {
|
||||||
|
t.Errorf("no settle event: %+v", evs)
|
||||||
|
}
|
||||||
|
refuses(t, next, Move{Kind: "draw"}, ErrGameOver)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last card home ends the game on its own — the player doesn't have to tell
|
||||||
|
// the table they've won.
|
||||||
|
func TestTheLastCardHomeClearsTheBoard(t *testing.T) {
|
||||||
|
s := board(vegas(), 1000)
|
||||||
|
for suit := 0; suit < Foundations; suit++ {
|
||||||
|
top := 13
|
||||||
|
if suit == int(cards.Clubs) {
|
||||||
|
top = 12 // the king of clubs is the one card still out
|
||||||
|
}
|
||||||
|
for r := 1; r <= top; r++ {
|
||||||
|
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Table[0].Up = []cards.Card{card(cards.King, cards.Clubs)}
|
||||||
|
|
||||||
|
next, evs := apply(t, s, Move{Kind: "home", From: "t0"})
|
||||||
|
if next.Phase != PhaseDone || next.Outcome != OutcomeCleared {
|
||||||
|
t.Fatalf("phase %q outcome %q, want done/cleared", next.Phase, next.Outcome)
|
||||||
|
}
|
||||||
|
if next.Payout != int64(float64(1000)*vegas().Base)-int64(float64(int64(float64(1000)*vegas().Base)-1000)*rake) {
|
||||||
|
t.Errorf("a cleared board paid %d", next.Payout)
|
||||||
|
}
|
||||||
|
if evs[len(evs)-1].Kind != "settle" {
|
||||||
|
t.Errorf("the winning card didn't settle the game: %+v", evs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the shape of the thing ------------------------------------------------
|
||||||
|
|
||||||
|
// A game survives a redeploy: the whole state, shoe and face-down cards and all,
|
||||||
|
// goes through JSON and comes back the same board.
|
||||||
|
func TestAGameSurvivesJSON(t *testing.T) {
|
||||||
|
s, _, err := New(500, cut(), rake, cards.NewRNG(7, 11))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
s, _, _ = ApplyMove(s, Move{Kind: "draw"})
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if !sameBoard(s, back) {
|
||||||
|
t.Fatal("the board didn't come back the same")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same seed deals the same board. This is what lets a disputed game be dealt
|
||||||
|
// again exactly as it fell, and it is why the RNG is threaded rather than global.
|
||||||
|
func TestASeedDealsTheSameBoard(t *testing.T) {
|
||||||
|
a, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameBoard(a, b) {
|
||||||
|
t.Fatal("the same seed dealt two different boards")
|
||||||
|
}
|
||||||
|
|
||||||
|
c, _, _ := New(100, vegas(), rake, cards.NewRNG(43, 99))
|
||||||
|
if sameBoard(a, c) {
|
||||||
|
t.Fatal("two seeds dealt the same board")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every card is on the board exactly once, whatever you do to it. A move that
|
||||||
|
// duplicated a card would be a move that printed money.
|
||||||
|
func TestNoCardIsEverLostOrDuplicated(t *testing.T) {
|
||||||
|
rng := rand.New(rand.NewPCG(3, 5))
|
||||||
|
s, _, err := New(1000, patient(), rake, rng)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
countDeck(t, s, "the deal")
|
||||||
|
|
||||||
|
// Play a long random game: whatever the fuzzer stumbles into, the deck holds.
|
||||||
|
for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
|
||||||
|
m := randomMove(rng)
|
||||||
|
next, _, err := ApplyMove(s, m)
|
||||||
|
if err != nil {
|
||||||
|
continue // an illegal move is a fine thing for a fuzzer to find
|
||||||
|
}
|
||||||
|
s = next
|
||||||
|
countDeck(t, s, "after "+m.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomMove(rng *rand.Rand) Move {
|
||||||
|
pile := func() string {
|
||||||
|
switch rng.IntN(3) {
|
||||||
|
case 0:
|
||||||
|
return "waste"
|
||||||
|
case 1:
|
||||||
|
return "t" + strconv.Itoa(rng.IntN(Piles))
|
||||||
|
default:
|
||||||
|
return "f" + strconv.Itoa(rng.IntN(Foundations))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch rng.IntN(10) {
|
||||||
|
case 0, 1, 2, 3:
|
||||||
|
return Move{Kind: "draw"}
|
||||||
|
case 4:
|
||||||
|
return Move{Kind: "home", From: pile()}
|
||||||
|
case 5:
|
||||||
|
return Move{Kind: "auto"}
|
||||||
|
default:
|
||||||
|
return Move{Kind: "move", From: pile(), To: pile(), Count: 1 + rng.IntN(4)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countDeck(t *testing.T, s State, when string) {
|
||||||
|
t.Helper()
|
||||||
|
seen := map[cards.Card]int{}
|
||||||
|
add := func(cs []cards.Card) {
|
||||||
|
for _, c := range cs {
|
||||||
|
seen[c]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(s.Stock)
|
||||||
|
add(s.Waste)
|
||||||
|
for _, p := range s.Table {
|
||||||
|
add(p.Down)
|
||||||
|
add(p.Up)
|
||||||
|
}
|
||||||
|
for _, f := range s.Found {
|
||||||
|
add(f)
|
||||||
|
}
|
||||||
|
if len(seen) != FullDeck {
|
||||||
|
t.Fatalf("%s: %d distinct cards on the board, want 52", when, len(seen))
|
||||||
|
}
|
||||||
|
for c, n := range seen {
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("%s: %v appears %d times", when, c, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The face-up run in every tableau column is always a legal run, and a column
|
||||||
|
// with cards face-up never has an unturned card left under it. Both are things
|
||||||
|
// the *rules* keep true, so a fuzzer that breaks them has found a real bug.
|
||||||
|
func TestTheBoardStaysWellFormed(t *testing.T) {
|
||||||
|
rng := rand.New(rand.NewPCG(11, 13))
|
||||||
|
s, _, err := New(1000, vegas(), rake, rng)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
|
||||||
|
next, _, err := ApplyMove(s, randomMove(rng))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s = next
|
||||||
|
for j, p := range s.Table {
|
||||||
|
if !isRun(p.Up) {
|
||||||
|
t.Fatalf("column %d holds a run that isn't one: %v", j, p.Up)
|
||||||
|
}
|
||||||
|
if len(p.Up) == 0 && len(p.Down) > 0 {
|
||||||
|
t.Fatalf("column %d has %d cards face down and nothing turned over", j, len(p.Down))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for suit, f := range s.Found {
|
||||||
|
for r, c := range f {
|
||||||
|
if int(c.Suit) != suit || int(c.Rank) != r+1 {
|
||||||
|
t.Fatalf("foundation %d holds %v at position %d", suit, c, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
375
internal/games/trivia/trivia.go
Normal file
375
internal/games/trivia/trivia.go
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
// Package trivia is a pure trivia-ladder engine, played for chips.
|
||||||
|
//
|
||||||
|
// Same seam as blackjack and hangman: ApplyMove(state, move, now) (state,
|
||||||
|
// events, error), where an error means the move was illegal and nothing else.
|
||||||
|
// The one difference is that clock: trivia is the only game in the room where
|
||||||
|
// *when* you move changes what it pays, and a pure reducer cannot own a timer.
|
||||||
|
// So the time is an argument. The engine stays a value in, value out, and the
|
||||||
|
// only thing that knows what o'clock it is remains the caller.
|
||||||
|
//
|
||||||
|
// The shape is a ladder. You stake once, and then answer a run of questions:
|
||||||
|
// every right answer multiplies what the stake is worth, a wrong one loses the
|
||||||
|
// lot, and you may walk with what you've built at any point after the first.
|
||||||
|
// It is the oldest quiz-show bet there is — the tension is entirely in whether
|
||||||
|
// you take the money.
|
||||||
|
//
|
||||||
|
// The reason for the clock is less pretty: trivia answers are googlable, and a
|
||||||
|
// game that paid the same for a slow right answer as a fast one would be a game
|
||||||
|
// about typing into another tab. So the multiple a question is worth decays
|
||||||
|
// from Fast to Buzzer across the tier's time limit, and running out of time
|
||||||
|
// loses exactly as much as being wrong. The countdown in the browser is
|
||||||
|
// decoration; this is the clock that counts.
|
||||||
|
package trivia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"math/rand/v2"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Errors an illegal move can produce.
|
||||||
|
var (
|
||||||
|
ErrGameOver = errors.New("trivia: the game is already over")
|
||||||
|
ErrUnknownMove = errors.New("trivia: unknown move")
|
||||||
|
ErrBadBet = errors.New("trivia: bet must be positive")
|
||||||
|
ErrUnknownTier = errors.New("trivia: no such tier")
|
||||||
|
ErrShortLadder = errors.New("trivia: not enough questions to build a ladder")
|
||||||
|
ErrNothingBanked = errors.New("trivia: answer one before you walk")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rungs is how long the ladder is. Clearing it is a win in itself: the run ends
|
||||||
|
// and banks, because a ladder with no top is just a slot machine you can't stop
|
||||||
|
// playing, and eventually every player loses everything to one bad question.
|
||||||
|
const Rungs = 12
|
||||||
|
|
||||||
|
// Tier is a difficulty, chosen before the bet. It sets three things that move
|
||||||
|
// together: how hard the questions are, how long you get, and what a right
|
||||||
|
// answer is worth. Hard questions pay more and give you less time to look them
|
||||||
|
// up, which is the whole bargain.
|
||||||
|
type Tier struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Difficulty string `json:"difficulty"` // what OpenTDB calls it: easy | medium | hard
|
||||||
|
Fast float64 `json:"fast"` // what a right answer multiplies by, answered instantly
|
||||||
|
Buzzer float64 `json:"buzzer"` // ...and what it's worth answered on the last tick
|
||||||
|
Limit int `json:"limit"` // seconds on the clock, per question
|
||||||
|
Blurb string `json:"blurb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiers are the three tables.
|
||||||
|
var Tiers = []Tier{
|
||||||
|
{Slug: "easy", Name: "Easy", Difficulty: "easy", Fast: 1.30, Buzzer: 1.10, Limit: 20,
|
||||||
|
Blurb: "Things you know. The clock is the only thing in your way."},
|
||||||
|
{Slug: "medium", Name: "Medium", Difficulty: "medium", Fast: 1.55, Buzzer: 1.20, Limit: 18,
|
||||||
|
Blurb: "Things you nearly know."},
|
||||||
|
{Slug: "hard", Name: "Hard", Difficulty: "hard", Fast: 1.90, Buzzer: 1.30, Limit: 15,
|
||||||
|
Blurb: "Things you don't. Fifteen seconds is not enough to find out."},
|
||||||
|
}
|
||||||
|
|
||||||
|
// TierBySlug finds a tier by the name the browser sent.
|
||||||
|
func TierBySlug(slug string) (Tier, error) {
|
||||||
|
for _, t := range Tiers {
|
||||||
|
if t.Slug == slug {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Tier{}, ErrUnknownTier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step is what a right answer multiplies the running total by, given how long
|
||||||
|
// it took. Fast at nought seconds, Buzzer at the limit, straight line between.
|
||||||
|
//
|
||||||
|
// Answering at the buzzer still pays *something* — the decay is a reason to be
|
||||||
|
// quick, not a punishment for thinking. The punishment for thinking too long is
|
||||||
|
// the timeout, and that one takes everything.
|
||||||
|
func (t Tier) Step(elapsed time.Duration) float64 {
|
||||||
|
limit := t.Clock()
|
||||||
|
switch {
|
||||||
|
case elapsed <= 0:
|
||||||
|
return t.Fast
|
||||||
|
case elapsed >= limit:
|
||||||
|
return t.Buzzer
|
||||||
|
}
|
||||||
|
speed := 1 - float64(elapsed)/float64(limit) // 1 answering instantly, 0 at the buzzer
|
||||||
|
return t.Buzzer + (t.Fast-t.Buzzer)*speed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clock is the tier's time limit as a duration.
|
||||||
|
func (t Tier) Clock() time.Duration { return time.Duration(t.Limit) * time.Second }
|
||||||
|
|
||||||
|
// Question is one rung. It carries its own correct index, which is exactly why
|
||||||
|
// a State never crosses the wire — the browser is sent the answers and not
|
||||||
|
// which of them is right.
|
||||||
|
type Question struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Answers []string `json:"answers"` // already shuffled: the right one is not always first
|
||||||
|
Correct int `json:"correct"` // index into Answers
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase is where the game is.
|
||||||
|
type Phase string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PhasePlaying Phase = "playing"
|
||||||
|
PhaseDone Phase = "done"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Outcome is how it ended.
|
||||||
|
type Outcome string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutcomeNone Outcome = ""
|
||||||
|
OutcomeWalked Outcome = "walked" // took the money
|
||||||
|
OutcomeCleared Outcome = "cleared" // answered all twelve
|
||||||
|
OutcomeWrong Outcome = "wrong" // picked the wrong one
|
||||||
|
OutcomeTimeout Outcome = "timeout" // ran out of clock
|
||||||
|
)
|
||||||
|
|
||||||
|
// Won reports whether this outcome pays.
|
||||||
|
func (o Outcome) Won() bool { return o == OutcomeWalked || o == OutcomeCleared }
|
||||||
|
|
||||||
|
// State is one game. The ladder — every question, and every right answer — is
|
||||||
|
// in here, which is why this value stays on the server. The browser gets a view
|
||||||
|
// of the current rung and nothing about the ones ahead of it.
|
||||||
|
type State struct {
|
||||||
|
Tier Tier `json:"tier"`
|
||||||
|
Ladder []Question `json:"ladder"` // the whole run, drawn up front
|
||||||
|
Rung int `json:"rung"` // how many answered right; also the index of the live question
|
||||||
|
|
||||||
|
// AskedAt is when the current question was *put to the player*, by the
|
||||||
|
// server's clock. It is the only clock in the game. A reload does not reset
|
||||||
|
// it: you cannot stop time by refreshing.
|
||||||
|
AskedAt time.Time `json:"asked_at"`
|
||||||
|
|
||||||
|
Multiple float64 `json:"multiple"` // 1.0 at the start; the product of every step earned
|
||||||
|
RakePct float64 `json:"rake_pct"`
|
||||||
|
|
||||||
|
Bet int64 `json:"bet"`
|
||||||
|
Phase Phase `json:"phase"`
|
||||||
|
Outcome Outcome `json:"outcome"`
|
||||||
|
Payout int64 `json:"payout"`
|
||||||
|
Rake int64 `json:"rake"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is something the table animates.
|
||||||
|
type Event struct {
|
||||||
|
Kind string `json:"kind"` // "ask" | "right" | "wrong" | "timeout" | "settle"
|
||||||
|
Choice int `json:"choice"` // what the player picked (-1 when they didn't)
|
||||||
|
Correct int `json:"correct"` // which one was right — sent only once it's decided
|
||||||
|
Step float64 `json:"step,omitempty"` // what this answer multiplied by
|
||||||
|
Multiple float64 `json:"multiple,omitempty"` // the running total, after
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move is a player action: pick an answer, or take the money.
|
||||||
|
type Move struct {
|
||||||
|
Choice int `json:"choice"` // index into the live question's answers
|
||||||
|
Walk bool `json:"walk"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// New starts a game on a ladder of questions the caller has already drawn. The
|
||||||
|
// engine does not reach for a database any more than blackjack reaches for a
|
||||||
|
// deck: the questions arrive as a value, so a game is reproducible and a test
|
||||||
|
// can pin every rung.
|
||||||
|
//
|
||||||
|
// The answers are shuffled here, with the caller's seeded rng, because a bank
|
||||||
|
// that always stores the right answer first would otherwise be a game about
|
||||||
|
// clicking first.
|
||||||
|
func New(bet int64, t Tier, rakePct float64, qs []Question, now time.Time, rng *rand.Rand) (State, []Event, error) {
|
||||||
|
if bet <= 0 {
|
||||||
|
return State{}, nil, ErrBadBet
|
||||||
|
}
|
||||||
|
if len(qs) < Rungs {
|
||||||
|
return State{}, nil, ErrShortLadder
|
||||||
|
}
|
||||||
|
ladder := make([]Question, Rungs)
|
||||||
|
for i := range ladder {
|
||||||
|
ladder[i] = shuffleAnswers(qs[i], rng)
|
||||||
|
}
|
||||||
|
s := State{
|
||||||
|
Tier: t, Ladder: ladder, RakePct: rakePct,
|
||||||
|
Multiple: 1,
|
||||||
|
AskedAt: now,
|
||||||
|
Bet: bet, Phase: PhasePlaying,
|
||||||
|
}
|
||||||
|
return s, []Event{{Kind: "ask", Choice: -1, Correct: -1}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shuffleAnswers moves the right answer somewhere the player can't guess from
|
||||||
|
// position, and keeps track of where it went.
|
||||||
|
func shuffleAnswers(q Question, rng *rand.Rand) Question {
|
||||||
|
answers := append([]string(nil), q.Answers...)
|
||||||
|
correct := q.Answers[q.Correct]
|
||||||
|
rng.Shuffle(len(answers), func(i, j int) { answers[i], answers[j] = answers[j], answers[i] })
|
||||||
|
out := q
|
||||||
|
out.Answers = answers
|
||||||
|
for i, a := range answers {
|
||||||
|
if a == correct {
|
||||||
|
out.Correct = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live is the question the player is looking at.
|
||||||
|
func (s State) Live() Question {
|
||||||
|
if s.Rung < 0 || s.Rung >= len(s.Ladder) {
|
||||||
|
return Question{}
|
||||||
|
}
|
||||||
|
return s.Ladder[s.Rung]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left is how much clock the live question has, at the given moment. It goes to
|
||||||
|
// the browser so its countdown starts where the server's does — but the browser
|
||||||
|
// is never asked what it says.
|
||||||
|
func (s State) Left(now time.Time) time.Duration {
|
||||||
|
d := s.Tier.Clock() - now.Sub(s.AskedAt)
|
||||||
|
if d < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyMove is the engine. now is the server's clock, and the only one that
|
||||||
|
// counts toward the answer.
|
||||||
|
func ApplyMove(s State, m Move, now time.Time) (State, []Event, error) {
|
||||||
|
if s.Phase == PhaseDone {
|
||||||
|
return s, nil, ErrGameOver
|
||||||
|
}
|
||||||
|
s = s.clone()
|
||||||
|
|
||||||
|
q := s.Live()
|
||||||
|
if len(q.Answers) == 0 {
|
||||||
|
return s, nil, ErrUnknownMove
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := now.Sub(s.AskedAt)
|
||||||
|
|
||||||
|
// Out of time. This is a loss, and it has to be — a timeout that merely cost
|
||||||
|
// you the speed bonus would make "leave it open in another tab and go and
|
||||||
|
// look it up" the strongest way to play.
|
||||||
|
//
|
||||||
|
// It is checked before *everything*, walking included. A dead clock that you
|
||||||
|
// could still walk away from would be no clock at all: you would sit on every
|
||||||
|
// question for as long as you liked, answer the ones you found and walk off
|
||||||
|
// the ones you didn't, and never once lose the ladder. The timeout has to be
|
||||||
|
// the first thing that happens to a move, or it is not a deadline.
|
||||||
|
if elapsed > s.Tier.Clock() {
|
||||||
|
evs := []Event{{Kind: "timeout", Choice: -1, Correct: q.Correct}}
|
||||||
|
s.settle(OutcomeTimeout, &evs)
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Walk {
|
||||||
|
// You cannot walk off a rung you haven't climbed. If you could, seeing the
|
||||||
|
// first question and walking away would be a free look: stake, peek, walk,
|
||||||
|
// stake again, and keep reshuffling until the question is one you know.
|
||||||
|
// The first question is therefore the price of sitting down.
|
||||||
|
if s.Rung == 0 {
|
||||||
|
return s, nil, ErrNothingBanked
|
||||||
|
}
|
||||||
|
evs := []Event{}
|
||||||
|
s.settle(OutcomeWalked, &evs)
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Choice < 0 || m.Choice >= len(q.Answers) {
|
||||||
|
return s, nil, ErrUnknownMove
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Choice != q.Correct {
|
||||||
|
evs := []Event{{Kind: "wrong", Choice: m.Choice, Correct: q.Correct}}
|
||||||
|
s.settle(OutcomeWrong, &evs)
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right, and quick enough to be worth something.
|
||||||
|
step := s.Tier.Step(elapsed)
|
||||||
|
s.Multiple *= step
|
||||||
|
s.Rung++
|
||||||
|
evs := []Event{{
|
||||||
|
Kind: "right", Choice: m.Choice, Correct: q.Correct,
|
||||||
|
Step: step, Multiple: s.Multiple,
|
||||||
|
}}
|
||||||
|
|
||||||
|
if s.Rung >= Rungs {
|
||||||
|
s.settle(OutcomeCleared, &evs)
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next question goes up, and its clock starts now.
|
||||||
|
s.AskedAt = now
|
||||||
|
evs = append(evs, Event{Kind: "ask", Choice: -1, Correct: -1})
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pays is what banking *right now* would put back on the player's stack: the
|
||||||
|
// stake, plus the winnings, less the house's cut of the winnings.
|
||||||
|
//
|
||||||
|
// It exists for the same reason hangman's does. The felt quotes this number
|
||||||
|
// while the game is still running — it is the "take the money" button's label —
|
||||||
|
// and settle() calls it rather than doing the sum a second time, so the table
|
||||||
|
// can never advertise a payout the house doesn't hand over.
|
||||||
|
func (s State) Pays() int64 {
|
||||||
|
total := int64(math.Floor(float64(s.Bet) * s.Multiple))
|
||||||
|
if total < s.Bet {
|
||||||
|
total = s.Bet // banking never hands back less than the stake
|
||||||
|
}
|
||||||
|
profit := total - s.Bet
|
||||||
|
if profit > 0 {
|
||||||
|
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||||
|
if rake > 0 {
|
||||||
|
profit -= rake
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.Bet + profit
|
||||||
|
}
|
||||||
|
|
||||||
|
// rakeNow is the other half of what Pays works out: the house's cut of a win
|
||||||
|
// banked at this moment. Never taken from the stake, so a player who walks
|
||||||
|
// having answered nothing — which they can't — and one who loses, pay nothing.
|
||||||
|
func (s State) rakeNow() int64 {
|
||||||
|
total := int64(math.Floor(float64(s.Bet) * s.Multiple))
|
||||||
|
if total <= s.Bet {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
rake := int64(math.Floor(float64(total-s.Bet) * s.RakePct))
|
||||||
|
if rake < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return rake
|
||||||
|
}
|
||||||
|
|
||||||
|
// settle decides the payout. Same rule as every other table in the room: the
|
||||||
|
// rake comes out of winnings, never out of the stake, and a loss is never
|
||||||
|
// charged a fee.
|
||||||
|
func (s *State) settle(o Outcome, evs *[]Event) {
|
||||||
|
s.Outcome = o
|
||||||
|
s.Phase = PhaseDone
|
||||||
|
|
||||||
|
if o.Won() {
|
||||||
|
s.Payout = s.Pays()
|
||||||
|
s.Rake = s.rakeNow()
|
||||||
|
} else {
|
||||||
|
s.Payout = 0
|
||||||
|
}
|
||||||
|
*evs = append(*evs, Event{Kind: "settle", Choice: -1, Correct: -1, Text: string(o)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Net is what the game did to the player's stack.
|
||||||
|
func (s State) Net() int64 {
|
||||||
|
if s.Phase != PhaseDone {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return s.Payout - s.Bet
|
||||||
|
}
|
||||||
|
|
||||||
|
// clone deep-copies the ladder, so a derived state shares no backing array with
|
||||||
|
// the one it came from and a game can be replayed freely.
|
||||||
|
func (s State) clone() State {
|
||||||
|
s.Ladder = append([]Question(nil), s.Ladder...)
|
||||||
|
return s
|
||||||
|
}
|
||||||
352
internal/games/trivia/trivia_test.go
Normal file
352
internal/games/trivia/trivia_test.go
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
package trivia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func rng() *rand.Rand { return rand.New(rand.NewPCG(1, 2)) }
|
||||||
|
|
||||||
|
var epoch = time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// bank builds n questions whose right answer is always "right", so a test can
|
||||||
|
// find it after the shuffle without caring where it landed.
|
||||||
|
func bank(n int) []Question {
|
||||||
|
qs := make([]Question, n)
|
||||||
|
for i := range qs {
|
||||||
|
qs[i] = Question{
|
||||||
|
Category: "General",
|
||||||
|
Text: "question?",
|
||||||
|
Answers: []string{"right", "wrong1", "wrong2", "wrong3"},
|
||||||
|
Correct: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return qs
|
||||||
|
}
|
||||||
|
|
||||||
|
func tier(slug string) Tier {
|
||||||
|
t, err := TierBySlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGame(t *testing.T, bet int64, slug string) State {
|
||||||
|
t.Helper()
|
||||||
|
s, evs, err := New(bet, tier(slug), 0.05, bank(Rungs), epoch, rng())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
if len(evs) != 1 || evs[0].Kind != "ask" {
|
||||||
|
t.Fatalf("New should open with one ask, got %+v", evs)
|
||||||
|
}
|
||||||
|
if s.Multiple != 1 {
|
||||||
|
t.Fatalf("a fresh ladder is worth the stake, got multiple %v", s.Multiple)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerRight plays the live question correctly, after `took` on the clock.
|
||||||
|
func answerRight(t *testing.T, s State, took time.Duration) (State, []Event) {
|
||||||
|
t.Helper()
|
||||||
|
q := s.Live()
|
||||||
|
next, evs, err := ApplyMove(s, Move{Choice: q.Correct}, s.AskedAt.Add(took))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("right answer refused: %v", err)
|
||||||
|
}
|
||||||
|
return next, evs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewShufflesButKeepsTheAnswer(t *testing.T) {
|
||||||
|
s := newGame(t, 100, "medium")
|
||||||
|
moved := 0
|
||||||
|
for _, q := range s.Ladder {
|
||||||
|
if q.Answers[q.Correct] != "right" {
|
||||||
|
t.Fatalf("Correct points at %q, not the right answer", q.Answers[q.Correct])
|
||||||
|
}
|
||||||
|
if q.Correct != 0 {
|
||||||
|
moved++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All twelve landing on index 0 would mean the shuffle isn't running, and the
|
||||||
|
// game would be "always click the first one".
|
||||||
|
if moved == 0 {
|
||||||
|
t.Fatal("the right answer is first in every question — the shuffle did nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortBankIsRefused(t *testing.T) {
|
||||||
|
if _, _, err := New(100, tier("easy"), 0.05, bank(Rungs-1), epoch, rng()); err != ErrShortLadder {
|
||||||
|
t.Fatalf("a ladder with a missing rung should be refused, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one that matters most: the number the felt quotes is the number the
|
||||||
|
// player is actually paid, at every rung, exactly as in hangman.
|
||||||
|
func TestTheQuoteIsThePayout(t *testing.T) {
|
||||||
|
s := newGame(t, 200, "hard")
|
||||||
|
for rung := 1; rung < Rungs; rung++ {
|
||||||
|
s, _ = answerRight(t, s, 3*time.Second)
|
||||||
|
|
||||||
|
quoted := s.Pays() // what the "take the money" button says it's worth
|
||||||
|
|
||||||
|
banked, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rung %d: walk refused: %v", rung, err)
|
||||||
|
}
|
||||||
|
if banked.Payout != quoted {
|
||||||
|
t.Fatalf("rung %d: the felt quoted %d and the house paid %d", rung, quoted, banked.Payout)
|
||||||
|
}
|
||||||
|
if banked.Phase != PhaseDone || banked.Outcome != OutcomeWalked {
|
||||||
|
t.Fatalf("rung %d: walking should end the game, got %s/%s", rung, banked.Phase, banked.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walking before answering anything would be a free look at the first question:
|
||||||
|
// stake, peek, walk, restake until the question is one you happen to know.
|
||||||
|
func TestYouCannotWalkOffTheFirstRung(t *testing.T) {
|
||||||
|
s := newGame(t, 100, "easy")
|
||||||
|
if _, _, err := ApplyMove(s, Move{Walk: true}, epoch); err != ErrNothingBanked {
|
||||||
|
t.Fatalf("walking on rung 0 should be refused, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One right answer, and now you may.
|
||||||
|
s, _ = answerRight(t, s, time.Second)
|
||||||
|
if _, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt); err != nil {
|
||||||
|
t.Fatalf("walking after a right answer should be allowed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWrongAnswerLosesTheLot(t *testing.T) {
|
||||||
|
s := newGame(t, 300, "medium")
|
||||||
|
// Build a decent ladder first, so there is something real to lose.
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
s, _ = answerRight(t, s, time.Second)
|
||||||
|
}
|
||||||
|
if s.Pays() <= 300 {
|
||||||
|
t.Fatalf("four right answers should be worth more than the stake, got %d", s.Pays())
|
||||||
|
}
|
||||||
|
|
||||||
|
q := s.Live()
|
||||||
|
wrong := (q.Correct + 1) % len(q.Answers)
|
||||||
|
out, evs, err := ApplyMove(s, Move{Choice: wrong}, s.AskedAt.Add(time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a wrong answer is a legal move: %v", err)
|
||||||
|
}
|
||||||
|
if out.Outcome != OutcomeWrong || out.Payout != 0 {
|
||||||
|
t.Fatalf("a wrong answer should pay nothing, got %s/%d", out.Outcome, out.Payout)
|
||||||
|
}
|
||||||
|
if out.Rake != 0 {
|
||||||
|
t.Fatalf("a loss must never be charged a rake, got %d", out.Rake)
|
||||||
|
}
|
||||||
|
if out.Net() != -300 {
|
||||||
|
t.Fatalf("a wrong answer costs the stake and nothing more, got %d", out.Net())
|
||||||
|
}
|
||||||
|
// The player is told which one it was.
|
||||||
|
if evs[0].Kind != "wrong" || evs[0].Correct != q.Correct {
|
||||||
|
t.Fatalf("a wrong answer should reveal the right one, got %+v", evs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clock is the whole anti-google mechanism: running out of it has to cost
|
||||||
|
// as much as being wrong, or leaving the tab open and looking it up wins.
|
||||||
|
func TestTheClockTakesEverything(t *testing.T) {
|
||||||
|
s := newGame(t, 250, "hard")
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
s, _ = answerRight(t, s, time.Second)
|
||||||
|
}
|
||||||
|
banked := s.Pays()
|
||||||
|
|
||||||
|
q := s.Live()
|
||||||
|
late := s.AskedAt.Add(s.Tier.Clock() + time.Millisecond)
|
||||||
|
out, evs, err := ApplyMove(s, Move{Choice: q.Correct}, late) // the *right* answer, too late
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a late answer is a legal move: %v", err)
|
||||||
|
}
|
||||||
|
if out.Outcome != OutcomeTimeout {
|
||||||
|
t.Fatalf("answering past the limit should time out, got %s", out.Outcome)
|
||||||
|
}
|
||||||
|
if out.Payout != 0 {
|
||||||
|
t.Fatalf("a timeout pays nothing — it was worth %d a moment ago, and paid %d", banked, out.Payout)
|
||||||
|
}
|
||||||
|
if evs[0].Kind != "timeout" {
|
||||||
|
t.Fatalf("expected a timeout event, got %+v", evs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// And answering on the final tick still counts.
|
||||||
|
onTime := s.AskedAt.Add(s.Tier.Clock())
|
||||||
|
if out, _, err = ApplyMove(s, Move{Choice: q.Correct}, onTime); err != nil {
|
||||||
|
t.Fatalf("an answer on the buzzer is legal: %v", err)
|
||||||
|
}
|
||||||
|
if out.Rung != s.Rung+1 {
|
||||||
|
t.Fatal("an answer on the final tick should still count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speed is the only thing separating a slow right answer from a fast one.
|
||||||
|
func TestFasterPaysMore(t *testing.T) {
|
||||||
|
base := newGame(t, 1000, "hard")
|
||||||
|
|
||||||
|
quick, _ := answerRight(t, base, time.Second)
|
||||||
|
slow, _ := answerRight(t, base, 14*time.Second)
|
||||||
|
|
||||||
|
if quick.Multiple <= slow.Multiple {
|
||||||
|
t.Fatalf("a quick answer should be worth more: quick %v, slow %v", quick.Multiple, slow.Multiple)
|
||||||
|
}
|
||||||
|
if quick.Pays() <= slow.Pays() {
|
||||||
|
t.Fatalf("a quick answer should pay more: quick %d, slow %d", quick.Pays(), slow.Pays())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ends of the scale are the tier's own numbers, and nothing is outside them.
|
||||||
|
instant, _ := answerRight(t, base, 0)
|
||||||
|
buzzer, _ := answerRight(t, base, base.Tier.Clock())
|
||||||
|
if instant.Multiple != base.Tier.Fast {
|
||||||
|
t.Fatalf("an instant answer is worth Fast (%v), got %v", base.Tier.Fast, instant.Multiple)
|
||||||
|
}
|
||||||
|
if buzzer.Multiple != base.Tier.Buzzer {
|
||||||
|
t.Fatalf("an answer on the buzzer is worth Buzzer (%v), got %v", base.Tier.Buzzer, buzzer.Multiple)
|
||||||
|
}
|
||||||
|
if quick.Multiple > base.Tier.Fast || slow.Multiple < base.Tier.Buzzer {
|
||||||
|
t.Fatal("a step escaped the tier's range")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clearing the ladder ends the run and banks it, rather than leaving the player
|
||||||
|
// on a rung that doesn't exist.
|
||||||
|
func TestClearingTheLadderBanks(t *testing.T) {
|
||||||
|
s := newGame(t, 100, "easy")
|
||||||
|
for i := 0; i < Rungs; i++ {
|
||||||
|
if s.Phase != PhasePlaying {
|
||||||
|
t.Fatalf("the game ended early, on rung %d", i)
|
||||||
|
}
|
||||||
|
s, _ = answerRight(t, s, time.Second)
|
||||||
|
}
|
||||||
|
if s.Outcome != OutcomeCleared {
|
||||||
|
t.Fatalf("twelve right answers should clear the ladder, got %s", s.Outcome)
|
||||||
|
}
|
||||||
|
if s.Rung != Rungs {
|
||||||
|
t.Fatalf("expected to be on rung %d, got %d", Rungs, s.Rung)
|
||||||
|
}
|
||||||
|
if s.Payout != s.Pays() || s.Payout <= s.Bet {
|
||||||
|
t.Fatalf("clearing should bank a win, got payout %d on a %d stake", s.Payout, s.Bet)
|
||||||
|
}
|
||||||
|
if _, _, err := ApplyMove(s, Move{Choice: 0}, s.AskedAt); err != ErrGameOver {
|
||||||
|
t.Fatalf("a cleared ladder takes no more moves, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rake comes out of winnings, never out of the stake.
|
||||||
|
func TestRakeOnlyBitesWinnings(t *testing.T) {
|
||||||
|
s := newGame(t, 1000, "medium")
|
||||||
|
s, _ = answerRight(t, s, 0) // instant: multiple is exactly Fast, so the sum is checkable by hand
|
||||||
|
|
||||||
|
banked, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("walk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
total := int64(float64(1000) * s.Tier.Fast) // 1550
|
||||||
|
profit := total - 1000 // 550
|
||||||
|
rake := int64(float64(profit) * 0.05) // 27
|
||||||
|
want := 1000 + profit - rake // 1523
|
||||||
|
|
||||||
|
if banked.Payout != want {
|
||||||
|
t.Fatalf("payout should be stake + winnings - 5%% of winnings = %d, got %d", want, banked.Payout)
|
||||||
|
}
|
||||||
|
if banked.Rake != rake {
|
||||||
|
t.Fatalf("rake should be %d, got %d", rake, banked.Rake)
|
||||||
|
}
|
||||||
|
if banked.Payout < banked.Bet {
|
||||||
|
t.Fatal("a win handed back less than the stake")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A move must not scribble on the state it came from — a game has to replay.
|
||||||
|
func TestApplyMoveDoesNotMutateItsInput(t *testing.T) {
|
||||||
|
s := newGame(t, 100, "easy")
|
||||||
|
before := s.Live()
|
||||||
|
|
||||||
|
next, _, err := ApplyMove(s, Move{Choice: before.Correct}, s.AskedAt.Add(time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("move: %v", err)
|
||||||
|
}
|
||||||
|
if s.Rung != 0 || s.Multiple != 1 || s.Phase != PhasePlaying {
|
||||||
|
t.Fatalf("the original state moved underneath us: rung %d multiple %v", s.Rung, s.Multiple)
|
||||||
|
}
|
||||||
|
if next.Rung != 1 {
|
||||||
|
t.Fatalf("the derived state should have climbed a rung, got %d", next.Rung)
|
||||||
|
}
|
||||||
|
// The same move replays to the same place.
|
||||||
|
again, _, err := ApplyMove(s, Move{Choice: before.Correct}, s.AskedAt.Add(time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replay: %v", err)
|
||||||
|
}
|
||||||
|
if again.Multiple != next.Multiple || again.Rung != next.Rung {
|
||||||
|
t.Fatal("the same move from the same state landed somewhere else")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeftCountsDown(t *testing.T) {
|
||||||
|
s := newGame(t, 100, "hard") // 15s
|
||||||
|
if got := s.Left(epoch); got != 15*time.Second {
|
||||||
|
t.Fatalf("a fresh question has the whole clock, got %v", got)
|
||||||
|
}
|
||||||
|
if got := s.Left(epoch.Add(10 * time.Second)); got != 5*time.Second {
|
||||||
|
t.Fatalf("expected 5s left, got %v", got)
|
||||||
|
}
|
||||||
|
// It floors at nought rather than going negative, so a browser can render it.
|
||||||
|
if got := s.Left(epoch.Add(time.Hour)); got != 0 {
|
||||||
|
t.Fatalf("the clock should stop at zero, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGarbageMovesAreRefused(t *testing.T) {
|
||||||
|
s := newGame(t, 100, "easy")
|
||||||
|
for _, choice := range []int{-1, 4, 99} {
|
||||||
|
if _, _, err := ApplyMove(s, Move{Choice: choice}, s.AskedAt); err != ErrUnknownMove {
|
||||||
|
t.Fatalf("choice %d should be refused, got %v", choice, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Phase != PhasePlaying {
|
||||||
|
t.Fatal("a refused move should leave the game alone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clock has to beat the walk button, or it is not a deadline.
|
||||||
|
//
|
||||||
|
// If a dead clock could still be walked away from, the ladder would carry no
|
||||||
|
// risk at all: sit on every question for as long as you like, answer the ones
|
||||||
|
// you can look up, and walk off the ones you can't. The timeout has to be the
|
||||||
|
// first thing that happens to a move.
|
||||||
|
func TestWalkingOffADeadClockIsATimeout(t *testing.T) {
|
||||||
|
s := newGame(t, 500, "hard")
|
||||||
|
s, _ = answerRight(t, s, time.Second) // one rung banked, so a walk is otherwise legal
|
||||||
|
|
||||||
|
late := s.AskedAt.Add(s.Tier.Clock() + time.Second)
|
||||||
|
out, evs, err := ApplyMove(s, Move{Walk: true}, late)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("walking after the clock died should resolve, not error: %v", err)
|
||||||
|
}
|
||||||
|
if out.Outcome != OutcomeTimeout {
|
||||||
|
t.Fatalf("a walk after the clock ran out is a timeout, got %q", out.Outcome)
|
||||||
|
}
|
||||||
|
if out.Payout != 0 {
|
||||||
|
t.Fatalf("a timeout pays nothing, got %d", out.Payout)
|
||||||
|
}
|
||||||
|
if len(evs) == 0 || evs[0].Kind != "timeout" {
|
||||||
|
t.Fatalf("expected the timeout event first, got %+v", evs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the same walk, one tick inside the limit, still banks.
|
||||||
|
intime := s.AskedAt.Add(s.Tier.Clock() - time.Millisecond)
|
||||||
|
banked, _, err := ApplyMove(s, Move{Walk: true}, intime)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("walk with the clock still running: %v", err)
|
||||||
|
}
|
||||||
|
if banked.Outcome != OutcomeWalked || banked.Payout <= 0 {
|
||||||
|
t.Fatalf("a walk inside the clock banks, got %q paying %d", banked.Outcome, banked.Payout)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
var parts []string
|
||||||
|
if source != "" {
|
||||||
|
parts = append(parts, strings.ToLower(source))
|
||||||
|
}
|
||||||
|
parts = append(parts, platforms...)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i, p := range parts {
|
||||||
if isHTML {
|
if isHTML {
|
||||||
parts := []string{fmt.Sprintf("<code>%s</code>", html.EscapeString(strings.ToLower(source)))}
|
parts[i] = fmt.Sprintf("<code>%s</code>", html.EscapeString(p))
|
||||||
for _, p := range platforms {
|
} else {
|
||||||
parts = append(parts, fmt.Sprintf("<code>%s</code>", html.EscapeString(p)))
|
parts[i] = fmt.Sprintf("`%s`", p)
|
||||||
}
|
}
|
||||||
return strings.Join(parts, " \u00b7 ")
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := []string{fmt.Sprintf("`%s`", strings.ToLower(source))}
|
|
||||||
for _, p := range platforms {
|
|
||||||
parts = append(parts, fmt.Sprintf("`%s`", p))
|
|
||||||
}
|
}
|
||||||
return strings.Join(parts, " \u00b7 ")
|
return strings.Join(parts, " \u00b7 ")
|
||||||
}
|
}
|
||||||
|
|||||||
180
internal/opentdb/opentdb.go
Normal file
180
internal/opentdb/opentdb.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
// Package opentdb fills the casino's trivia bank from the Open Trivia Database.
|
||||||
|
//
|
||||||
|
// The questions are *prefetched* into a local table, not fetched per question,
|
||||||
|
// and that is a deliberate call rather than an optimisation. A trivia ladder
|
||||||
|
// asks a question every fifteen seconds with money on the clock: a per-question
|
||||||
|
// fetch would put somebody else's latency, rate limit and downtime inside a
|
||||||
|
// timed round the player is being scored against. Pull the bank in the
|
||||||
|
// background, and a round becomes a local read that either works or doesn't.
|
||||||
|
//
|
||||||
|
// OpenTDB allows one request every five seconds per IP and caps a batch at 50,
|
||||||
|
// so the refill is a slow, polite drip, run in the background and never in the
|
||||||
|
// path of anything a player is waiting for.
|
||||||
|
package opentdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/games/trivia"
|
||||||
|
"pete/internal/safehttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// endpoint is the API. It is the only host this package ever talks to, and it
|
||||||
|
// goes through safehttp like every other outbound fetch in Pete.
|
||||||
|
const endpoint = "https://opentdb.com/api.php"
|
||||||
|
|
||||||
|
// Batch is the most OpenTDB will hand over in one request.
|
||||||
|
const Batch = 50
|
||||||
|
|
||||||
|
// Politeness is the gap the API asks for between requests. Going faster earns a
|
||||||
|
// response_code 5 and nothing else.
|
||||||
|
const Politeness = 6 * time.Second
|
||||||
|
|
||||||
|
// fetchTimeout bounds a single request. The refill runs in the background, so a
|
||||||
|
// slow answer costs nothing but its own goroutine — but it must still end.
|
||||||
|
const fetchTimeout = 20 * time.Second
|
||||||
|
|
||||||
|
// maxBody caps what we will read from the API, hostile or merely broken.
|
||||||
|
const maxBody = 1 << 20
|
||||||
|
|
||||||
|
// apiResponse is OpenTDB's envelope. ResponseCode is the part that matters:
|
||||||
|
// zero is the only one that means "here are your questions".
|
||||||
|
type apiResponse struct {
|
||||||
|
ResponseCode int `json:"response_code"`
|
||||||
|
Results []struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Question string `json:"question"`
|
||||||
|
Correct string `json:"correct_answer"`
|
||||||
|
Incorrect []string `json:"incorrect_answers"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// responseErr turns a non-zero code into something a log line can explain.
|
||||||
|
func responseErr(code int) error {
|
||||||
|
switch code {
|
||||||
|
case 1:
|
||||||
|
return fmt.Errorf("opentdb: no results for that query")
|
||||||
|
case 2:
|
||||||
|
return fmt.Errorf("opentdb: the query was invalid")
|
||||||
|
case 3, 4:
|
||||||
|
return fmt.Errorf("opentdb: session token expired or exhausted")
|
||||||
|
case 5:
|
||||||
|
return fmt.Errorf("opentdb: rate limited — slow down")
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("opentdb: response code %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client fetches questions.
|
||||||
|
type Client struct {
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Client {
|
||||||
|
return &Client{http: safehttp.NewClient(fetchTimeout)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch pulls up to n multiple-choice questions of one difficulty.
|
||||||
|
//
|
||||||
|
// Only "multiple" questions are asked for: the ladder is four buttons, and a
|
||||||
|
// true/false question on the same felt would be a coin flip dressed up as a
|
||||||
|
// question — and a coin flip the player is being paid a difficulty multiple for.
|
||||||
|
func (c *Client) Fetch(ctx context.Context, difficulty string, n int) ([]trivia.Question, error) {
|
||||||
|
if n <= 0 || n > Batch {
|
||||||
|
n = Batch
|
||||||
|
}
|
||||||
|
q := url.Values{
|
||||||
|
"amount": {fmt.Sprint(n)},
|
||||||
|
"difficulty": {difficulty},
|
||||||
|
"type": {"multiple"},
|
||||||
|
}
|
||||||
|
raw := endpoint + "?" + q.Encode()
|
||||||
|
if err := safehttp.ValidateURL(raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "pete-games/1.0 (+https://games.parodia.dev)")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("opentdb: http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(safehttp.LimitedBody(resp.Body, maxBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var out apiResponse
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("opentdb: %w", err)
|
||||||
|
}
|
||||||
|
if out.ResponseCode != 0 {
|
||||||
|
return nil, responseErr(out.ResponseCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
qs := make([]trivia.Question, 0, len(out.Results))
|
||||||
|
for _, r := range out.Results {
|
||||||
|
// The API hands back HTML entities ("Who wrote "Dune"?"), which
|
||||||
|
// would otherwise be drawn literally onto a button.
|
||||||
|
text := clean(r.Question)
|
||||||
|
correct := clean(r.Correct)
|
||||||
|
if text == "" || correct == "" || len(r.Incorrect) != 3 {
|
||||||
|
continue // a malformed question is one we simply don't take
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct: 0 here is a convention, not a tell. The engine reshuffles every
|
||||||
|
// question against the game's own seed as it builds the ladder, so where
|
||||||
|
// the right answer sits in the bank never reaches a player.
|
||||||
|
answers := make([]string, 0, 4)
|
||||||
|
answers = append(answers, correct)
|
||||||
|
dupe := false
|
||||||
|
for _, w := range r.Incorrect {
|
||||||
|
a := clean(w)
|
||||||
|
// A wrong answer that reads the same as the right one — usually two
|
||||||
|
// spellings that collapse once the entities are decoded — is a question
|
||||||
|
// with two identical buttons on it, and the shuffle can only call one of
|
||||||
|
// them correct. A player who clicked the right words and was told they
|
||||||
|
// were wrong has lost the whole ladder to our typography. Drop it.
|
||||||
|
if a == "" || a == correct {
|
||||||
|
dupe = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
answers = append(answers, a)
|
||||||
|
}
|
||||||
|
if dupe || len(answers) != 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qs = append(qs, trivia.Question{
|
||||||
|
Category: clean(r.Category),
|
||||||
|
Text: text,
|
||||||
|
Answers: answers,
|
||||||
|
Correct: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return qs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clean turns an API string into something you can put on a button: entities
|
||||||
|
// decoded, whitespace tidied.
|
||||||
|
func clean(s string) string {
|
||||||
|
return strings.TrimSpace(html.UnescapeString(s))
|
||||||
|
}
|
||||||
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,114 @@ 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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The trivia bank: questions pulled from the Open Trivia Database ahead of time,
|
||||||
|
-- so that asking one is a local read.
|
||||||
|
--
|
||||||
|
-- Prefetched rather than fetched per question because a trivia ladder asks a
|
||||||
|
-- question every fifteen seconds with money on a clock the player is scored
|
||||||
|
-- against. A live fetch would put somebody else's latency and rate limit inside
|
||||||
|
-- that clock. The refill is a slow background drip (internal/opentdb); a round
|
||||||
|
-- never waits on it.
|
||||||
|
--
|
||||||
|
-- The question text is UNIQUE, which is the whole dedup strategy: OpenTDB hands back
|
||||||
|
-- overlapping batches and the bank would otherwise fill up with the same forty
|
||||||
|
-- questions. correct/incorrect are stored as the API gives them; the *shuffle*
|
||||||
|
-- happens in the engine, per game, against that game's seed — so where the right
|
||||||
|
-- answer sits in this table tells a player nothing.
|
||||||
|
CREATE TABLE IF NOT EXISTS trivia_questions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
difficulty TEXT NOT NULL, -- 'easy' | 'medium' | 'hard'
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
question TEXT NOT NULL UNIQUE,
|
||||||
|
correct TEXT NOT NULL,
|
||||||
|
incorrect TEXT NOT NULL, -- JSON array of the three wrong answers
|
||||||
|
fetched_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trivia_difficulty ON trivia_questions(difficulty);
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
147
internal/storage/trivia.go
Normal file
147
internal/storage/trivia.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/games/trivia"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The trivia bank.
|
||||||
|
//
|
||||||
|
// Questions are pulled from OpenTDB in the background (internal/opentdb) and
|
||||||
|
// drawn from here when a ladder is built. Nothing in a player's round ever
|
||||||
|
// touches the network.
|
||||||
|
|
||||||
|
// ErrBankEmpty means the bank hasn't got enough questions of that difficulty to
|
||||||
|
// build a ladder. It is a real state, not a bug: a fresh database has an empty
|
||||||
|
// bank until the refill loop has been round a few times.
|
||||||
|
var ErrBankEmpty = fmt.Errorf("trivia: the bank is short of questions")
|
||||||
|
|
||||||
|
// AddTriviaQuestions files a fetched batch. Questions already in the bank are
|
||||||
|
// ignored rather than replaced — OpenTDB hands back overlapping batches, and the
|
||||||
|
// UNIQUE on the text is what stops the bank becoming forty questions deep.
|
||||||
|
// Returns how many were actually new, which is what the refill loop logs.
|
||||||
|
func AddTriviaQuestions(difficulty string, qs []trivia.Question) (int, error) {
|
||||||
|
if len(qs) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
tx, err := Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("trivia: begin: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare(
|
||||||
|
`INSERT OR IGNORE INTO trivia_questions
|
||||||
|
(difficulty, category, question, correct, incorrect, fetched_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("trivia: prepare: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
added := 0
|
||||||
|
for _, q := range qs {
|
||||||
|
if len(q.Answers) < 2 || q.Correct < 0 || q.Correct >= len(q.Answers) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
correct := q.Answers[q.Correct]
|
||||||
|
wrong := make([]string, 0, len(q.Answers)-1)
|
||||||
|
for i, a := range q.Answers {
|
||||||
|
if i != q.Correct {
|
||||||
|
wrong = append(wrong, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blob, err := json.Marshal(wrong)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res, err := stmt.Exec(difficulty, q.Category, q.Text, correct, string(blob), now)
|
||||||
|
if err != nil {
|
||||||
|
return added, fmt.Errorf("trivia: insert: %w", err)
|
||||||
|
}
|
||||||
|
if n, err := res.RowsAffected(); err == nil {
|
||||||
|
added += int(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, fmt.Errorf("trivia: commit: %w", err)
|
||||||
|
}
|
||||||
|
return added, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountTrivia is how many questions of a difficulty the bank holds. The refill
|
||||||
|
// loop reads it to decide whether to bother.
|
||||||
|
func CountTrivia(difficulty string) (int, error) {
|
||||||
|
var n int
|
||||||
|
if err := Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM trivia_questions WHERE difficulty = ?`, difficulty,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("trivia: count: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawTrivia deals a ladder: n distinct questions of one difficulty, chosen with
|
||||||
|
// the game's own rng.
|
||||||
|
//
|
||||||
|
// The choice is made in Go rather than with ORDER BY RANDOM() so that the seed
|
||||||
|
// in the audit log means something: the same seed against the same bank deals
|
||||||
|
// the same ladder, which is what lets a disputed game be replayed. It reads the
|
||||||
|
// ids first and picks from them, so a bank of a few thousand questions costs one
|
||||||
|
// small scan rather than a sort of the whole table.
|
||||||
|
func DrawTrivia(difficulty string, n int, rng *rand.Rand) ([]trivia.Question, error) {
|
||||||
|
if n <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`SELECT id FROM trivia_questions WHERE difficulty = ? ORDER BY id`, difficulty)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("trivia: draw ids: %w", err)
|
||||||
|
}
|
||||||
|
var ids []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("trivia: scan id: %w", err)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("trivia: draw ids: %w", err)
|
||||||
|
}
|
||||||
|
if len(ids) < n {
|
||||||
|
return nil, ErrBankEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
|
||||||
|
pick := ids[:n]
|
||||||
|
|
||||||
|
out := make([]trivia.Question, 0, n)
|
||||||
|
for _, id := range pick {
|
||||||
|
var q trivia.Question
|
||||||
|
var correct, blob string
|
||||||
|
if err := Get().QueryRow(
|
||||||
|
`SELECT category, question, correct, incorrect FROM trivia_questions WHERE id = ?`, id,
|
||||||
|
).Scan(&q.Category, &q.Text, &correct, &blob); err != nil {
|
||||||
|
return nil, fmt.Errorf("trivia: load question: %w", err)
|
||||||
|
}
|
||||||
|
var wrong []string
|
||||||
|
if err := json.Unmarshal([]byte(blob), &wrong); err != nil {
|
||||||
|
return nil, fmt.Errorf("trivia: unreadable answers: %w", err)
|
||||||
|
}
|
||||||
|
// Correct: 0 is a convention the engine immediately destroys — New()
|
||||||
|
// reshuffles every question against the game's seed. Nothing that reaches a
|
||||||
|
// player depends on the order they come out of the table in.
|
||||||
|
q.Answers = append([]string{correct}, wrong...)
|
||||||
|
q.Correct = 0
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -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,9 +41,25 @@ 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"`
|
||||||
|
// 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"`
|
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).
|
||||||
func (u *SessionUser) Display() string {
|
func (u *SessionUser) Display() string {
|
||||||
if u.Name != "" {
|
if u.Name != "" {
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
111
internal/web/devcasino_test.go
Normal file
111
internal/web/devcasino_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/games/trivia"
|
||||||
|
"pete/internal/opentdb"
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
seedTriviaBank(t)
|
||||||
|
|
||||||
|
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))))
|
||||||
|
s.casinoRoutes(mux)
|
||||||
|
|
||||||
|
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\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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedTriviaBank puts enough questions in the bank to deal a ladder of each
|
||||||
|
// difficulty.
|
||||||
|
//
|
||||||
|
// The rig does not run StartTriviaBank — a dev casino that spends its first two
|
||||||
|
// minutes dripping four hundred questions per difficulty out of a free API is a
|
||||||
|
// dev casino you cannot use. But a fresh database has an empty bank, and an
|
||||||
|
// empty bank means every start 503s, so the rig would be unable to show you the
|
||||||
|
// one game it exists to show you.
|
||||||
|
//
|
||||||
|
// One real batch per difficulty, through the real client: fifty questions is
|
||||||
|
// four ladders' worth, and it means what the browser renders came out of OpenTDB
|
||||||
|
// and through the same decode-and-store path production uses, entities and all.
|
||||||
|
func seedTriviaBank(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
client := opentdb.New()
|
||||||
|
|
||||||
|
for i, tier := range trivia.Tiers {
|
||||||
|
have, err := storage.CountTrivia(tier.Difficulty)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if have >= trivia.Rungs {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i > 0 {
|
||||||
|
time.Sleep(opentdb.Politeness) // the API asks; asking faster earns nothing
|
||||||
|
}
|
||||||
|
qs, err := client.Fetch(ctx, tier.Difficulty, opentdb.Batch)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seeding the trivia bank (%s): %v", tier.Difficulty, err)
|
||||||
|
}
|
||||||
|
added, err := storage.AddTriviaQuestions(tier.Difficulty, qs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("BANK %-6s %d questions\n", tier.Difficulty, added)
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
210
internal/web/games_hangman.go
Normal file
210
internal/web/games_hangman.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"pete/internal/games/blackjack"
|
||||||
|
"pete/internal/games/hangman"
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hangman, played for chips.
|
||||||
|
//
|
||||||
|
// The same shape as the blackjack table: the browser sends intents, the server
|
||||||
|
// holds the state, and the payload carries only what the player is entitled to
|
||||||
|
// see. Here that means the *masked* phrase. The unmasked one is in the engine
|
||||||
|
// state, which is in game_live_hands, which is on this side of the wire — a
|
||||||
|
// phrase sent down and flagged hidden is a phrase read out of devtools, and the
|
||||||
|
// game would be a formality.
|
||||||
|
|
||||||
|
// cellView is one position in the phrase, as the browser draws it.
|
||||||
|
//
|
||||||
|
// Ch is empty while the letter is hidden — not the letter with a flag beside
|
||||||
|
// it. Slot says whether this is a position you'd guess at all: a space or an
|
||||||
|
// exclamation mark is scaffolding, shows from the start, and gets no tile.
|
||||||
|
type cellView struct {
|
||||||
|
Ch string `json:"ch"`
|
||||||
|
Slot bool `json:"slot"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// hangmanView is a game as its player may see it.
|
||||||
|
type hangmanView struct {
|
||||||
|
Tier hangman.Tier `json:"tier"`
|
||||||
|
Cells []cellView `json:"cells"`
|
||||||
|
Tried []string `json:"tried"` // every letter guessed, right or wrong
|
||||||
|
Wrong []string `json:"wrong"` // just the misses — the gallows counts these
|
||||||
|
Lives int `json:"lives"`
|
||||||
|
MaxWrong int `json:"max_wrong"`
|
||||||
|
Multiple float64 `json:"multiple"` // what a win is worth right now
|
||||||
|
Bet int64 `json:"bet"`
|
||||||
|
Stands int64 `json:"stands"` // what the player would actually be paid if they won now
|
||||||
|
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Outcome string `json:"outcome,omitempty"`
|
||||||
|
Phrase string `json:"phrase,omitempty"` // only once it's over
|
||||||
|
Payout int64 `json:"payout,omitempty"`
|
||||||
|
Rake int64 `json:"rake,omitempty"`
|
||||||
|
Net int64 `json:"net"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewHangman(g hangman.State) hangmanView {
|
||||||
|
v := hangmanView{
|
||||||
|
Tier: g.Tier,
|
||||||
|
Lives: g.Lives(),
|
||||||
|
MaxWrong: hangman.MaxWrong,
|
||||||
|
Multiple: g.Multiple(),
|
||||||
|
Bet: g.Bet,
|
||||||
|
// What the player would actually collect, rake already taken out. Quoting
|
||||||
|
// the pre-rake figure here would have the felt advertising a payout the
|
||||||
|
// house doesn't hand over.
|
||||||
|
Stands: g.Pays(),
|
||||||
|
Phase: string(g.Phase),
|
||||||
|
Outcome: string(g.Outcome),
|
||||||
|
Payout: g.Payout,
|
||||||
|
Rake: g.Rake,
|
||||||
|
Net: g.Net(),
|
||||||
|
}
|
||||||
|
for i, r := range g.Runes {
|
||||||
|
c := cellView{Slot: hangman.Guessable(r)}
|
||||||
|
if i < len(g.Shown) && g.Shown[i] {
|
||||||
|
c.Ch = string(r)
|
||||||
|
}
|
||||||
|
v.Cells = append(v.Cells, c)
|
||||||
|
}
|
||||||
|
for _, r := range g.Tried {
|
||||||
|
v.Tried = append(v.Tried, string(r))
|
||||||
|
}
|
||||||
|
for _, r := range g.Wrong {
|
||||||
|
v.Wrong = append(v.Wrong, string(r))
|
||||||
|
}
|
||||||
|
// The phrase goes over the wire exactly once: when the game is over and it no
|
||||||
|
// longer decides anything.
|
||||||
|
if g.Phase == hangman.PhaseDone {
|
||||||
|
v.Phrase = g.Phrase
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHangmanStart takes the bet and draws a phrase. Same order as a deal:
|
||||||
|
// the chips are staked first, in the same statement that checks they exist, so
|
||||||
|
// two starts fired at once cannot bet the same chip.
|
||||||
|
func (s *Server) handleHangmanStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Bet int64 `json:"bet"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tier, err := hangman.TierBySlug(req.Tier)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a length"})
|
||||||
|
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: hangman stake", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seed1, seed2 := newSeeds()
|
||||||
|
rng := rand.New(rand.NewPCG(seed1, seed2))
|
||||||
|
g, evs, err := hangman.New(req.Bet, tier, blackjack.DefaultRules().RakePct, rng)
|
||||||
|
if err != nil {
|
||||||
|
// The game never happened, so the stake never should have left.
|
||||||
|
_ = storage.Award(user, req.Bet)
|
||||||
|
slog.Error("games: hangman start", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.persistHangman(w, user, g, evs, seed1, seed2, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHangmanGuess plays one guess: a letter, or the whole phrase.
|
||||||
|
func (s *Server) handleHangmanGuess(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var move hangman.Move
|
||||||
|
if err := decodeJSON(r, &move); 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 game in progress"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: hangman load", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if live.Game != gameHangman {
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand you're in first"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var g hangman.State
|
||||||
|
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||||
|
slog.Error("games: unreadable hangman game", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next, evs, err := hangman.ApplyMove(g, move)
|
||||||
|
if err != nil {
|
||||||
|
// A letter already tried is the one illegal move a player makes by
|
||||||
|
// accident rather than by trying it on, so it gets its own answer.
|
||||||
|
msg := "that guess isn't legal here"
|
||||||
|
if errors.Is(err, hangman.ErrAlreadyTried) {
|
||||||
|
msg = "you've already tried that one"
|
||||||
|
}
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.persistHangman(w, user, next, evs, live.Seed1, live.Seed2, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistHangman writes the game back and answers the browser.
|
||||||
|
func (s *Server) persistHangman(w http.ResponseWriter, user string, g hangman.State, evs []hangman.Event, seed1, seed2 uint64, fresh bool) {
|
||||||
|
blob, err := json.Marshal(g)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: marshal hangman", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done := g.Phase == hangman.PhaseDone
|
||||||
|
v, ok := s.commit(w, user, finished{
|
||||||
|
Game: gameHangman, Blob: blob,
|
||||||
|
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
|
||||||
|
Outcome: string(g.Outcome), Done: done,
|
||||||
|
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A finished game is gone from storage, so the table has none to show — but
|
||||||
|
// the browser still needs the final board to reveal the phrase onto.
|
||||||
|
if done {
|
||||||
|
hv := viewHangman(g)
|
||||||
|
v.Hangman = &hv
|
||||||
|
}
|
||||||
|
v.HangEvents = evs
|
||||||
|
writeJSON(w, v)
|
||||||
|
}
|
||||||
188
internal/web/games_hangman_test.go
Normal file
188
internal/web/games_hangman_test.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The one thing this table cannot get wrong: the stake leaves the stack, and the
|
||||||
|
// phrase does not leave the server.
|
||||||
|
func TestHangmanStartTakesTheStakeAndKeepsThePhrase(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
v, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||||
|
map[string]any{"bet": 100, "tier": "short"}))
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("start = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if v.Chips != 900 {
|
||||||
|
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
|
||||||
|
}
|
||||||
|
if v.Hangman == nil {
|
||||||
|
t.Fatal("start returned no game")
|
||||||
|
}
|
||||||
|
if v.Game != gameHangman {
|
||||||
|
t.Errorf("game = %q, want hangman", v.Game)
|
||||||
|
}
|
||||||
|
if v.Hangman.Phrase != "" {
|
||||||
|
t.Fatalf("the phrase was sent to the browser before it was won: %q", v.Hangman.Phrase)
|
||||||
|
}
|
||||||
|
// Nothing is revealed at the start except the scaffolding, and a space is not
|
||||||
|
// a letter you have to earn.
|
||||||
|
for _, c := range v.Hangman.Cells {
|
||||||
|
if c.Slot && c.Ch != "" {
|
||||||
|
t.Fatalf("a letter was face up before it was guessed: %+v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v.Hangman.Lives != 6 {
|
||||||
|
t.Errorf("lives = %d, want 6", v.Hangman.Lives)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A win pays what the felt said it would, and the rake comes out of the winnings.
|
||||||
|
func TestHangmanWinPaysWhatTheFeltQuoted(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
v, _ := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||||
|
map[string]any{"bet": 100, "tier": "short"}))
|
||||||
|
quoted := v.Hangman.Stands
|
||||||
|
|
||||||
|
// The server holds the phrase, so read it out of the live row — which is the
|
||||||
|
// only place it exists — and solve it.
|
||||||
|
phrase := livePhrase(t)
|
||||||
|
v, code := call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess",
|
||||||
|
map[string]string{"solve": phrase}))
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("solve = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if v.Hangman.Outcome != "solved" {
|
||||||
|
t.Fatalf("outcome = %q, want solved", v.Hangman.Outcome)
|
||||||
|
}
|
||||||
|
// No wrong guesses, so the full 2.6×: 260 gross, 160 profit, 8 rake, 252 back.
|
||||||
|
if v.Hangman.Payout != quoted {
|
||||||
|
t.Errorf("felt quoted %d, house paid %d", quoted, v.Hangman.Payout)
|
||||||
|
}
|
||||||
|
if v.Hangman.Payout != 252 || v.Hangman.Rake != 8 {
|
||||||
|
t.Errorf("payout/rake = %d/%d, want 252/8", v.Hangman.Payout, v.Hangman.Rake)
|
||||||
|
}
|
||||||
|
if got := chipsNow(t); got != 900+252 {
|
||||||
|
t.Errorf("chips = %d, want %d", got, 900+252)
|
||||||
|
}
|
||||||
|
// And the phrase is finally allowed out, now that it decides nothing.
|
||||||
|
if v.Hangman.Phrase == "" {
|
||||||
|
t.Error("a finished game never told the player what the phrase was")
|
||||||
|
}
|
||||||
|
// The game is off the felt.
|
||||||
|
if _, err := storage.LoadLiveHand(testPlayer); err == nil {
|
||||||
|
t.Error("a settled game is still sitting in game_live_hands")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Six wrong guesses take the stake and nothing more.
|
||||||
|
func TestHangmanHangingCostsExactlyTheStake(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||||
|
map[string]any{"bet": 100, "tier": "short"}))
|
||||||
|
|
||||||
|
// Six solves that are certainly wrong — a wrong solve costs a life, same as a
|
||||||
|
// wrong letter, and this needs no knowledge of the phrase.
|
||||||
|
var v tableView
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
v, _ = call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess",
|
||||||
|
map[string]string{"solve": "definitely not the phrase at all"}))
|
||||||
|
}
|
||||||
|
if v.Hangman == nil || v.Hangman.Outcome != "hung" {
|
||||||
|
t.Fatalf("outcome = %+v, want hung", v.Hangman)
|
||||||
|
}
|
||||||
|
if v.Hangman.Payout != 0 {
|
||||||
|
t.Errorf("payout = %d, want 0", v.Hangman.Payout)
|
||||||
|
}
|
||||||
|
if got := chipsNow(t); got != 900 {
|
||||||
|
t.Errorf("chips = %d, want 900 — a loss costs the stake and no more", got)
|
||||||
|
}
|
||||||
|
if v.Hangman.Phrase == "" {
|
||||||
|
t.Error("hung without being told the answer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One game at a time, across games: you cannot walk from a hangman into a hand of
|
||||||
|
// blackjack with chips still riding on a phrase.
|
||||||
|
func TestHangmanHoldsTheSeatAgainstBlackjack(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||||
|
map[string]any{"bet": 100, "tier": "short"}))
|
||||||
|
|
||||||
|
_, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal",
|
||||||
|
map[string]int64{"bet": 100}))
|
||||||
|
if code != 409 {
|
||||||
|
t.Fatalf("dealt blackjack on top of a live hangman: %d, want 409", code)
|
||||||
|
}
|
||||||
|
// And the stake that was refused came back: 1000 - 100 (the hangman) and not a
|
||||||
|
// chip more.
|
||||||
|
if got := chipsNow(t); got != 900 {
|
||||||
|
t.Errorf("chips = %d, want 900 — the refused deal kept the stake", got)
|
||||||
|
}
|
||||||
|
if _, err := storage.LoadLiveHand(testPlayer); err != nil {
|
||||||
|
t.Errorf("the hangman was evicted by the deal it refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cashing out mid-phrase is refused, for the same reason as mid-hand.
|
||||||
|
func TestCannotCashOutMidPhrase(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||||
|
map[string]any{"bet": 100, "tier": "short"}))
|
||||||
|
|
||||||
|
_, code := call(t, s, s.handleCashOut, as(t, s, "reala", "POST", "/api/games/cashout",
|
||||||
|
map[string]int64{"amount": 0}))
|
||||||
|
if code != 409 {
|
||||||
|
t.Fatalf("cash-out mid-phrase = %d, want 409", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tier the browser made up is refused, and costs nothing.
|
||||||
|
func TestHangmanRefusesAnInventedTier(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 1000)
|
||||||
|
|
||||||
|
_, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||||
|
map[string]any{"bet": 100, "tier": "impossible"}))
|
||||||
|
if code != 400 {
|
||||||
|
t.Fatalf("start on a made-up tier = %d, want 400", code)
|
||||||
|
}
|
||||||
|
if got := chipsNow(t); got != 1000 {
|
||||||
|
t.Errorf("chips = %d, want 1000 — a refused game must not take a stake", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// livePhrase digs the phrase out of the live row. Only a test may do this: it is
|
||||||
|
// reaching past the wire on purpose, to prove the wire doesn't carry it.
|
||||||
|
func livePhrase(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
live, err := storage.LoadLiveHand(testPlayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
blob := string(live.State)
|
||||||
|
const key = `"phrase":"`
|
||||||
|
i := strings.Index(blob, key)
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatalf("no phrase in the live row: %s", blob)
|
||||||
|
}
|
||||||
|
rest := blob[i+len(key):]
|
||||||
|
j := strings.Index(rest, `"`)
|
||||||
|
if j < 0 {
|
||||||
|
t.Fatal("unterminated phrase in the live row")
|
||||||
|
}
|
||||||
|
return rest[:j]
|
||||||
|
}
|
||||||
178
internal/web/games_pages.go
Normal file
178
internal/web/games_pages.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/games/blackjack"
|
||||||
|
"pete/internal/games/hangman"
|
||||||
|
"pete/internal/games/klondike"
|
||||||
|
"pete/internal/games/trivia"
|
||||||
|
"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."},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
Tiers []hangman.Tier // hangman's three lengths, and what each pays
|
||||||
|
MaxWrong int
|
||||||
|
Deals []klondike.Tier // solitaire's three deals
|
||||||
|
FullDeck int
|
||||||
|
Quizzes []trivia.Tier // trivia's three difficulties
|
||||||
|
Rungs int // how long the trivia ladder is
|
||||||
|
}
|
||||||
|
|
||||||
|
// casinoRoutes hangs every table off the mux.
|
||||||
|
//
|
||||||
|
// It exists so there is exactly one list of them. The dev rig (devcasino_test.go)
|
||||||
|
// has to wire its own mux — New() decides whether the casino exists before the
|
||||||
|
// rig has signed anybody in — and a second copy of this list is a list that
|
||||||
|
// silently stops including the newest game.
|
||||||
|
func (s *Server) casinoRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /games", s.handleLobby)
|
||||||
|
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
|
||||||
|
mux.HandleFunc("GET /games/hangman", s.handleHangman)
|
||||||
|
mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
|
||||||
|
mux.HandleFunc("GET /games/trivia", s.handleTrivia)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /api/games/hangman/start", s.handleHangmanStart)
|
||||||
|
mux.HandleFunc("POST /api/games/hangman/guess", s.handleHangmanGuess)
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /api/games/solitaire/start", s.handleSolitaireStart)
|
||||||
|
mux.HandleFunc("POST /api/games/solitaire/move", s.handleSolitaireMove)
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart)
|
||||||
|
mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
Tiers: hangman.Tiers,
|
||||||
|
MaxWrong: hangman.MaxWrong,
|
||||||
|
Deals: klondike.Tiers,
|
||||||
|
FullDeck: klondike.FullDeck,
|
||||||
|
Quizzes: trivia.Tiers,
|
||||||
|
Rungs: trivia.Rungs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleHangman(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requirePlayer(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, "hangman", s.gamesPage(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSolitaire(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requirePlayer(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, "solitaire", s.gamesPage(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTrivia(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requirePlayer(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, "trivia", s.gamesPage(r))
|
||||||
|
}
|
||||||
631
internal/web/games_play.go
Normal file
631
internal/web/games_play.go
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/games/blackjack"
|
||||||
|
"pete/internal/games/cards"
|
||||||
|
"pete/internal/games/hangman"
|
||||||
|
"pete/internal/games/klondike"
|
||||||
|
"pete/internal/games/trivia"
|
||||||
|
"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 whatever game is in progress.
|
||||||
|
//
|
||||||
|
// A player is in at most one game at a time — game_live_hands is keyed on the
|
||||||
|
// player, so the primary key enforces it — and Game says which. Each game gets
|
||||||
|
// its own field rather than a shared blob, because a hangman phrase and a
|
||||||
|
// blackjack shoe have nothing in common and pretending otherwise would mean a
|
||||||
|
// browser that has to guess what it's holding.
|
||||||
|
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"`
|
||||||
|
|
||||||
|
Game string `json:"game,omitempty"` // "blackjack" | "hangman" | "solitaire", if one is live
|
||||||
|
|
||||||
|
Hand *handView `json:"hand,omitempty"` // blackjack
|
||||||
|
Events []eventView `json:"events,omitempty"` // blackjack, only on a move
|
||||||
|
|
||||||
|
Hangman *hangmanView `json:"hangman,omitempty"`
|
||||||
|
HangEvents []hangman.Event `json:"hang_events,omitempty"`
|
||||||
|
|
||||||
|
Solitaire *solitaireView `json:"solitaire,omitempty"`
|
||||||
|
SolEvents []solEventView `json:"sol_events,omitempty"`
|
||||||
|
|
||||||
|
Trivia *triviaView `json:"trivia,omitempty"`
|
||||||
|
TrivEvents []trivia.Event `json:"triv_events,omitempty"`
|
||||||
|
|
||||||
|
Rake float64 `json:"rake_pct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// table reads the player's money and any game 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch on the game the row says it is. Unmarshalling a hangman state into
|
||||||
|
// a blackjack one would not fail — JSON is happy to fill nothing in — it would
|
||||||
|
// just quietly produce an empty hand, which is the worst of both.
|
||||||
|
v.Game = live.Game
|
||||||
|
switch live.Game {
|
||||||
|
case gameBlackjack:
|
||||||
|
var hand blackjack.State
|
||||||
|
if err := json.Unmarshal(live.State, &hand); err != nil {
|
||||||
|
return s.dropUnreadable(user, v, err)
|
||||||
|
}
|
||||||
|
hv := viewHand(hand)
|
||||||
|
v.Hand = &hv
|
||||||
|
case gameHangman:
|
||||||
|
var g hangman.State
|
||||||
|
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||||
|
return s.dropUnreadable(user, v, err)
|
||||||
|
}
|
||||||
|
hv := viewHangman(g)
|
||||||
|
v.Hangman = &hv
|
||||||
|
case gameSolitaire:
|
||||||
|
var g klondike.State
|
||||||
|
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||||
|
return s.dropUnreadable(user, v, err)
|
||||||
|
}
|
||||||
|
sv := viewSolitaire(g)
|
||||||
|
v.Solitaire = &sv
|
||||||
|
case gameTrivia:
|
||||||
|
var g trivia.State
|
||||||
|
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||||
|
return s.dropUnreadable(user, v, err)
|
||||||
|
}
|
||||||
|
// The clock does not stop for a reload: Left is measured from the AskedAt
|
||||||
|
// the server stamped, so a player who refreshes to buy themselves a fresh
|
||||||
|
// twenty seconds finds the countdown exactly where they left it.
|
||||||
|
tv := viewTrivia(g, time.Now())
|
||||||
|
v.Trivia = &tv
|
||||||
|
default:
|
||||||
|
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropUnreadable throws away a live game nobody can play. Rather than wedge the
|
||||||
|
// player out of the casino forever, it goes, and their stake with it — which is
|
||||||
|
// why it is logged loudly. The alternative is a player who can never be dealt
|
||||||
|
// another hand because an old one won't parse.
|
||||||
|
func (s *Server) dropUnreadable(user string, v tableView, err error) (tableView, error) {
|
||||||
|
slog.Error("games: unreadable live game, discarding", "user", user, "err", err)
|
||||||
|
_ = storage.ClearLiveHand(user)
|
||||||
|
v.Game = ""
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The games a live row can be. They're the storage key, so they're constants:
|
||||||
|
// a typo here is a game nobody can ever load again.
|
||||||
|
const (
|
||||||
|
gameBlackjack = "blackjack"
|
||||||
|
gameHangman = "hangman"
|
||||||
|
gameSolitaire = "solitaire"
|
||||||
|
gameTrivia = "trivia"
|
||||||
|
)
|
||||||
|
|
||||||
|
// finished is what commit needs to know about a game it's writing back: enough
|
||||||
|
// to settle it, and nothing about how it's played. Both engines produce one.
|
||||||
|
type finished struct {
|
||||||
|
Game string
|
||||||
|
Blob []byte // the engine's whole state, shoe or phrase and all
|
||||||
|
Bet int64
|
||||||
|
Payout int64
|
||||||
|
Rake int64
|
||||||
|
Outcome string
|
||||||
|
Done bool
|
||||||
|
Seed1 uint64
|
||||||
|
Seed2 uint64
|
||||||
|
Fresh bool // a game just started, which is the one write that may be refused
|
||||||
|
}
|
||||||
|
|
||||||
|
// commit writes a game back and settles it if it's over. It is the money path,
|
||||||
|
// and both games go through it so that neither has to re-derive an ordering
|
||||||
|
// that took a while to get right.
|
||||||
|
//
|
||||||
|
// It returns the table as it now stands. ok is false when it has already
|
||||||
|
// written an error response and the caller must simply return.
|
||||||
|
func (s *Server) commit(w http.ResponseWriter, user string, f finished) (tableView, bool) {
|
||||||
|
// Seat the game before doing anything else with it — even one that is already
|
||||||
|
// over, because a blackjack natural settles the instant it's dealt. The insert
|
||||||
|
// is what enforces one game at a time, and it has to happen for *every* new
|
||||||
|
// one: a natural dealt on top of a game already in progress would otherwise
|
||||||
|
// settle, clear the felt, and take the other game's stake down with it.
|
||||||
|
live := storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2}
|
||||||
|
save := storage.SaveLiveHand
|
||||||
|
if f.Fresh {
|
||||||
|
save = storage.StartLiveHand
|
||||||
|
}
|
||||||
|
if err := save(user, live); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrHandInProgress) {
|
||||||
|
// Somebody was already sitting here. This game was never seated, so the
|
||||||
|
// chips it staked go back: the player is in one game, not two.
|
||||||
|
_ = storage.Award(user, f.Bet)
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"})
|
||||||
|
return tableView{}, false
|
||||||
|
}
|
||||||
|
slog.Error("games: save game", "user", user, "game", f.Game, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return tableView{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.Done {
|
||||||
|
// Pay first, then clear. If Pete dies between the two, the player has been
|
||||||
|
// paid and the worst case is a settled game still showing on the felt —
|
||||||
|
// which reads as done and can be cleared. The other order loses them a win.
|
||||||
|
if err := storage.Award(user, f.Payout); err != nil {
|
||||||
|
slog.Error("games: award", "user", user, "payout", f.Payout, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return tableView{}, false
|
||||||
|
}
|
||||||
|
if err := storage.RecordHand(storage.Hand{
|
||||||
|
MatrixUser: user, Game: f.Game,
|
||||||
|
Bet: f.Bet, Payout: f.Payout, Rake: f.Rake,
|
||||||
|
Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2,
|
||||||
|
}); err != nil {
|
||||||
|
slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's game
|
||||||
|
}
|
||||||
|
if err := storage.ClearLiveHand(user); err != nil {
|
||||||
|
slog.Error("games: clear game", "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 tableView{}, false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// persist writes a blackjack hand back and answers the browser.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
done := st.Phase == blackjack.PhaseDone
|
||||||
|
v, ok := s.commit(w, user, finished{
|
||||||
|
Game: gameBlackjack, Blob: blob,
|
||||||
|
Bet: st.Bet, Payout: st.Payout, Rake: st.Rake,
|
||||||
|
Outcome: string(st.Outcome), Done: done,
|
||||||
|
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
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 done {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
286
internal/web/games_solitaire.go
Normal file
286
internal/web/games_solitaire.go
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"pete/internal/games/blackjack"
|
||||||
|
"pete/internal/games/cards"
|
||||||
|
"pete/internal/games/klondike"
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Solitaire, played for chips. Vegas scoring: you buy the deck, and every card
|
||||||
|
// you get home pays a slice of it back.
|
||||||
|
//
|
||||||
|
// The withheld information here is bigger than blackjack's single hole card —
|
||||||
|
// it's the stock and every face-down card in the tableau, which between them are
|
||||||
|
// most of the deck. So the view sends *counts* for both: a column says how many
|
||||||
|
// cards are face-down under it, never which. A browser that held the stock would
|
||||||
|
// be a browser that knows whether the next pull is worth taking, and this game is
|
||||||
|
// nothing but that decision, repeated.
|
||||||
|
//
|
||||||
|
// The events, on the other hand, need no filtering at all, and that's worth
|
||||||
|
// saying out loud because blackjack's do. Every card a klondike event carries is
|
||||||
|
// a card the move itself just turned face up: the draw puts cards in the waste,
|
||||||
|
// the flip turns a column's top card over, a move carries cards that were already
|
||||||
|
// face up. There is no event here that mentions a card the player isn't now
|
||||||
|
// looking at.
|
||||||
|
|
||||||
|
// solPileView is one tableau column: how deep the face-down stack is, and the
|
||||||
|
// run sitting face up on top of it.
|
||||||
|
type solPileView struct {
|
||||||
|
Down int `json:"down"`
|
||||||
|
Up []cardView `json:"up"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// solFoundView is one foundation. Only the top card matters — it's the only one
|
||||||
|
// that can be played back off — so it's the only one sent, with a count for the
|
||||||
|
// height of the pile.
|
||||||
|
type solFoundView struct {
|
||||||
|
Suit string `json:"suit"` // the glyph, so the empty pile can show what it wants
|
||||||
|
Red bool `json:"red"`
|
||||||
|
N int `json:"n"`
|
||||||
|
Top *cardView `json:"top,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// solitaireView is a board as its player may see it.
|
||||||
|
type solitaireView struct {
|
||||||
|
Tier klondike.Tier `json:"tier"`
|
||||||
|
|
||||||
|
Stock int `json:"stock"` // how many cards are left in it, not which
|
||||||
|
Waste []cardView `json:"waste"` // the top few, in the order they were turned
|
||||||
|
WasteN int `json:"waste_n"`
|
||||||
|
Table []solPileView `json:"table"`
|
||||||
|
Found []solFoundView `json:"found"`
|
||||||
|
|
||||||
|
Passes int `json:"passes"` // through the stock, counting this one; -1 unlimited
|
||||||
|
Moves int `json:"moves"`
|
||||||
|
CanAuto bool `json:"can_auto"`
|
||||||
|
|
||||||
|
Home int `json:"home"` // cards on the foundations
|
||||||
|
PerCard float64 `json:"per_card"` // what one more is worth
|
||||||
|
BreakEven int `json:"break_even"` // how many gets you square with the house
|
||||||
|
Bet int64 `json:"bet"`
|
||||||
|
Stands int64 `json:"stands"` // what cashing out right now actually pays
|
||||||
|
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Outcome string `json:"outcome,omitempty"`
|
||||||
|
Payout int64 `json:"payout,omitempty"`
|
||||||
|
Rake int64 `json:"rake,omitempty"`
|
||||||
|
Net int64 `json:"net"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wasteShown is how much of the waste the felt fans out. Three, because that is
|
||||||
|
// what a three-card draw puts down and the rest of the pile is just a pile.
|
||||||
|
const wasteShown = 3
|
||||||
|
|
||||||
|
func viewSolitaire(g klondike.State) solitaireView {
|
||||||
|
v := solitaireView{
|
||||||
|
Tier: g.Tier,
|
||||||
|
Stock: len(g.Stock),
|
||||||
|
WasteN: len(g.Waste),
|
||||||
|
Passes: g.PassesLeft(),
|
||||||
|
Moves: g.Moves,
|
||||||
|
CanAuto: g.CanAuto(),
|
||||||
|
Home: g.Home(),
|
||||||
|
PerCard: g.PerCard(),
|
||||||
|
BreakEven: g.Tier.BreakEven(),
|
||||||
|
Bet: g.Bet,
|
||||||
|
// What cashing out right now would actually land on the stack, rake already
|
||||||
|
// out of it. The pre-rake figure would have the felt advertising a number
|
||||||
|
// the house doesn't hand over.
|
||||||
|
Stands: g.Pays(),
|
||||||
|
Phase: string(g.Phase),
|
||||||
|
Outcome: string(g.Outcome),
|
||||||
|
Payout: g.Payout,
|
||||||
|
Rake: g.Rake,
|
||||||
|
Net: g.Net(),
|
||||||
|
}
|
||||||
|
|
||||||
|
from := len(g.Waste) - wasteShown
|
||||||
|
if from < 0 {
|
||||||
|
from = 0
|
||||||
|
}
|
||||||
|
for _, c := range g.Waste[from:] {
|
||||||
|
v.Waste = append(v.Waste, viewCard(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
v.Table = make([]solPileView, klondike.Piles)
|
||||||
|
for i, p := range g.Table {
|
||||||
|
v.Table[i] = solPileView{Down: len(p.Down)}
|
||||||
|
for _, c := range p.Up {
|
||||||
|
v.Table[i].Up = append(v.Table[i].Up, viewCard(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
v.Found = make([]solFoundView, klondike.Foundations)
|
||||||
|
for i, f := range g.Found {
|
||||||
|
suit := cards.Suit(i)
|
||||||
|
fv := solFoundView{Suit: suit.String(), Red: suit == cards.Hearts || suit == cards.Diamonds, N: len(f)}
|
||||||
|
if len(f) > 0 {
|
||||||
|
top := viewCard(f[len(f)-1])
|
||||||
|
fv.Top = &top
|
||||||
|
}
|
||||||
|
v.Found[i] = fv
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// solEventView is one thing the table animates. See the note at the top: unlike
|
||||||
|
// blackjack's, these need nothing stripped out of them.
|
||||||
|
type solEventView struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Cards []cardView `json:"cards,omitempty"`
|
||||||
|
From string `json:"from,omitempty"`
|
||||||
|
To string `json:"to,omitempty"`
|
||||||
|
Home int `json:"home"`
|
||||||
|
Pays int64 `json:"pays"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewSolEvents(evs []klondike.Event) []solEventView {
|
||||||
|
out := make([]solEventView, 0, len(evs))
|
||||||
|
for _, e := range evs {
|
||||||
|
v := solEventView{Kind: e.Kind, From: e.From, To: e.To, Home: e.Home, Pays: e.Pays}
|
||||||
|
for _, c := range e.Cards {
|
||||||
|
v.Cards = append(v.Cards, viewCard(c))
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSolitaireStart takes the stake and deals the board. Same order as a
|
||||||
|
// blackjack deal: the chips are staked first, in the same statement that checks
|
||||||
|
// they exist, so two starts fired at once cannot buy the same deck twice.
|
||||||
|
func (s *Server) handleSolitaireStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Bet int64 `json:"bet"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tier, err := klondike.TierBySlug(req.Tier)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a deal"})
|
||||||
|
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 deck"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("games: solitaire stake", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seed1, seed2 := newSeeds()
|
||||||
|
rng := rand.New(rand.NewPCG(seed1, seed2))
|
||||||
|
g, evs, err := klondike.New(req.Bet, tier, blackjack.DefaultRules().RakePct, rng)
|
||||||
|
if err != nil {
|
||||||
|
// The board never happened, so the stake never should have left.
|
||||||
|
_ = storage.Award(user, req.Bet)
|
||||||
|
slog.Error("games: solitaire deal", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.persistSolitaire(w, user, g, evs, seed1, seed2, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// solitaireErrors are the illegal moves a player makes by playing, rather than
|
||||||
|
// by tampering. Each gets said back to them in words, because "that move isn't
|
||||||
|
// legal" over a board with 60 legal-looking targets on it is not an answer.
|
||||||
|
var solitaireErrors = map[error]string{
|
||||||
|
klondike.ErrWontGo: "that card doesn't go there",
|
||||||
|
klondike.ErrNotASequence: "you can only lift a run that goes down in rank and alternates colour",
|
||||||
|
klondike.ErrEmptyPile: "there's nothing there",
|
||||||
|
klondike.ErrNoDraw: "the stock is empty",
|
||||||
|
klondike.ErrNoPasses: "that was your last pass through the stock",
|
||||||
|
klondike.ErrNothingHome: "nothing can go home right now",
|
||||||
|
klondike.ErrGameOver: "that board is finished",
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSolitaireMove plays one move: a draw, a card moved, a card sent home, an
|
||||||
|
// auto-finish, or cashing the board in.
|
||||||
|
func (s *Server) handleSolitaireMove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var move klondike.Move
|
||||||
|
if err := decodeJSON(r, &move); 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 game in progress"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: solitaire load", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if live.Game != gameSolitaire {
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand you're in first"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var g klondike.State
|
||||||
|
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||||
|
slog.Error("games: unreadable solitaire board", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next, evs, err := klondike.ApplyMove(g, move)
|
||||||
|
if err != nil {
|
||||||
|
msg, known := solitaireErrors[err]
|
||||||
|
if !known {
|
||||||
|
msg = "that move isn't legal here"
|
||||||
|
}
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.persistSolitaire(w, user, next, evs, live.Seed1, live.Seed2, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistSolitaire writes the board back and answers the browser.
|
||||||
|
func (s *Server) persistSolitaire(w http.ResponseWriter, user string, g klondike.State, evs []klondike.Event, seed1, seed2 uint64, fresh bool) {
|
||||||
|
blob, err := json.Marshal(g)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: marshal solitaire", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done := g.Phase == klondike.PhaseDone
|
||||||
|
v, ok := s.commit(w, user, finished{
|
||||||
|
Game: gameSolitaire, Blob: blob,
|
||||||
|
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
|
||||||
|
Outcome: string(g.Outcome), Done: done,
|
||||||
|
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A finished board is gone from storage, so the table has none to show — but
|
||||||
|
// the browser still needs the final one to animate the last cards onto.
|
||||||
|
if done {
|
||||||
|
sv := viewSolitaire(g)
|
||||||
|
v.Solitaire = &sv
|
||||||
|
}
|
||||||
|
v.SolEvents = viewSolEvents(evs)
|
||||||
|
writeJSON(w, v)
|
||||||
|
}
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
224
internal/web/games_trivia.go
Normal file
224
internal/web/games_trivia.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/games/blackjack"
|
||||||
|
"pete/internal/games/trivia"
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trivia, played for chips.
|
||||||
|
//
|
||||||
|
// The same shape as the other tables: the browser sends intents, the server
|
||||||
|
// holds the state, and the payload carries only what the player is entitled to
|
||||||
|
// see. Here that means the four answers *without* which of them is right. The
|
||||||
|
// right one is an index in the engine state, which is in game_live_hands, on
|
||||||
|
// this side of the wire — and it only ever crosses in the event that reveals it,
|
||||||
|
// once the question has been decided and it can't be used to answer.
|
||||||
|
//
|
||||||
|
// The clock is the other half. The countdown in the browser is decoration: the
|
||||||
|
// only clock that scores anything is time.Now() here, measured against the
|
||||||
|
// AskedAt the server stamped when it served the question. A player who stops
|
||||||
|
// their own countdown, or reloads to restart it, changes nothing.
|
||||||
|
|
||||||
|
// triviaView is a game as its player may see it.
|
||||||
|
type triviaView struct {
|
||||||
|
Tier trivia.Tier `json:"tier"`
|
||||||
|
Rung int `json:"rung"` // how many they've answered
|
||||||
|
Rungs int `json:"rungs"` // how many there are
|
||||||
|
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Question string `json:"question,omitempty"`
|
||||||
|
Answers []string `json:"answers,omitempty"` // and *not* which one is right
|
||||||
|
|
||||||
|
Limit int `json:"limit"` // the tier's seconds per question
|
||||||
|
Left float64 `json:"left"` // seconds this question has left, by the server's clock
|
||||||
|
|
||||||
|
Multiple float64 `json:"multiple"`
|
||||||
|
Bet int64 `json:"bet"`
|
||||||
|
Stands int64 `json:"stands"` // what taking the money right now actually pays
|
||||||
|
CanWalk bool `json:"can_walk"` // false on the first question: see the engine
|
||||||
|
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Outcome string `json:"outcome,omitempty"`
|
||||||
|
Payout int64 `json:"payout,omitempty"`
|
||||||
|
Rake int64 `json:"rake,omitempty"`
|
||||||
|
Net int64 `json:"net"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewTrivia(g trivia.State, now time.Time) triviaView {
|
||||||
|
v := triviaView{
|
||||||
|
Tier: g.Tier,
|
||||||
|
Rung: g.Rung,
|
||||||
|
Rungs: trivia.Rungs,
|
||||||
|
Limit: g.Tier.Limit,
|
||||||
|
// What the player would actually collect, rake already out of it — quoting
|
||||||
|
// the pre-rake figure would have the felt advertising a payout the house
|
||||||
|
// doesn't hand over.
|
||||||
|
Stands: g.Pays(),
|
||||||
|
Multiple: g.Multiple,
|
||||||
|
Bet: g.Bet,
|
||||||
|
CanWalk: g.Rung > 0,
|
||||||
|
Phase: string(g.Phase),
|
||||||
|
Outcome: string(g.Outcome),
|
||||||
|
Payout: g.Payout,
|
||||||
|
Rake: g.Rake,
|
||||||
|
Net: g.Net(),
|
||||||
|
}
|
||||||
|
// A finished game has no live question, and must not ship the next one — the
|
||||||
|
// ladder still has rungs on it that a later game might deal.
|
||||||
|
if g.Phase == trivia.PhasePlaying {
|
||||||
|
q := g.Live()
|
||||||
|
v.Category = q.Category
|
||||||
|
v.Question = q.Text
|
||||||
|
v.Answers = q.Answers
|
||||||
|
v.Left = g.Left(now).Seconds()
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTriviaStart takes the bet and builds a ladder. Same order as every other
|
||||||
|
// table: the chips are staked first, in the same statement that checks they
|
||||||
|
// exist, so two starts fired at once cannot bet the same chip.
|
||||||
|
func (s *Server) handleTriviaStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Bet int64 `json:"bet"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tier, err := trivia.TierBySlug(req.Tier)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a difficulty"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seed1, seed2 := newSeeds()
|
||||||
|
rng := rand.New(rand.NewPCG(seed1, seed2))
|
||||||
|
|
||||||
|
// Draw the ladder *before* taking the money. A bank too thin to deal from is
|
||||||
|
// the one failure here that isn't the player's fault, and they should not have
|
||||||
|
// to be refunded for it.
|
||||||
|
qs, err := storage.DrawTrivia(tier.Difficulty, trivia.Rungs, rng)
|
||||||
|
if errors.Is(err, storage.ErrBankEmpty) {
|
||||||
|
writeJSONStatus(w, http.StatusServiceUnavailable, map[string]string{
|
||||||
|
"error": "the question bank is still filling up — give it a minute",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: trivia draw", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
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: trivia stake", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
g, evs, err := trivia.New(req.Bet, tier, blackjack.DefaultRules().RakePct, qs, now, rng)
|
||||||
|
if err != nil {
|
||||||
|
// The game never happened, so the stake never should have left.
|
||||||
|
_ = storage.Award(user, req.Bet)
|
||||||
|
slog.Error("games: trivia start", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.persistTrivia(w, user, g, evs, seed1, seed2, true, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTriviaAnswer plays one move: pick an answer, or take the money.
|
||||||
|
func (s *Server) handleTriviaAnswer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var move trivia.Move
|
||||||
|
if err := decodeJSON(r, &move); 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 game in progress"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: trivia load", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if live.Game != gameTrivia {
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var g trivia.State
|
||||||
|
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||||
|
slog.Error("games: unreadable trivia game", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server's clock, and the only one that counts. Read once, so the answer
|
||||||
|
// and the view that reports it agree about what time it is.
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
next, evs, err := trivia.ApplyMove(g, move, now)
|
||||||
|
if err != nil {
|
||||||
|
msg := "that move isn't legal here"
|
||||||
|
if errors.Is(err, trivia.ErrNothingBanked) {
|
||||||
|
msg = "answer one before you walk"
|
||||||
|
}
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.persistTrivia(w, user, next, evs, live.Seed1, live.Seed2, false, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistTrivia writes the game back and answers the browser.
|
||||||
|
func (s *Server) persistTrivia(w http.ResponseWriter, user string, g trivia.State, evs []trivia.Event, seed1, seed2 uint64, fresh bool, now time.Time) {
|
||||||
|
blob, err := json.Marshal(g)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: marshal trivia", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done := g.Phase == trivia.PhaseDone
|
||||||
|
v, ok := s.commit(w, user, finished{
|
||||||
|
Game: gameTrivia, Blob: blob,
|
||||||
|
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
|
||||||
|
Outcome: string(g.Outcome), Done: done,
|
||||||
|
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A finished game is gone from storage, so the table has none to show — but the
|
||||||
|
// browser still needs the final board to land the verdict on.
|
||||||
|
if done {
|
||||||
|
tv := viewTrivia(g, now)
|
||||||
|
v.Trivia = &tv
|
||||||
|
}
|
||||||
|
v.TrivEvents = evs
|
||||||
|
writeJSON(w, v)
|
||||||
|
}
|
||||||
@@ -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,23 +78,40 @@ 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", "hangman", "solitaire", "trivia"}},
|
||||||
)
|
}
|
||||||
|
tpls := make(map[string]*template.Template)
|
||||||
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
||||||
if !sc.Enabled || sc.Name == "" {
|
if !sc.Enabled || sc.Name == "" {
|
||||||
@@ -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,21 @@ 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() {
|
||||||
|
s.casinoRoutes(mux)
|
||||||
|
}
|
||||||
|
|
||||||
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 +257,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() {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
414
internal/web/static/js/blackjack.js
Normal file
414
internal/web/static/js/blackjack.js
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
// 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]");
|
||||||
|
|
||||||
|
// The spot owns the chips on the felt and the number under them — see PeteFX.
|
||||||
|
// Nothing is bet until a chip is on it, so `bet` starts at nothing rather than
|
||||||
|
// at a default stake nobody put down.
|
||||||
|
var spot = FX.spot({ spot: spotEl, stack: stackEl, total: spotTotalEl });
|
||||||
|
|
||||||
|
var bet = 0; // what you're building between hands
|
||||||
|
var 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 --------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The deck itself — the faces, the pips, the flip — is PeteCards, shared with
|
||||||
|
// every other table in the room. Here a card is always dealt out of the shoe
|
||||||
|
// and always lands with a degree or two of tilt on it, which are this table's
|
||||||
|
// two opinions about a card and the only ones it has.
|
||||||
|
|
||||||
|
var CARDS = window.PeteCards;
|
||||||
|
|
||||||
|
function cardEl(face) { return CARDS.el(face); }
|
||||||
|
var turnOver = CARDS.turnOver;
|
||||||
|
|
||||||
|
// ---- the money on the felt -------------------------------------------------
|
||||||
|
|
||||||
|
// stake moves chips from your pile onto the spot: the bet you build before a
|
||||||
|
// deal, and the second bet a double puts down beside it.
|
||||||
|
function stake(amount, from) {
|
||||||
|
return spot.pour(from || purseEl, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// settleChips is what the felt does about the outcome, after the cards have
|
||||||
|
// 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.
|
||||||
|
return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The house pays first, into the spot beside your stake, so you watch the
|
||||||
|
// winnings arrive on top of the bet that earned them.
|
||||||
|
return spot
|
||||||
|
.pour(houseEl, back, { gap: 60 })
|
||||||
|
.then(function () { return wait(back > 0 ? 380 : 200); })
|
||||||
|
// Paid, then swept up: the whole lot comes back to your pile, and only then
|
||||||
|
// does the number in the bar move.
|
||||||
|
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
||||||
|
}
|
||||||
|
|
||||||
|
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); spot.render(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));
|
||||||
|
spot.render(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 > spot.amount) {
|
||||||
|
var extra = final.bet - spot.amount;
|
||||||
|
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) spot.render(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoped to buttons: the bare [data-chip] spans in the corner are the house's
|
||||||
|
// rack, and the house is not betting.
|
||||||
|
root.querySelectorAll("button[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 the spot's total moves now, so a Deal pressed mid-flight still knows
|
||||||
|
// the chip is on its way and doesn't put a second one down.
|
||||||
|
var target = bet;
|
||||||
|
spot.amount = bet;
|
||||||
|
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
||||||
|
if (bet >= target) spot.render(target); // unless Clear got there first
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var clearBtn = root.querySelector("[data-bet-clear]");
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener("click", function () {
|
||||||
|
if (busy || !spot.amount) { bet = 0; showBet(); return; }
|
||||||
|
spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||||||
|
bet = 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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
172
internal/web/static/js/casino-cards.js
Normal file
172
internal/web/static/js/casino-cards.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// The deck, as the room draws it.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// This started life inside blackjack.js. It's out here because solitaire deals
|
||||||
|
// off the same deck, and the second table in a casino is exactly the moment a
|
||||||
|
// copied card renderer becomes two card renderers that drift.
|
||||||
|
//
|
||||||
|
// Exposed as window.PeteCards. Nothing in here knows what game is being played.
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
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" };
|
||||||
|
var SUIT_NAMES = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" };
|
||||||
|
|
||||||
|
// 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 — and in
|
||||||
|
// solitaire, from a column where all you can see is the top eighth of it.
|
||||||
|
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>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// paint draws the face. Every table uses this one, because they all deal out of
|
||||||
|
// the same deck.
|
||||||
|
function paint(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="' + aria(face) + '">' + index(face) + body + "</svg>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// "A♠" is not something a screen reader should be asked to pronounce.
|
||||||
|
function aria(face) {
|
||||||
|
var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank);
|
||||||
|
var suit = SUIT_NAMES[face.suit];
|
||||||
|
return suit ? name + " of " + suit : face.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dealt = 0; // how many cards this page has put down, ever — the tilt seed
|
||||||
|
|
||||||
|
// el builds one card. face === null means face-down: the card is on the table,
|
||||||
|
// but this browser has not been told what it is.
|
||||||
|
//
|
||||||
|
// opts.deal — fly it in from the shoe (blackjack). Solitaire turns this off:
|
||||||
|
// it animates its own cards from wherever they actually came from,
|
||||||
|
// and a board that re-renders would otherwise re-deal itself out of
|
||||||
|
// the corner on every single move.
|
||||||
|
// opts.tilt — a degree or two of resting angle. A dealt hand wants it; a
|
||||||
|
// solitaire column does not, because thirteen tilted cards stacked
|
||||||
|
// an eighth of an inch apart read as a mistake rather than as a
|
||||||
|
// hand that was handled.
|
||||||
|
function el(face, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var deal = opts.deal !== false;
|
||||||
|
var tilt = opts.tilt !== false;
|
||||||
|
|
||||||
|
var wrap = document.createElement("div");
|
||||||
|
wrap.className = "pete-card";
|
||||||
|
wrap.dataset.face = face ? "up" : "down";
|
||||||
|
if (face) wrap.dataset.key = face.label; // one deck, so the label is an id
|
||||||
|
|
||||||
|
if (deal) {
|
||||||
|
// Every card flies out of the shoe, which sits in the top-right of the felt.
|
||||||
|
wrap.style.setProperty("--deal-x", "14rem");
|
||||||
|
wrap.style.setProperty("--deal-y", "-6rem");
|
||||||
|
} else {
|
||||||
|
wrap.style.animation = "none";
|
||||||
|
}
|
||||||
|
wrap.style.setProperty("--tilt", tilt ? window.PeteFX.jitter(dealt++, 2.4).toFixed(2) + "deg" : "0deg");
|
||||||
|
|
||||||
|
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) paint(front, face);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// turnOver flips a face-down card up, now that we've been told what it is.
|
||||||
|
function turnOver(wrap, face) {
|
||||||
|
if (!wrap) return;
|
||||||
|
paint(wrap.querySelector(".pete-card-front"), face);
|
||||||
|
wrap.dataset.face = "up";
|
||||||
|
wrap.dataset.key = face.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.PeteCards = {
|
||||||
|
el: el,
|
||||||
|
paint: paint,
|
||||||
|
turnOver: turnOver,
|
||||||
|
aria: aria,
|
||||||
|
art: SUIT_ART,
|
||||||
|
};
|
||||||
|
})();
|
||||||
322
internal/web/static/js/casino-fx.js
Normal file
322
internal/web/static/js/casino-fx.js
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bet spot: the pile of chips sitting on it, and the number printed under
|
||||||
|
// the pile.
|
||||||
|
//
|
||||||
|
// The rule the whole room is built on lives in here, which is why it's one
|
||||||
|
// object and not two variables on a table: **the number is a readout of the
|
||||||
|
// pile, never the other way round.** There is no way to change one without the
|
||||||
|
// other, so a settled game can't leave "your bet: 300" printed over an empty
|
||||||
|
// circle, and a payout can't be counted before the chips that justify it have
|
||||||
|
// landed.
|
||||||
|
//
|
||||||
|
// els: {spot, stack, total}. Blackjack's spot holds the stake; solitaire's
|
||||||
|
// holds what you've banked, which grows a card at a time. Same object.
|
||||||
|
function spot(els) {
|
||||||
|
var api = {
|
||||||
|
// What the pile is holding. Written by render, and readable by a table that
|
||||||
|
// needs to know whether a chip is already on its way down.
|
||||||
|
amount: 0,
|
||||||
|
|
||||||
|
render: function (n) {
|
||||||
|
api.amount = n || 0;
|
||||||
|
els.stack.innerHTML = "";
|
||||||
|
if (els.spot) els.spot.dataset.live = api.amount > 0 ? "1" : "0";
|
||||||
|
if (!api.amount) {
|
||||||
|
if (els.total) els.total.classList.add("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chipsFor(api.amount).forEach(function (d, i) {
|
||||||
|
var c = disc(d);
|
||||||
|
c.style.setProperty("--i", i);
|
||||||
|
c.style.setProperty("--spin", jitter(i, 12).toFixed(1) + "deg");
|
||||||
|
c.style.animationDelay = (reduced ? 0 : i * 40) + "ms";
|
||||||
|
els.stack.appendChild(c);
|
||||||
|
});
|
||||||
|
if (els.total) {
|
||||||
|
els.total.textContent = api.amount.toLocaleString();
|
||||||
|
els.total.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// pour throws a run of chips onto the spot and grows the pile 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.
|
||||||
|
pour: function (from, amount, opts) {
|
||||||
|
if (amount <= 0) return Promise.resolve();
|
||||||
|
var base = api.amount;
|
||||||
|
var chips = chipsFor(amount, 8);
|
||||||
|
var run = 0;
|
||||||
|
return flyMany(from, els.spot, chips, Object.assign({
|
||||||
|
onLand: function (d, i) {
|
||||||
|
run += d;
|
||||||
|
api.render(base + (i === chips.length - 1 ? amount : run));
|
||||||
|
},
|
||||||
|
}, opts || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// sweep sends chips off the spot to somewhere else — your pile, or the
|
||||||
|
// house's rack. The spot is emptied *now* rather than when they land, so
|
||||||
|
// nothing that is already in the air can be bet a second time.
|
||||||
|
sweep: function (to, amount, opts) {
|
||||||
|
var n = amount == null ? api.amount : amount;
|
||||||
|
var left = api.amount - n;
|
||||||
|
if (n <= 0) return Promise.resolve();
|
||||||
|
var chain = flyMany(els.spot, to, chipsFor(n, 8), Object.assign({ gap: 40, lift: 0.8 }, opts || {}));
|
||||||
|
api.render(left > 0 ? left : 0);
|
||||||
|
return chain;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
spot: spot,
|
||||||
|
burst: burst,
|
||||||
|
count: count,
|
||||||
|
centre: centre,
|
||||||
|
};
|
||||||
|
})();
|
||||||
170
internal/web/static/js/games.js
Normal file
170
internal/web/static/js/games.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
// 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-game: the stake is already on the table. `game` is
|
||||||
|
// set by whichever game you're in, so this holds for any of them — checking
|
||||||
|
// for a blackjack hand specifically would let you walk out on a hangman.
|
||||||
|
if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.game;
|
||||||
|
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();
|
||||||
|
})();
|
||||||
555
internal/web/static/js/hangman.js
Normal file
555
internal/web/static/js/hangman.js
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
// The hangman table.
|
||||||
|
//
|
||||||
|
// Same bargain as the blackjack table: the browser holds no game. It sends a
|
||||||
|
// letter, and the server answers with the board you're allowed to see — the
|
||||||
|
// phrase with the letters you haven't earned still blank — plus the script of
|
||||||
|
// what just happened. The phrase itself only ever arrives once the game is over
|
||||||
|
// and it no longer decides anything.
|
||||||
|
//
|
||||||
|
// The gallows is the meter. It counts your lives down and your winnings down at
|
||||||
|
// the same time, which is why a miss draws a limb *and* knocks the multiple back
|
||||||
|
// in the same beat: they are the same event, and showing them as one thing is
|
||||||
|
// the whole reason to bet on this rather than play it on paper.
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var root = document.querySelector("[data-hangman]");
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
var FX = window.PeteFX;
|
||||||
|
|
||||||
|
var boardEl = root.querySelector("[data-board]");
|
||||||
|
var gallowsEl = root.querySelector("[data-gallows]");
|
||||||
|
var wrongEl = root.querySelector("[data-wrong]");
|
||||||
|
var wrongLbl = root.querySelector("[data-wrong-label]");
|
||||||
|
var multEl = root.querySelector("[data-multiple]");
|
||||||
|
var meterEl = root.querySelector("[data-meter]");
|
||||||
|
var standsEl = root.querySelector("[data-stands]");
|
||||||
|
var standsLbl = root.querySelector("[data-stands-label]");
|
||||||
|
var livesEl = root.querySelector("[data-lives]");
|
||||||
|
var verdictEl = root.querySelector("[data-verdict]");
|
||||||
|
|
||||||
|
var betting = root.querySelector("[data-betting]");
|
||||||
|
var guessing = root.querySelector("[data-guessing]");
|
||||||
|
var keysEl = root.querySelector("[data-keyboard]");
|
||||||
|
var betAmount = root.querySelector("[data-bet-amount]");
|
||||||
|
var startBtn = root.querySelector("[data-start]");
|
||||||
|
var solveIn = root.querySelector("[data-solve-input]");
|
||||||
|
var solveBtn = root.querySelector("[data-solve]");
|
||||||
|
var msgEl = root.querySelector("[data-table-msg]");
|
||||||
|
var gameMsgEl = root.querySelector("[data-game-msg]");
|
||||||
|
|
||||||
|
// The three places a chip can be, exactly as at the other table.
|
||||||
|
var purseEl = document.querySelector("[data-chips]");
|
||||||
|
var spotEl = root.querySelector("[data-spot]");
|
||||||
|
var houseEl = root.querySelector("[data-house]");
|
||||||
|
|
||||||
|
// The bet spot, and the rule that comes with it: the number under the pile is a
|
||||||
|
// readout of the pile, never the other way round.
|
||||||
|
var spot = FX.spot({
|
||||||
|
spot: spotEl,
|
||||||
|
stack: root.querySelector("[data-stack]"),
|
||||||
|
total: root.querySelector("[data-spot-total]"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var bet = 0; // what you're building between games
|
||||||
|
var busy = false;
|
||||||
|
var game = null; // the board as the server last described it
|
||||||
|
var tier = "medium";
|
||||||
|
|
||||||
|
var FLIP_MS = 320;
|
||||||
|
var MISS_MS = 520;
|
||||||
|
|
||||||
|
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, where) {
|
||||||
|
var el = where || msgEl;
|
||||||
|
if (!el) return;
|
||||||
|
if (!text) { el.classList.add("hidden"); return; }
|
||||||
|
el.textContent = text;
|
||||||
|
el.classList.remove("hidden");
|
||||||
|
el.style.color = tone === "bad" ? "#cc3d4a" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the board -------------------------------------------------------------
|
||||||
|
|
||||||
|
// renderBoard lays the phrase out as tiles. A tile is a letter you have to earn;
|
||||||
|
// a space or a piece of punctuation is scaffolding and gets no tile, because a
|
||||||
|
// row of blanks with the word breaks hidden is a puzzle about typography.
|
||||||
|
//
|
||||||
|
// Words are kept whole: the board wraps between words, never inside one.
|
||||||
|
function renderBoard(cells) {
|
||||||
|
boardEl.innerHTML = "";
|
||||||
|
if (!cells) return;
|
||||||
|
|
||||||
|
var word = document.createElement("div");
|
||||||
|
word.className = "pete-word";
|
||||||
|
|
||||||
|
cells.forEach(function (c, i) {
|
||||||
|
if (!c.slot && (c.ch === " " || c.ch === "")) {
|
||||||
|
// A space: end the word and start the next one.
|
||||||
|
if (word.childNodes.length) boardEl.appendChild(word);
|
||||||
|
word = document.createElement("div");
|
||||||
|
word.className = "pete-word";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var t = document.createElement("span");
|
||||||
|
t.className = "pete-tile";
|
||||||
|
t.dataset.at = String(i);
|
||||||
|
if (!c.slot) {
|
||||||
|
t.dataset.punct = "1"; // an exclamation mark is not a blank to fill
|
||||||
|
t.textContent = c.ch;
|
||||||
|
} else {
|
||||||
|
t.dataset.up = c.ch ? "1" : "0";
|
||||||
|
t.textContent = c.ch || "";
|
||||||
|
}
|
||||||
|
word.appendChild(t);
|
||||||
|
});
|
||||||
|
if (word.childNodes.length) boardEl.appendChild(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
// turnUp flips the tiles a hit just earned, one after the other so a letter that
|
||||||
|
// appears three times reads as three finds rather than one repaint.
|
||||||
|
function turnUp(at, ch) {
|
||||||
|
if (!at || !at.length) return Promise.resolve();
|
||||||
|
at.forEach(function (i, n) {
|
||||||
|
var t = boardEl.querySelector('.pete-tile[data-at="' + i + '"]');
|
||||||
|
if (!t) return;
|
||||||
|
setTimeout(function () {
|
||||||
|
// Left as it comes: the tile is uppercased in CSS, and doing it here too
|
||||||
|
// would mean the resume path (which paints the phrase's own casing) and
|
||||||
|
// this one put different text in the same tile.
|
||||||
|
t.textContent = ch;
|
||||||
|
t.dataset.up = "1";
|
||||||
|
t.classList.add("pete-tile-hit");
|
||||||
|
}, pace(n * 90));
|
||||||
|
});
|
||||||
|
return wait(FLIP_MS + (at.length - 1) * 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the gallows -----------------------------------------------------------
|
||||||
|
|
||||||
|
// drawGallows shows the first n parts. Each one draws itself in along its own
|
||||||
|
// length rather than fading up — the difference between a limb being *drawn* and
|
||||||
|
// a limb appearing is the whole character of the game.
|
||||||
|
function drawGallows(n, animateLast) {
|
||||||
|
var parts = gallowsEl.querySelectorAll("[data-part]");
|
||||||
|
parts.forEach(function (p, i) {
|
||||||
|
var on = i < n;
|
||||||
|
var was = p.dataset.on === "1";
|
||||||
|
p.dataset.on = on ? "1" : "0";
|
||||||
|
if (on && !was && animateLast && i === n - 1 && !reduced) {
|
||||||
|
// Restart the draw-in animation on the part that was just earned.
|
||||||
|
p.classList.remove("pete-part-draw");
|
||||||
|
void p.getBoundingClientRect();
|
||||||
|
p.classList.add("pete-part-draw");
|
||||||
|
} else if (on && !animateLast) {
|
||||||
|
p.classList.remove("pete-part-draw");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shake() {
|
||||||
|
if (reduced) return;
|
||||||
|
gallowsEl.classList.remove("pete-shake");
|
||||||
|
void gallowsEl.getBoundingClientRect();
|
||||||
|
gallowsEl.classList.add("pete-shake");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the meter -------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderMeter(v) {
|
||||||
|
if (!v) {
|
||||||
|
multEl.textContent = "—";
|
||||||
|
standsEl.textContent = "—";
|
||||||
|
standsLbl.textContent = "if you get it";
|
||||||
|
livesEl.textContent = "";
|
||||||
|
meterEl.dataset.cold = "0";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
multEl.textContent = v.multiple.toFixed(2) + "×";
|
||||||
|
standsEl.textContent = (v.stands || 0).toLocaleString();
|
||||||
|
standsLbl.textContent = v.phase === "done" ? "was on it" : "if you get it";
|
||||||
|
livesEl.textContent = v.lives + (v.lives === 1 ? " life left" : " lives left");
|
||||||
|
// The meter goes cold once the multiple is down at its floor: from here a win
|
||||||
|
// hands back the stake and nothing more.
|
||||||
|
meterEl.dataset.cold = v.multiple <= 1.001 ? "1" : "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
// knock ticks the multiple down to its new value, so the number falls rather
|
||||||
|
// than simply being different.
|
||||||
|
function knock(v) {
|
||||||
|
if (reduced) { renderMeter(v); return; }
|
||||||
|
var from = parseFloat(multEl.textContent) || v.multiple;
|
||||||
|
var to = v.multiple;
|
||||||
|
var t0 = performance.now();
|
||||||
|
meterEl.dataset.hit = "1";
|
||||||
|
setTimeout(function () { meterEl.dataset.hit = "0"; }, 400);
|
||||||
|
|
||||||
|
(function step(now) {
|
||||||
|
var p = Math.min(1, (now - t0) / 380);
|
||||||
|
var eased = 1 - Math.pow(1 - p, 3);
|
||||||
|
multEl.textContent = (from + (to - from) * eased).toFixed(2) + "×";
|
||||||
|
if (p < 1) requestAnimationFrame(step);
|
||||||
|
else renderMeter(v);
|
||||||
|
})(t0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the keyboard ----------------------------------------------------------
|
||||||
|
|
||||||
|
var ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm", "0123456789"];
|
||||||
|
|
||||||
|
function buildKeys() {
|
||||||
|
keysEl.innerHTML = "";
|
||||||
|
ROWS.forEach(function (row, r) {
|
||||||
|
var line = document.createElement("div");
|
||||||
|
line.className = "pete-key-row";
|
||||||
|
if (r === 3) line.dataset.digits = "1";
|
||||||
|
row.split("").forEach(function (ch) {
|
||||||
|
var b = document.createElement("button");
|
||||||
|
b.type = "button";
|
||||||
|
b.className = "pete-key";
|
||||||
|
b.dataset.key = ch;
|
||||||
|
b.textContent = ch.toUpperCase();
|
||||||
|
b.addEventListener("click", function () { guessLetter(ch); });
|
||||||
|
line.appendChild(b);
|
||||||
|
});
|
||||||
|
keysEl.appendChild(line);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// paintKeys marks every letter that's been tried, and how it went. A key that
|
||||||
|
// has been spent should look spent — otherwise you spend it again.
|
||||||
|
function paintKeys(v) {
|
||||||
|
var tried = (v && v.tried) || [];
|
||||||
|
var wrong = (v && v.wrong) || [];
|
||||||
|
keysEl.querySelectorAll(".pete-key").forEach(function (b) {
|
||||||
|
var ch = b.dataset.key;
|
||||||
|
var used = tried.indexOf(ch) !== -1;
|
||||||
|
b.disabled = used || !v || v.phase !== "playing";
|
||||||
|
b.dataset.state = !used ? "" : wrong.indexOf(ch) !== -1 ? "miss" : "hit";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWrong(v) {
|
||||||
|
wrongEl.innerHTML = "";
|
||||||
|
var wrong = (v && v.wrong) || [];
|
||||||
|
// A wrong *solve* is recorded as a miss with no letter on it — it cost a life
|
||||||
|
// and it's on the gallows, but there's no key to grey out for it.
|
||||||
|
var letters = wrong.filter(function (c) { return c !== "·"; });
|
||||||
|
wrongLbl.classList.toggle("hidden", letters.length === 0);
|
||||||
|
letters.forEach(function (ch) {
|
||||||
|
var s = document.createElement("span");
|
||||||
|
s.className = "pete-missed";
|
||||||
|
s.textContent = ch.toUpperCase();
|
||||||
|
wrongEl.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the money on the felt -------------------------------------------------
|
||||||
|
// The spot is PeteFX's, the same one every other table in the room bets onto: a
|
||||||
|
// chip has to behave the same way in both rooms or it isn't a chip, it's a
|
||||||
|
// widget.
|
||||||
|
|
||||||
|
function stake(amount, from) {
|
||||||
|
return spot.pour(from || purseEl, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function settleChips(final) {
|
||||||
|
var payout = final.payout || 0;
|
||||||
|
var back = payout - final.bet;
|
||||||
|
|
||||||
|
if (payout <= 0) {
|
||||||
|
// The house takes it. The stack goes to the rack and doesn't come back.
|
||||||
|
return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
||||||
|
}
|
||||||
|
// Paid into the spot beside your stake, then the whole lot swept home.
|
||||||
|
return spot
|
||||||
|
.pour(houseEl, back, { gap: 60 })
|
||||||
|
.then(function () { return wait(back > 0 ? 380 : 200); })
|
||||||
|
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- phases ----------------------------------------------------------------
|
||||||
|
|
||||||
|
var VERDICTS = {
|
||||||
|
solved: "Got it! 🎉",
|
||||||
|
filled: "That's the lot!",
|
||||||
|
hung: "Hung.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function verdict(v) {
|
||||||
|
var text = VERDICTS[v.outcome] || "";
|
||||||
|
if (!text) { verdictEl.classList.add("hidden"); return; }
|
||||||
|
if (v.outcome === "hung" && v.phrase) text = "Hung. It was “" + v.phrase + "”.";
|
||||||
|
if (v.net > 0) text += " +" + v.net.toLocaleString();
|
||||||
|
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
||||||
|
verdictEl.textContent = text;
|
||||||
|
verdictEl.classList.remove("hidden");
|
||||||
|
|
||||||
|
// Confetti for a phrase guessed outright — the one call you make on your own
|
||||||
|
// rather than by grinding the alphabet.
|
||||||
|
if (v.outcome === "solved" && v.net > 0) FX.burst(verdictEl, { count: 30 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPhase(v) {
|
||||||
|
game = v;
|
||||||
|
var live = !!v && v.phase === "playing";
|
||||||
|
betting.classList.toggle("hidden", live);
|
||||||
|
guessing.classList.toggle("hidden", !live);
|
||||||
|
paintKeys(v);
|
||||||
|
if (!live && solveIn) solveIn.value = "";
|
||||||
|
if (!v || !v.outcome) verdictEl.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
// paint puts a board up with no animation: the resume path, after a reload or a
|
||||||
|
// redeploy. Your stake is still on the spot because the server still has it.
|
||||||
|
function paint(v) {
|
||||||
|
if (!v) {
|
||||||
|
renderBoard(null);
|
||||||
|
drawGallows(0, false);
|
||||||
|
renderWrong(null);
|
||||||
|
renderMeter(null);
|
||||||
|
spot.render(0);
|
||||||
|
setPhase(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderBoard(v.cells);
|
||||||
|
drawGallows((v.wrong || []).length, false);
|
||||||
|
renderWrong(v);
|
||||||
|
renderMeter(v);
|
||||||
|
spot.render(v.phase === "done" ? 0 : v.bet);
|
||||||
|
setPhase(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the script ------------------------------------------------------------
|
||||||
|
|
||||||
|
// play walks the server's events. Same rule as the other table: on a live game
|
||||||
|
// the money is already right (your stake left your pile when you pressed Play,
|
||||||
|
// and it's on the spot), but on a settling one the chip bar is held back until
|
||||||
|
// the chips have physically come home.
|
||||||
|
function play(view, money) {
|
||||||
|
var events = view.hang_events || [];
|
||||||
|
var final = view.hangman;
|
||||||
|
var settles = !!final && final.phase === "done";
|
||||||
|
var chain = Promise.resolve();
|
||||||
|
|
||||||
|
if (!settles) money();
|
||||||
|
|
||||||
|
if (final && final.bet > spot.amount) {
|
||||||
|
var extra = final.bet - spot.amount;
|
||||||
|
chain = chain.then(function () { return stake(extra); });
|
||||||
|
}
|
||||||
|
|
||||||
|
events.forEach(function (e) {
|
||||||
|
chain = chain.then(function () {
|
||||||
|
switch (e.kind) {
|
||||||
|
case "start":
|
||||||
|
verdictEl.classList.add("hidden");
|
||||||
|
renderBoard(final && final.cells);
|
||||||
|
drawGallows(0, false);
|
||||||
|
renderWrong(null);
|
||||||
|
renderMeter(final);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case "hit":
|
||||||
|
return turnUp(e.at, e.letter);
|
||||||
|
|
||||||
|
case "miss":
|
||||||
|
// The limb, the shake and the multiple falling are one event, because
|
||||||
|
// they are one event: this is what a wrong guess costs, all of it.
|
||||||
|
drawGallows(countMisses(events, e), true);
|
||||||
|
shake();
|
||||||
|
if (final) knock(final);
|
||||||
|
return wait(MISS_MS);
|
||||||
|
|
||||||
|
case "solve":
|
||||||
|
return wait(220);
|
||||||
|
|
||||||
|
case "settle":
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return chain.then(function () {
|
||||||
|
if (!final) { paint(null); money(); return; }
|
||||||
|
|
||||||
|
renderWrong(final);
|
||||||
|
if (!settles) {
|
||||||
|
renderMeter(final);
|
||||||
|
setPhase(final);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Over: the board goes fully face up (the server has finally sent the
|
||||||
|
// phrase), then the money moves, and only then does the bar catch up.
|
||||||
|
guessing.classList.add("hidden");
|
||||||
|
renderBoard(final.cells);
|
||||||
|
renderMeter(final);
|
||||||
|
verdict(final);
|
||||||
|
return settleChips(final)
|
||||||
|
.then(money)
|
||||||
|
.then(function () { return standing(final.bet); })
|
||||||
|
.then(function () { setPhase(final); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// countMisses works out how many limbs should be on the gallows by the time
|
||||||
|
// this miss has been played — the misses already on the board when the request
|
||||||
|
// went out, plus every miss in this batch up to and including this one.
|
||||||
|
function countMisses(events, upTo) {
|
||||||
|
var before = game ? (game.wrong || []).length : 0;
|
||||||
|
var n = 0;
|
||||||
|
for (var i = 0; i < events.length; i++) {
|
||||||
|
if (events[i].kind === "miss") n++;
|
||||||
|
if (events[i] === upTo) break;
|
||||||
|
}
|
||||||
|
return before + n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// standing puts the stake back on the spot for the next phrase, the way the
|
||||||
|
// blackjack table leaves your bet up.
|
||||||
|
function standing(amount) {
|
||||||
|
var money = window.PeteGames.view();
|
||||||
|
if (!amount || !money || money.chips < amount) {
|
||||||
|
bet = 0;
|
||||||
|
showBet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bet = amount;
|
||||||
|
showBet();
|
||||||
|
return stake(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- talking to the table ---------------------------------------------------
|
||||||
|
|
||||||
|
function send(path, body, where) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
say("", null, where);
|
||||||
|
return window.PeteGames.post(path, body)
|
||||||
|
.then(function (view) {
|
||||||
|
return play(view, function () { window.PeteGames.apply(view); });
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
say(err.message, "bad", where);
|
||||||
|
return window.PeteGames.refresh().then(function (v) {
|
||||||
|
if (v && !v.hangman) spot.render(0);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function () { busy = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessLetter(ch) {
|
||||||
|
if (busy || !game || game.phase !== "playing") return;
|
||||||
|
if ((game.tried || []).indexOf(ch) !== -1) return;
|
||||||
|
send("/api/games/hangman/guess", { letter: ch }, gameMsgEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- betting ----------------------------------------------------------------
|
||||||
|
|
||||||
|
function showBet() {
|
||||||
|
betAmount.textContent = bet.toLocaleString();
|
||||||
|
var money = window.PeteGames.view();
|
||||||
|
if (startBtn) startBtn.disabled = bet <= 0 || !money || money.chips < bet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickTier(slug) {
|
||||||
|
tier = slug;
|
||||||
|
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||||
|
b.dataset.on = b.dataset.tier === slug ? "1" : "0";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||||
|
b.addEventListener("click", function () {
|
||||||
|
if (busy) return;
|
||||||
|
pickTier(b.dataset.tier);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scoped to buttons: the bare [data-chip] spans in the corner are the house's
|
||||||
|
// rack, and the house is not betting.
|
||||||
|
root.querySelectorAll("button[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();
|
||||||
|
|
||||||
|
var target = bet;
|
||||||
|
spot.amount = bet;
|
||||||
|
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
||||||
|
if (bet >= target) spot.render(target); // unless Clear got there first
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var clearBtn = root.querySelector("[data-bet-clear]");
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener("click", function () {
|
||||||
|
if (busy || !spot.amount) { bet = 0; showBet(); return; }
|
||||||
|
spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||||||
|
bet = 0;
|
||||||
|
showBet();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startBtn) {
|
||||||
|
startBtn.addEventListener("click", function () {
|
||||||
|
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
|
||||||
|
send("/api/games/hangman/start", { bet: bet, tier: tier });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function solve() {
|
||||||
|
if (busy || !game || game.phase !== "playing") return;
|
||||||
|
var attempt = (solveIn.value || "").trim();
|
||||||
|
if (!attempt) { say("Say what it is, then.", "bad", gameMsgEl); return; }
|
||||||
|
send("/api/games/hangman/guess", { solve: attempt }, gameMsgEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (solveBtn) solveBtn.addEventListener("click", solve);
|
||||||
|
if (solveIn) {
|
||||||
|
solveIn.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); solve(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type a letter to guess it — but not while you're typing a solution into the
|
||||||
|
// box, which is the whole reason this checks what has focus.
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
|
||||||
|
if (!game || game.phase !== "playing" || busy) return;
|
||||||
|
var ch = (e.key || "").toLowerCase();
|
||||||
|
if (!/^[a-z0-9]$/.test(ch)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
guessLetter(ch);
|
||||||
|
});
|
||||||
|
|
||||||
|
buildKeys();
|
||||||
|
pickTier(tier);
|
||||||
|
|
||||||
|
var resumed = false;
|
||||||
|
window.PeteGames.onUpdate(function (v) {
|
||||||
|
if (!resumed) {
|
||||||
|
resumed = true;
|
||||||
|
if (v.hangman) {
|
||||||
|
paint(v.hangman);
|
||||||
|
if (v.hangman.phase === "done") verdict(v.hangman);
|
||||||
|
} else {
|
||||||
|
paint(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showBet();
|
||||||
|
});
|
||||||
|
})();
|
||||||
729
internal/web/static/js/solitaire.js
Normal file
729
internal/web/static/js/solitaire.js
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
// The solitaire table.
|
||||||
|
//
|
||||||
|
// Blackjack plays back a *script*: the server sends one event per card off the
|
||||||
|
// shoe and the table deals them out in order. That works because a blackjack hand
|
||||||
|
// only ever grows at one end. Solitaire doesn't: a move takes a run from anywhere
|
||||||
|
// and puts it anywhere, an auto-finish moves eleven cards at once, and a single
|
||||||
|
// move can turn a card over three columns away. A script of "append this card
|
||||||
|
// there" would be a second engine over here, and it would be the one that's wrong.
|
||||||
|
//
|
||||||
|
// So this table re-renders the whole board from the server's view after every
|
||||||
|
// move, and then animates the difference — FLIP: measure where every card *was*,
|
||||||
|
// re-render, measure where it *is*, and play each card from its old place to its
|
||||||
|
// new one. The board on screen is therefore always exactly the board the server
|
||||||
|
// says exists, and the animation is derived from it rather than the other way
|
||||||
|
// round. The events are still used, for the two things a diff genuinely cannot
|
||||||
|
// tell you: where a newly-revealed card came from (out of the stock, or turned
|
||||||
|
// over in place), and what the board is now worth.
|
||||||
|
//
|
||||||
|
// The money follows the same rule as every other table in the room: nothing about
|
||||||
|
// it changes without a chip crossing the felt to make it change. Here the stake
|
||||||
|
// buys the deck — it goes to the house and does not come back — and the spot in
|
||||||
|
// the rail holds what you've *banked*, which grows by one card's worth every time
|
||||||
|
// a card reaches a foundation and shrinks again if you take one back off.
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var root = document.querySelector("[data-solitaire]");
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
var FX = window.PeteFX;
|
||||||
|
var CARDS = window.PeteCards;
|
||||||
|
|
||||||
|
var stockEl = root.querySelector("[data-stock]");
|
||||||
|
var stockCountEl = root.querySelector("[data-stock-count]");
|
||||||
|
var stockRecycleEl = root.querySelector("[data-stock-recycle]");
|
||||||
|
var wasteEl = root.querySelector("[data-waste]");
|
||||||
|
var foundEl = root.querySelector("[data-foundations]");
|
||||||
|
var tableauEl = root.querySelector("[data-tableau]");
|
||||||
|
var verdictEl = root.querySelector("[data-verdict]");
|
||||||
|
var homeEl = root.querySelector("[data-home]");
|
||||||
|
var perCardEl = root.querySelector("[data-per-card]");
|
||||||
|
var breakEvenEl = root.querySelector("[data-break-even]");
|
||||||
|
var meterEl = root.querySelector("[data-meter]");
|
||||||
|
|
||||||
|
var playing = root.querySelector("[data-playing]");
|
||||||
|
var betting = root.querySelector("[data-betting]");
|
||||||
|
var autoBtn = root.querySelector("[data-auto]");
|
||||||
|
var cashBtn = root.querySelector("[data-cash]");
|
||||||
|
var cashAmountEl = root.querySelector("[data-cash-amount]");
|
||||||
|
var startBtn = root.querySelector("[data-start]");
|
||||||
|
var betAmountEl = root.querySelector("[data-bet-amount]");
|
||||||
|
var gameMsg = root.querySelector("[data-game-msg]");
|
||||||
|
var tableMsg = root.querySelector("[data-table-msg]");
|
||||||
|
|
||||||
|
var purseEl = document.querySelector("[data-chips]");
|
||||||
|
var spotEl = root.querySelector("[data-spot]");
|
||||||
|
var houseEl = root.querySelector("[data-house]");
|
||||||
|
|
||||||
|
// The spot in the rail. On this table it holds what you've banked, not a bet.
|
||||||
|
var spot = FX.spot({
|
||||||
|
spot: spotEl,
|
||||||
|
stack: root.querySelector("[data-stack]"),
|
||||||
|
total: root.querySelector("[data-spot-total]"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var FULL = 52;
|
||||||
|
var MOVE_MS = 300; // one card's journey across the board
|
||||||
|
var STEP_MS = 95; // the gap between two cards in a cascade
|
||||||
|
var reduced = FX.reduced;
|
||||||
|
|
||||||
|
var bet = 0; // the deck you're building the price of
|
||||||
|
var tier = null; // which deal
|
||||||
|
var busy = false; // a request is in flight, or cards are still moving
|
||||||
|
var board = null; // the board as the server last described it
|
||||||
|
var held = null; // {pile, count, cards} — the run in your hand
|
||||||
|
|
||||||
|
function wait(ms) {
|
||||||
|
return new Promise(function (r) { setTimeout(r, reduced ? 0 : ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function say(el, text, tone) {
|
||||||
|
if (!text) { el.classList.add("hidden"); return; }
|
||||||
|
el.textContent = text;
|
||||||
|
el.classList.remove("hidden");
|
||||||
|
el.style.color = tone === "bad" ? "#cc3d4a" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the rules, as the *browser* understands them --------------------------
|
||||||
|
//
|
||||||
|
// These mirror the engine, and they are not the engine: the server decides every
|
||||||
|
// move and will refuse one this file thought was fine. They exist because you
|
||||||
|
// cannot light up the columns a card can go to without knowing which those are,
|
||||||
|
// and a table that makes you find that out by being told no is a table that
|
||||||
|
// teaches Klondike by refusal. When the two disagree the server wins and the
|
||||||
|
// board snaps back to whatever it says — see send().
|
||||||
|
|
||||||
|
var RANKS = { A: 1, J: 11, Q: 12, K: 13 };
|
||||||
|
function rank(c) { return RANKS[c.rank] || parseInt(c.rank, 10); }
|
||||||
|
|
||||||
|
// isRun: descending by one, alternating colour — the only thing you may lift
|
||||||
|
// off a column as a block.
|
||||||
|
function isRun(cs) {
|
||||||
|
for (var i = 1; i < cs.length; i++) {
|
||||||
|
if (rank(cs[i]) !== rank(cs[i - 1]) - 1 || cs[i].red === cs[i - 1].red) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// accepts: what may be put where. A foundation takes its own suit in order from
|
||||||
|
// the ace; a column takes a run that descends and alternates from its top card,
|
||||||
|
// and an empty column takes a King and nothing else.
|
||||||
|
function accepts(pile, cs) {
|
||||||
|
if (!board || !cs || !cs.length) return false;
|
||||||
|
|
||||||
|
if (pile.charAt(0) === "f") {
|
||||||
|
var f = board.found[parseInt(pile.slice(1), 10)];
|
||||||
|
return !!f && cs.length === 1 && cs[0].suit === f.suit && rank(cs[0]) === f.n + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var col = board.table[parseInt(pile.slice(1), 10)];
|
||||||
|
if (!col || !isRun(cs)) return false;
|
||||||
|
if (!col.up || !col.up.length) return col.down === 0 && rank(cs[0]) === 13;
|
||||||
|
var top = col.up[col.up.length - 1];
|
||||||
|
return rank(cs[0]) === rank(top) - 1 && cs[0].red !== top.red;
|
||||||
|
}
|
||||||
|
|
||||||
|
// cardsAt is the run you'd be picking up by clicking this card.
|
||||||
|
function cardsAt(pile, idx) {
|
||||||
|
if (!board) return null;
|
||||||
|
if (pile === "waste") {
|
||||||
|
return board.waste && board.waste.length ? [board.waste[board.waste.length - 1]] : null;
|
||||||
|
}
|
||||||
|
if (pile.charAt(0) === "f") {
|
||||||
|
var f = board.found[parseInt(pile.slice(1), 10)];
|
||||||
|
return f && f.top ? [f.top] : null;
|
||||||
|
}
|
||||||
|
var col = board.table[parseInt(pile.slice(1), 10)];
|
||||||
|
if (!col || !col.up || idx >= col.up.length) return null;
|
||||||
|
return col.up.slice(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- drawing the board -----------------------------------------------------
|
||||||
|
|
||||||
|
// face builds a card element wired up for clicking. No deal flight and no tilt:
|
||||||
|
// this table animates its own cards from wherever they actually came from, and a
|
||||||
|
// column of thirteen tilted cards overlapping by an eighth of an inch reads as a
|
||||||
|
// mistake rather than as a hand that was handled.
|
||||||
|
function face(c, pile, idx) {
|
||||||
|
var el = CARDS.el(c, { deal: false, tilt: false });
|
||||||
|
el.dataset.pile = pile;
|
||||||
|
el.dataset.idx = String(idx);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slot(pile, glyph, red) {
|
||||||
|
var el = document.createElement("div");
|
||||||
|
el.className = "pete-slot";
|
||||||
|
el.dataset.pile = pile;
|
||||||
|
if (glyph) {
|
||||||
|
el.innerHTML = '<span class="pete-slot-glyph" data-red="' + (red ? "1" : "0") + '">' + glyph + "</span>";
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(v) {
|
||||||
|
board = v;
|
||||||
|
if (!v) {
|
||||||
|
wasteEl.innerHTML = "";
|
||||||
|
foundEl.innerHTML = "";
|
||||||
|
tableauEl.innerHTML = "";
|
||||||
|
stockEl.dataset.dead = "1";
|
||||||
|
stockCountEl.classList.add("hidden");
|
||||||
|
stockRecycleEl.classList.add("hidden");
|
||||||
|
meter(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stock. Empty with a pass left is not the same thing as empty with none:
|
||||||
|
// one is a gesture you can still make and the other is a dead pile, and the
|
||||||
|
// difference is worth showing rather than leaving to a click that gets refused.
|
||||||
|
var canRecycle = v.stock === 0 && v.waste_n > 0 && (v.passes < 0 || v.passes > 1);
|
||||||
|
stockEl.dataset.empty = v.stock === 0 ? "1" : "0";
|
||||||
|
stockEl.dataset.dead = v.stock === 0 && !canRecycle ? "1" : "0";
|
||||||
|
stockCountEl.textContent = String(v.stock);
|
||||||
|
stockCountEl.classList.toggle("hidden", v.stock === 0);
|
||||||
|
stockRecycleEl.classList.toggle("hidden", !canRecycle);
|
||||||
|
|
||||||
|
// The waste: the last three, fanned, and only the top one is yours to take.
|
||||||
|
wasteEl.innerHTML = "";
|
||||||
|
(v.waste || []).forEach(function (c, i, all) {
|
||||||
|
var el = face(c, "waste", i);
|
||||||
|
if (i === all.length - 1) el.dataset.live = "1";
|
||||||
|
wasteEl.appendChild(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The foundations. Each is a slot that stays put whether or not there's a card
|
||||||
|
// on it, so the board doesn't reflow the moment an ace goes home — and so a
|
||||||
|
// drop target has somewhere to be even when it's empty.
|
||||||
|
foundEl.innerHTML = "";
|
||||||
|
v.found.forEach(function (f, i) {
|
||||||
|
var s = slot("f" + i, f.suit, f.red);
|
||||||
|
if (f.top) {
|
||||||
|
var el = face(f.top, "f" + i, 0);
|
||||||
|
el.dataset.live = "1";
|
||||||
|
s.appendChild(el);
|
||||||
|
}
|
||||||
|
foundEl.appendChild(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The seven columns.
|
||||||
|
tableauEl.innerHTML = "";
|
||||||
|
v.table.forEach(function (col, i) {
|
||||||
|
var c = document.createElement("div");
|
||||||
|
c.className = "pete-col";
|
||||||
|
c.dataset.pile = "t" + i;
|
||||||
|
|
||||||
|
if (!col.down && !(col.up && col.up.length)) {
|
||||||
|
c.appendChild(slot("t" + i));
|
||||||
|
}
|
||||||
|
for (var d = 0; d < col.down; d++) {
|
||||||
|
c.appendChild(CARDS.el(null, { deal: false, tilt: false }));
|
||||||
|
}
|
||||||
|
(col.up || []).forEach(function (card, j) {
|
||||||
|
var el = face(card, "t" + i, j);
|
||||||
|
// A card is pickable if the run from it down is a run. Anything else is a
|
||||||
|
// card you can see and can't lift, and it shouldn't offer.
|
||||||
|
if (isRun(col.up.slice(j))) el.dataset.live = "1";
|
||||||
|
c.appendChild(el);
|
||||||
|
});
|
||||||
|
tableauEl.appendChild(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
meter(v);
|
||||||
|
controls(v);
|
||||||
|
if (held) mark(); // a selection survives a re-render, so redraw what it lit
|
||||||
|
}
|
||||||
|
|
||||||
|
// meter is what the board is worth. Every number in it comes off the server —
|
||||||
|
// this file does no arithmetic about money, which is the point.
|
||||||
|
function meter(v) {
|
||||||
|
if (!v) {
|
||||||
|
homeEl.innerHTML = '0<span class="text-white/40">/' + FULL + "</span>";
|
||||||
|
perCardEl.textContent = "—";
|
||||||
|
breakEvenEl.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
homeEl.innerHTML = v.home + '<span class="text-white/40">/' + FULL + "</span>";
|
||||||
|
perCardEl.textContent = "+" + v.per_card.toFixed(1);
|
||||||
|
breakEvenEl.textContent =
|
||||||
|
v.home >= v.break_even
|
||||||
|
? "You're ahead of the house"
|
||||||
|
: v.break_even - v.home + " more to break even";
|
||||||
|
meterEl.dataset.cold = v.home === 0 ? "1" : "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
function controls(v) {
|
||||||
|
var live = !!v && v.phase === "playing";
|
||||||
|
playing.classList.toggle("hidden", !live);
|
||||||
|
betting.classList.toggle("hidden", live);
|
||||||
|
if (!live) return;
|
||||||
|
autoBtn.disabled = !v.can_auto;
|
||||||
|
cashAmountEl.textContent = (v.stands || 0).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FLIP ------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Where every card is, right now. One deck, so a card's label is its identity.
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
var map = {};
|
||||||
|
root.querySelectorAll(".pete-card[data-key]").forEach(function (el) {
|
||||||
|
map[el.dataset.key] = el.getBoundingClientRect();
|
||||||
|
});
|
||||||
|
map["#stock"] = stockEl.getBoundingClientRect();
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// plan reads the events for the two things a before/after diff can't tell you:
|
||||||
|
// where a card that is *new to the board* came from, and how much of a beat to
|
||||||
|
// leave before it moves, so that an auto-finish cascades rather than teleporting.
|
||||||
|
function planOf(events) {
|
||||||
|
var origins = {}, delays = {}, at = 0;
|
||||||
|
(events || []).forEach(function (e) {
|
||||||
|
(e.cards || []).forEach(function (c) {
|
||||||
|
if (e.kind === "draw") origins[c.label] = "draw";
|
||||||
|
if (e.kind === "flip") origins[c.label] = "flip";
|
||||||
|
delays[c.label] = at;
|
||||||
|
});
|
||||||
|
if (e.kind === "move" || e.kind === "home") at += STEP_MS;
|
||||||
|
});
|
||||||
|
return { origins: origins, delays: delays };
|
||||||
|
}
|
||||||
|
|
||||||
|
// animate plays every card from where it was to where it is.
|
||||||
|
function animate(before, plan) {
|
||||||
|
if (reduced) return Promise.resolve();
|
||||||
|
var waits = [];
|
||||||
|
|
||||||
|
root.querySelectorAll(".pete-card[data-key]").forEach(function (el) {
|
||||||
|
var key = el.dataset.key;
|
||||||
|
var now = el.getBoundingClientRect();
|
||||||
|
var was = before[key];
|
||||||
|
var delay = plan.delays[key] || 0;
|
||||||
|
var origin = plan.origins[key];
|
||||||
|
|
||||||
|
// A card that was already on the board: play it from its old place. This is
|
||||||
|
// every ordinary move, and it is also what makes an eleven-card auto-finish
|
||||||
|
// animate itself for free.
|
||||||
|
if (was && !origin) {
|
||||||
|
var dx = was.left - now.left;
|
||||||
|
var dy = was.top - now.top;
|
||||||
|
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;
|
||||||
|
waits.push(slide(el, dx, dy, delay));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A card that has just been turned over. Out of the stock it flies as well as
|
||||||
|
// turns; in a column it turns where it lies.
|
||||||
|
if (origin === "draw") {
|
||||||
|
var from = before["#stock"];
|
||||||
|
waits.push(slide(el, from.left - now.left, from.top - now.top, delay));
|
||||||
|
waits.push(turn(el, delay));
|
||||||
|
} else if (origin === "flip") {
|
||||||
|
waits.push(turn(el, delay));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(waits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function slide(el, dx, dy, delay) {
|
||||||
|
return el.animate(
|
||||||
|
[{ transform: "translate(" + dx + "px," + dy + "px)" }, { transform: "none" }],
|
||||||
|
{
|
||||||
|
duration: MOVE_MS,
|
||||||
|
delay: delay,
|
||||||
|
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
|
||||||
|
fill: "backwards",
|
||||||
|
}
|
||||||
|
).finished.catch(noop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The card turns over on its own axis. The wrapper is doing the travelling, so
|
||||||
|
// this has to be the inner face or the two transforms would fight.
|
||||||
|
function turn(el, delay) {
|
||||||
|
var inner = el.querySelector(".pete-card-inner");
|
||||||
|
return inner.animate(
|
||||||
|
[{ transform: "rotateY(180deg)" }, { transform: "rotateY(0deg)" }],
|
||||||
|
{ duration: MOVE_MS, delay: delay, easing: "cubic-bezier(0.4, 0, 0.2, 1)", fill: "backwards" }
|
||||||
|
).finished.catch(noop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The recycle is the one move where cards *leave* the board, so FLIP has nothing
|
||||||
|
// to animate: they're gone from the new render before it can measure them. They
|
||||||
|
// get their flight here instead, out of the old DOM, before it's replaced.
|
||||||
|
function recycleOut() {
|
||||||
|
if (reduced) return Promise.resolve();
|
||||||
|
var to = stockEl.getBoundingClientRect();
|
||||||
|
var waits = [];
|
||||||
|
wasteEl.querySelectorAll(".pete-card").forEach(function (el, i) {
|
||||||
|
var now = el.getBoundingClientRect();
|
||||||
|
waits.push(
|
||||||
|
el.animate(
|
||||||
|
[
|
||||||
|
{ transform: "none", opacity: 1 },
|
||||||
|
{ transform: "translate(" + (to.left - now.left) + "px," + (to.top - now.top) + "px) rotateY(180deg)", opacity: 1 },
|
||||||
|
],
|
||||||
|
{ duration: 260, delay: i * 50, easing: "cubic-bezier(0.4, 0, 1, 1)", fill: "forwards" }
|
||||||
|
).finished.catch(noop)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return Promise.all(waits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function noop() {}
|
||||||
|
|
||||||
|
// A card reaching a foundation is the only move in this game that pays you, so
|
||||||
|
// it's the only one that makes a noise about it.
|
||||||
|
function flashHome(events) {
|
||||||
|
if (reduced) return;
|
||||||
|
var at = 0;
|
||||||
|
(events || []).forEach(function (e) {
|
||||||
|
if (e.kind !== "home" || !e.to) {
|
||||||
|
if (e.kind === "move") at += STEP_MS;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var when = at + MOVE_MS;
|
||||||
|
at += STEP_MS;
|
||||||
|
setTimeout(function () {
|
||||||
|
var pile = foundEl.querySelector('[data-pile="' + e.to + '"]');
|
||||||
|
if (!pile) return;
|
||||||
|
pile.classList.remove("pete-home-flash");
|
||||||
|
void pile.offsetWidth; // restart the animation if it's still running
|
||||||
|
pile.classList.add("pete-home-flash");
|
||||||
|
}, when);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the money -------------------------------------------------------------
|
||||||
|
|
||||||
|
// bank moves the spot to what the server says the board is worth. Up is chips
|
||||||
|
// out of the house's rack; down — a card taken back off a foundation — is chips
|
||||||
|
// going back to it. Either way the pile moves before the number does.
|
||||||
|
function bank(pays) {
|
||||||
|
var delta = (pays || 0) - spot.amount;
|
||||||
|
if (delta === 0) return Promise.resolve();
|
||||||
|
if (delta > 0) return spot.pour(houseEl, delta, { gap: 55 });
|
||||||
|
return spot.sweep(houseEl, -delta, { gap: 40, lift: 0.6, fade: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- picking cards up ------------------------------------------------------
|
||||||
|
|
||||||
|
function mark() {
|
||||||
|
root.querySelectorAll('[data-held="1"]').forEach(function (el) { delete el.dataset.held; });
|
||||||
|
root.querySelectorAll('[data-drop="1"]').forEach(function (el) { delete el.dataset.drop; });
|
||||||
|
if (!held) return;
|
||||||
|
|
||||||
|
// The run in your hand lifts off the felt.
|
||||||
|
root.querySelectorAll('.pete-card[data-pile="' + held.pile + '"]').forEach(function (el) {
|
||||||
|
if (held.pile === "waste" || held.pile.charAt(0) === "f") {
|
||||||
|
if (el.dataset.live === "1") el.dataset.held = "1";
|
||||||
|
} else if (parseInt(el.dataset.idx, 10) >= held.idx) {
|
||||||
|
el.dataset.held = "1";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// And everywhere it could go lights up. This is the whole reason the rules are
|
||||||
|
// mirrored over here: being shown where a card goes is the game teaching you,
|
||||||
|
// and being told no after you commit is the game scolding you.
|
||||||
|
root.querySelectorAll("[data-pile]").forEach(function (el) {
|
||||||
|
var pile = el.dataset.pile;
|
||||||
|
if (pile === held.pile || pile === "waste" || el.classList.contains("pete-card")) return;
|
||||||
|
if (accepts(pile, held.cards)) el.dataset.drop = "1";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(pile, idx) {
|
||||||
|
var cs = cardsAt(pile, idx);
|
||||||
|
if (!cs || !isRun(cs)) return;
|
||||||
|
held = { pile: pile, idx: idx, count: cs.length, cards: cs };
|
||||||
|
mark();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop() {
|
||||||
|
held = null;
|
||||||
|
mark();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nope(el) {
|
||||||
|
if (!el || reduced) return;
|
||||||
|
el.classList.remove("pete-nope");
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add("pete-nope");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A click on the board. The order matters: if something is in your hand and the
|
||||||
|
// thing you clicked will take it, that's a move — otherwise it's you picking up
|
||||||
|
// something else. Which means you never have to put a card down before choosing
|
||||||
|
// a different one.
|
||||||
|
root.querySelector(".pete-felt").addEventListener("click", function (e) {
|
||||||
|
if (busy || !board || board.phase !== "playing") return;
|
||||||
|
if (e.target.closest("[data-stock]")) return; // the stock has its own handler
|
||||||
|
|
||||||
|
var pileEl = e.target.closest("[data-pile]");
|
||||||
|
if (!pileEl) { drop(); return; }
|
||||||
|
|
||||||
|
var cardEl = e.target.closest(".pete-card[data-key]");
|
||||||
|
var pile = pileEl.dataset.pile;
|
||||||
|
|
||||||
|
if (held) {
|
||||||
|
if (pile === held.pile) { drop(); return; } // clicking your own run puts it down
|
||||||
|
if (accepts(pile, held.cards)) {
|
||||||
|
var move = { kind: "move", from: held.pile, to: pile, count: held.count };
|
||||||
|
drop();
|
||||||
|
send(move);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Not a place it goes. If what you clicked is a card you *could* pick up,
|
||||||
|
// this was a change of mind rather than a bad move; only shake at a genuine
|
||||||
|
// dead end.
|
||||||
|
if (!cardEl || cardEl.dataset.live !== "1") { nope(pileEl); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardEl && cardEl.dataset.live === "1") {
|
||||||
|
pick(pile, parseInt(cardEl.dataset.idx, 10));
|
||||||
|
} else {
|
||||||
|
drop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Double-click sends a card home. It's the idiom every solitaire has used for
|
||||||
|
// thirty years, and the alternative is asking the player which foundation — a
|
||||||
|
// question with exactly one right answer.
|
||||||
|
root.addEventListener("dblclick", function (e) {
|
||||||
|
if (busy || !board || board.phase !== "playing") return;
|
||||||
|
var cardEl = e.target.closest('.pete-card[data-live="1"]');
|
||||||
|
if (!cardEl) return;
|
||||||
|
var pile = cardEl.dataset.pile;
|
||||||
|
if (pile.charAt(0) === "f") return; // it's already home
|
||||||
|
e.preventDefault();
|
||||||
|
drop();
|
||||||
|
send({ kind: "home", from: pile });
|
||||||
|
});
|
||||||
|
|
||||||
|
stockEl.addEventListener("click", function () {
|
||||||
|
if (busy || !board || board.phase !== "playing") return;
|
||||||
|
if (stockEl.dataset.dead === "1") {
|
||||||
|
say(gameMsg, "That was your last pass through the stock.", "bad");
|
||||||
|
nope(stockEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drop();
|
||||||
|
send({ kind: "draw" });
|
||||||
|
});
|
||||||
|
|
||||||
|
autoBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
|
||||||
|
|
||||||
|
cashBtn.addEventListener("click", function () {
|
||||||
|
drop();
|
||||||
|
send({ kind: "concede" });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
|
||||||
|
if (e.key === "Escape") { drop(); return; }
|
||||||
|
if (busy || !board || board.phase !== "playing") return;
|
||||||
|
|
||||||
|
var k = e.key.toLowerCase();
|
||||||
|
if (k === " " || k === "d") { e.preventDefault(); stockEl.click(); }
|
||||||
|
else if (k === "a" && !autoBtn.disabled) { e.preventDefault(); autoBtn.click(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- talking to the table --------------------------------------------------
|
||||||
|
|
||||||
|
var VERDICTS = {
|
||||||
|
cleared: "Cleared the board! 🎉",
|
||||||
|
cashed: "Board cashed.",
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
// Clearing 52 cards out of a Vegas deal is the rarest thing that happens in
|
||||||
|
// this room, so it's the one that gets the confetti.
|
||||||
|
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 40 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// play walks a server response onto the felt: the board is re-rendered, the
|
||||||
|
// cards animate from where they were, and the chips follow.
|
||||||
|
//
|
||||||
|
// `money` — the chip bar catching up — is held back on a *settling* board until
|
||||||
|
// the payout has physically swept home. A counter that pays you before the chips
|
||||||
|
// arrive is a counter that has told you the ending.
|
||||||
|
function play(view, money) {
|
||||||
|
var v = view.solitaire;
|
||||||
|
var events = view.sol_events || [];
|
||||||
|
var settles = !!v && v.phase === "done";
|
||||||
|
var recycled = events.some(function (e) { return e.kind === "recycle"; });
|
||||||
|
|
||||||
|
var pre = recycled ? recycleOut() : Promise.resolve();
|
||||||
|
|
||||||
|
return pre
|
||||||
|
.then(function () {
|
||||||
|
var before = snapshot();
|
||||||
|
render(v);
|
||||||
|
var plan = planOf(events);
|
||||||
|
flashHome(events);
|
||||||
|
return animate(before, plan);
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
if (!v) { money(); return; }
|
||||||
|
return bank(v.stands).then(function () {
|
||||||
|
if (!settles) { money(); return; }
|
||||||
|
// The board is finished. Everything banked comes home, and only then
|
||||||
|
// does the number in the bar move.
|
||||||
|
verdict(v);
|
||||||
|
return wait(300)
|
||||||
|
.then(function () { return spot.sweep(purseEl, v.payout, { gap: 40, lift: 0.8 }); })
|
||||||
|
.then(money)
|
||||||
|
.then(function () { controls(null); showBet(); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(move) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
say(gameMsg, "");
|
||||||
|
return window.PeteGames.post("/api/games/solitaire/move", move)
|
||||||
|
.then(function (view) { return play(view, function () { window.PeteGames.apply(view); }); })
|
||||||
|
.catch(function (err) {
|
||||||
|
say(gameMsg, err.message, "bad");
|
||||||
|
// Whatever this file thought the board was, the server is the authority on
|
||||||
|
// it. Ask, and draw what it says.
|
||||||
|
return window.PeteGames.refresh().then(function (v) {
|
||||||
|
if (v) { render(v.solitaire || null); spot.render(v.solitaire ? v.solitaire.stands : 0); }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function () { busy = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- buying a deck ---------------------------------------------------------
|
||||||
|
|
||||||
|
function showBet() {
|
||||||
|
betAmountEl.textContent = bet.toLocaleString();
|
||||||
|
var money = window.PeteGames.view();
|
||||||
|
startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.querySelectorAll("[data-tier]").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
tier = btn.dataset.tier;
|
||||||
|
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||||
|
b.dataset.on = b === btn ? "1" : "0";
|
||||||
|
});
|
||||||
|
showBet();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The chip you click is the chip that flies. It lands on the spot in the rail,
|
||||||
|
// which is where the price of the deck is stacked up before you pay it.
|
||||||
|
root.querySelectorAll("[data-chip]").forEach(function (btn) {
|
||||||
|
if (!btn.dataset.chip || btn.tagName !== "BUTTON") return;
|
||||||
|
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(tableMsg, "You haven't got that many chips.", "bad");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bet += d;
|
||||||
|
showBet();
|
||||||
|
|
||||||
|
var target = bet;
|
||||||
|
spot.amount = bet;
|
||||||
|
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
||||||
|
if (bet >= target) spot.render(target); // unless Clear got there first
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
root.querySelector("[data-bet-clear]").addEventListener("click", function () {
|
||||||
|
if (busy) return;
|
||||||
|
if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||||||
|
bet = 0;
|
||||||
|
showBet();
|
||||||
|
});
|
||||||
|
|
||||||
|
startBtn.addEventListener("click", function () {
|
||||||
|
if (busy) return;
|
||||||
|
if (!tier) { say(tableMsg, "Pick a deal first.", "bad"); return; }
|
||||||
|
if (bet <= 0) { say(tableMsg, "Put something down for the deck.", "bad"); return; }
|
||||||
|
|
||||||
|
busy = true;
|
||||||
|
say(tableMsg, "");
|
||||||
|
var price = bet;
|
||||||
|
|
||||||
|
window.PeteGames.post("/api/games/solitaire/start", { bet: price, tier: tier })
|
||||||
|
.then(function (view) {
|
||||||
|
// The deck is *bought*: the chips on the spot go across to the house and
|
||||||
|
// do not come back. Then the spot starts again at nothing, and from here
|
||||||
|
// on it holds what the board has earned back.
|
||||||
|
return spot
|
||||||
|
.sweep(houseEl, price, { gap: 45, lift: 0.6 })
|
||||||
|
.then(function () {
|
||||||
|
bet = 0;
|
||||||
|
showBet();
|
||||||
|
window.PeteGames.apply(view);
|
||||||
|
return dealOut(view.solitaire);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
say(tableMsg, err.message, "bad");
|
||||||
|
return window.PeteGames.refresh();
|
||||||
|
})
|
||||||
|
.then(function () { busy = false; });
|
||||||
|
});
|
||||||
|
|
||||||
|
// dealOut lays the board and flies every card onto it out of the stock, one at a
|
||||||
|
// time, across the columns — the way a deal actually goes down.
|
||||||
|
function dealOut(v) {
|
||||||
|
render(v);
|
||||||
|
if (reduced || !v) return Promise.resolve();
|
||||||
|
|
||||||
|
var from = stockEl.getBoundingClientRect();
|
||||||
|
var waits = [];
|
||||||
|
|
||||||
|
// The order a real deal goes in: one card to each column, then round again,
|
||||||
|
// starting a column further along each time.
|
||||||
|
var order = 0;
|
||||||
|
for (var row = 0; row < 7; row++) {
|
||||||
|
for (var col = row; col < 7; col++) {
|
||||||
|
var colEl = tableauEl.children[col];
|
||||||
|
var cardEl = colEl.children[row];
|
||||||
|
if (!cardEl) continue;
|
||||||
|
var now = cardEl.getBoundingClientRect();
|
||||||
|
waits.push(slide(cardEl, from.left - now.left, from.top - now.top, order * 34));
|
||||||
|
if (cardEl.dataset.face === "up") waits.push(turn(cardEl, order * 34));
|
||||||
|
order++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.all(waits);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- coming in ------------------------------------------------------------
|
||||||
|
|
||||||
|
// The money bar owns the first fetch. The table picks up whatever it found —
|
||||||
|
// including a board left mid-game by a reload or a redeploy, which comes back
|
||||||
|
// exactly as it was, right down to what it has banked.
|
||||||
|
var resumed = false;
|
||||||
|
window.PeteGames.onUpdate(function (v) {
|
||||||
|
if (!resumed) {
|
||||||
|
resumed = true;
|
||||||
|
if (v.solitaire) {
|
||||||
|
render(v.solitaire);
|
||||||
|
spot.render(v.solitaire.stands);
|
||||||
|
} else {
|
||||||
|
controls(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showBet();
|
||||||
|
});
|
||||||
|
})();
|
||||||
554
internal/web/static/js/trivia.js
Normal file
554
internal/web/static/js/trivia.js
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
// The trivia table.
|
||||||
|
//
|
||||||
|
// Same bargain as every other table in the room: the browser holds no game. It
|
||||||
|
// sends an answer, and the server says how it went. The four buttons arrive
|
||||||
|
// without any mark on which of them is right — that index is in the engine
|
||||||
|
// state, on the server — and the reveal only comes back in the event that
|
||||||
|
// decides the question, by which point knowing it is worth nothing.
|
||||||
|
//
|
||||||
|
// The countdown here is decoration, and it is important to be clear about that.
|
||||||
|
// Nothing it does scores anything: the server timed the answer the moment it
|
||||||
|
// arrived, against the clock it started when it served the question. Stopping
|
||||||
|
// this bar, or reloading to restart it, changes nothing at all. What the bar
|
||||||
|
// owes the player is *honesty* — so it is seeded from the seconds the server
|
||||||
|
// says are left, not from when the browser got round to painting.
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var root = document.querySelector("[data-trivia]");
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
var FX = window.PeteFX;
|
||||||
|
|
||||||
|
var questionEl = root.querySelector("[data-question]");
|
||||||
|
var categoryEl = root.querySelector("[data-category]");
|
||||||
|
var answersEl = root.querySelector("[data-answers]");
|
||||||
|
var clockEl = root.querySelector("[data-clock]");
|
||||||
|
var clockFillEl = root.querySelector("[data-clock-fill]");
|
||||||
|
var countdownEl = root.querySelector("[data-countdown]");
|
||||||
|
var ladderEl = root.querySelector("[data-ladder]");
|
||||||
|
var multEl = root.querySelector("[data-multiple]");
|
||||||
|
var meterEl = root.querySelector("[data-meter]");
|
||||||
|
var standsEl = root.querySelector("[data-stands]");
|
||||||
|
var standsLbl = root.querySelector("[data-stands-label]");
|
||||||
|
var rungEl = root.querySelector("[data-rung]");
|
||||||
|
var verdictEl = root.querySelector("[data-verdict]");
|
||||||
|
|
||||||
|
var betting = root.querySelector("[data-betting]");
|
||||||
|
var playing = root.querySelector("[data-playing]");
|
||||||
|
var walkBtn = root.querySelector("[data-walk]");
|
||||||
|
var walkAmtEl = root.querySelector("[data-walk-amount]");
|
||||||
|
var betAmount = root.querySelector("[data-bet-amount]");
|
||||||
|
var startBtn = root.querySelector("[data-start]");
|
||||||
|
var msgEl = root.querySelector("[data-table-msg]");
|
||||||
|
var gameMsgEl = root.querySelector("[data-game-msg]");
|
||||||
|
|
||||||
|
var purseEl = document.querySelector("[data-chips]");
|
||||||
|
var spotEl = root.querySelector("[data-spot]");
|
||||||
|
var houseEl = root.querySelector("[data-house]");
|
||||||
|
|
||||||
|
// The bet spot, and the rule that comes with it: the number under the pile is
|
||||||
|
// a readout of the pile, never the other way round.
|
||||||
|
var spot = FX.spot({
|
||||||
|
spot: spotEl,
|
||||||
|
stack: root.querySelector("[data-stack]"),
|
||||||
|
total: root.querySelector("[data-spot-total]"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var bet = 0; // what you're building between games
|
||||||
|
var busy = false;
|
||||||
|
var game = null; // the round as the server last described it
|
||||||
|
var tier = "medium";
|
||||||
|
|
||||||
|
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, where) {
|
||||||
|
var el = where || msgEl;
|
||||||
|
if (!el) return;
|
||||||
|
if (!text) { el.classList.add("hidden"); return; }
|
||||||
|
el.textContent = text;
|
||||||
|
el.classList.remove("hidden");
|
||||||
|
el.style.color = tone === "bad" ? "#cc3d4a" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the clock -------------------------------------------------------------
|
||||||
|
|
||||||
|
var raf = null;
|
||||||
|
var clockDeadline = 0; // performance.now() ms at which this question dies
|
||||||
|
var clockLimit = 1;
|
||||||
|
var timedOut = false;
|
||||||
|
|
||||||
|
// HOT is when the bar stops being a progress meter and starts being a warning.
|
||||||
|
var HOT = 5;
|
||||||
|
|
||||||
|
// GRACE is how long past its own zero the browser waits before reporting the
|
||||||
|
// timeout. It cannot be negative: the browser's countdown starts when the
|
||||||
|
// response *arrives*, so it is already behind the server's by the latency of
|
||||||
|
// that response, and it therefore always reaches zero after the server has.
|
||||||
|
// The grace is only there so that "always" survives a rounding error.
|
||||||
|
var GRACE = 400;
|
||||||
|
|
||||||
|
function stopClock() {
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
raf = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startClock(left, limit) {
|
||||||
|
stopClock();
|
||||||
|
timedOut = false;
|
||||||
|
clockLimit = limit > 0 ? limit : 1;
|
||||||
|
clockDeadline = performance.now() + left * 1000;
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
var left = (clockDeadline - performance.now()) / 1000;
|
||||||
|
if (left < 0) left = 0;
|
||||||
|
|
||||||
|
// A transform, not a width: the browser can run this one without laying the
|
||||||
|
// page out again on every frame.
|
||||||
|
clockFillEl.style.transform = "scaleX(" + (left / clockLimit).toFixed(4) + ")";
|
||||||
|
countdownEl.textContent = left.toFixed(1) + "s";
|
||||||
|
clockEl.dataset.hot = left <= HOT && left > 0 ? "1" : "0";
|
||||||
|
|
||||||
|
if (left <= 0) {
|
||||||
|
countdownEl.textContent = "0.0s";
|
||||||
|
clockEl.dataset.hot = "0";
|
||||||
|
timeUp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeUp reports the clock running out. It is not the browser *deciding* the
|
||||||
|
// question — it is the browser telling the server the player never answered,
|
||||||
|
// and the server (which has been holding the real clock all along) agreeing.
|
||||||
|
function timeUp() {
|
||||||
|
stopClock();
|
||||||
|
if (timedOut || !game || game.phase !== "playing") return;
|
||||||
|
// A move is already in flight. Come back rather than give up: this fires when
|
||||||
|
// the server has rejected our last timeout report (its clock hadn't run out
|
||||||
|
// yet) and the refresh has re-armed a countdown that is already at zero. Give
|
||||||
|
// up here and the clock sits frozen at 0.0s and the question never resolves.
|
||||||
|
if (busy) { setTimeout(timeUp, 250); return; }
|
||||||
|
timedOut = true;
|
||||||
|
lockAnswers();
|
||||||
|
setTimeout(function () {
|
||||||
|
// -1 is "no answer". The engine checks the clock before it checks the
|
||||||
|
// choice, so this resolves as the timeout it is rather than a bad move.
|
||||||
|
send("/api/games/trivia/answer", { choice: -1 }, gameMsgEl);
|
||||||
|
}, GRACE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearClock() {
|
||||||
|
stopClock();
|
||||||
|
clockFillEl.style.transform = "scaleX(0)";
|
||||||
|
countdownEl.textContent = "";
|
||||||
|
clockEl.dataset.hot = "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the question ----------------------------------------------------------
|
||||||
|
|
||||||
|
var KEYS = ["1", "2", "3", "4"];
|
||||||
|
|
||||||
|
function renderQuestion(v) {
|
||||||
|
answersEl.innerHTML = "";
|
||||||
|
if (!v || v.phase !== "playing" || !v.answers) {
|
||||||
|
questionEl.textContent = "";
|
||||||
|
categoryEl.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
categoryEl.textContent = v.category || "";
|
||||||
|
questionEl.textContent = v.question || "";
|
||||||
|
|
||||||
|
v.answers.forEach(function (text, i) {
|
||||||
|
var b = document.createElement("button");
|
||||||
|
b.type = "button";
|
||||||
|
b.className = "pete-answer";
|
||||||
|
b.dataset.at = String(i);
|
||||||
|
|
||||||
|
var key = document.createElement("span");
|
||||||
|
key.className = "pete-answer-key";
|
||||||
|
key.textContent = KEYS[i] || "";
|
||||||
|
key.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
|
var label = document.createElement("span");
|
||||||
|
label.textContent = text;
|
||||||
|
|
||||||
|
b.appendChild(key);
|
||||||
|
b.appendChild(label);
|
||||||
|
b.addEventListener("click", function () { answer(i); });
|
||||||
|
answersEl.appendChild(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockAnswers() {
|
||||||
|
answersEl.querySelectorAll(".pete-answer").forEach(function (b) { b.disabled = true; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// reveal marks the board once the server has decided. Nothing in here is known
|
||||||
|
// until it comes back: the right answer arrives in the event, not in the view.
|
||||||
|
function reveal(choice, correct) {
|
||||||
|
answersEl.querySelectorAll(".pete-answer").forEach(function (b) {
|
||||||
|
var i = parseInt(b.dataset.at, 10);
|
||||||
|
b.disabled = true;
|
||||||
|
if (i === choice && i === correct) b.dataset.state = "right";
|
||||||
|
else if (i === choice) b.dataset.state = "wrong";
|
||||||
|
else if (i === correct) b.dataset.state = "missed";
|
||||||
|
else b.dataset.state = "dim";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the meters ------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderLadder(v) {
|
||||||
|
ladderEl.innerHTML = "";
|
||||||
|
var rungs = (v && v.rungs) || 12;
|
||||||
|
var done = (v && v.rung) || 0;
|
||||||
|
for (var i = 0; i < rungs; i++) {
|
||||||
|
var pip = document.createElement("span");
|
||||||
|
pip.className = "pete-rung";
|
||||||
|
pip.dataset.on = i < done ? "1" : "0";
|
||||||
|
ladderEl.appendChild(pip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMeter(v) {
|
||||||
|
if (!v) {
|
||||||
|
multEl.textContent = "—";
|
||||||
|
standsEl.textContent = "—";
|
||||||
|
standsLbl.textContent = "if you walk";
|
||||||
|
meterEl.dataset.cold = "1";
|
||||||
|
rungEl.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
multEl.textContent = v.multiple.toFixed(2) + "×";
|
||||||
|
standsEl.textContent = (v.stands || 0).toLocaleString();
|
||||||
|
meterEl.dataset.cold = v.rung === 0 ? "1" : "0";
|
||||||
|
|
||||||
|
if (v.phase === "done") {
|
||||||
|
standsLbl.textContent = v.net > 0 ? "banked" : "gone";
|
||||||
|
rungEl.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
standsLbl.textContent = v.can_walk ? "if you walk" : "answer one to unlock";
|
||||||
|
rungEl.textContent = "Question " + (v.rung + 1) + " of " + v.rungs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// knock rolls the multiple up to its new value rather than swapping it, so a
|
||||||
|
// right answer reads as the total *growing* — which is the thing you're
|
||||||
|
// deciding whether to risk.
|
||||||
|
function climb(v) {
|
||||||
|
var from = parseFloat(multEl.textContent) || 1;
|
||||||
|
var to = v.multiple;
|
||||||
|
if (reduced) { renderMeter(v); return; }
|
||||||
|
var t0 = performance.now();
|
||||||
|
meterEl.dataset.hit = "0";
|
||||||
|
|
||||||
|
(function step(now) {
|
||||||
|
var p = Math.min(1, (now - t0) / 420);
|
||||||
|
var eased = 1 - Math.pow(1 - p, 3);
|
||||||
|
multEl.textContent = (from + (to - from) * eased).toFixed(2) + "×";
|
||||||
|
if (p < 1) requestAnimationFrame(step);
|
||||||
|
else renderMeter(v);
|
||||||
|
})(t0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the money -------------------------------------------------------------
|
||||||
|
|
||||||
|
function settleChips(final) {
|
||||||
|
var payout = final.payout || 0;
|
||||||
|
var back = payout - final.bet;
|
||||||
|
|
||||||
|
if (payout <= 0) {
|
||||||
|
var chain = spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
return spot
|
||||||
|
.pour(houseEl, back, { gap: 60 })
|
||||||
|
.then(function () { return wait(back > 0 ? 380 : 200); })
|
||||||
|
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// standing puts the stake back on the spot for the next ladder, the way every
|
||||||
|
// other table in the room leaves your bet up.
|
||||||
|
function standing(amount) {
|
||||||
|
var money = window.PeteGames.view();
|
||||||
|
if (!amount || !money || money.chips < amount) {
|
||||||
|
bet = 0;
|
||||||
|
showBet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bet = amount;
|
||||||
|
showBet();
|
||||||
|
// pour grows the pile from whatever is on the spot, and settle has just swept
|
||||||
|
// it clean — so it must not be told the chips are already there. Setting the
|
||||||
|
// amount first counted the standing bet twice, and the spot printed 400 under
|
||||||
|
// a 200 stake.
|
||||||
|
return spot.pour(purseEl, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- phases ----------------------------------------------------------------
|
||||||
|
|
||||||
|
var VERDICTS = {
|
||||||
|
walked: "Banked it.",
|
||||||
|
cleared: "Cleared the board! 🎉",
|
||||||
|
wrong: "Wrong.",
|
||||||
|
timeout: "Out of time.",
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
// Confetti only for clearing all twelve — the one thing in here worth it.
|
||||||
|
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 34 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPhase(v) {
|
||||||
|
game = v;
|
||||||
|
var live = !!v && v.phase === "playing";
|
||||||
|
betting.classList.toggle("hidden", live);
|
||||||
|
playing.classList.toggle("hidden", !live);
|
||||||
|
if (walkBtn) {
|
||||||
|
walkBtn.disabled = !live || !v.can_walk;
|
||||||
|
walkAmtEl.textContent = (v && v.can_walk ? v.stands : 0).toLocaleString();
|
||||||
|
}
|
||||||
|
if (!v || !v.outcome) verdictEl.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
// paint puts a round up with no animation: the resume path, after a reload or a
|
||||||
|
// redeploy. The clock picks up exactly where the server says it is — which is
|
||||||
|
// the whole point of it being the server's clock.
|
||||||
|
function paint(v) {
|
||||||
|
if (!v) {
|
||||||
|
clearClock();
|
||||||
|
renderQuestion(null);
|
||||||
|
renderLadder(null);
|
||||||
|
renderMeter(null);
|
||||||
|
spot.render(0);
|
||||||
|
setPhase(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderQuestion(v);
|
||||||
|
renderLadder(v);
|
||||||
|
renderMeter(v);
|
||||||
|
spot.render(v.phase === "done" ? 0 : v.bet);
|
||||||
|
setPhase(v);
|
||||||
|
|
||||||
|
if (v.phase === "playing") startClock(v.left, v.limit);
|
||||||
|
else clearClock();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the script ------------------------------------------------------------
|
||||||
|
|
||||||
|
// play walks the server's events. Same rule as the other tables: on a live
|
||||||
|
// round the money is already right (your stake left your pile when you pressed
|
||||||
|
// Play, and it's on the spot), but on a settling one the chip bar is held back
|
||||||
|
// until the chips have physically come home. A counter that pays you before
|
||||||
|
// the reveal is a counter that has told you the ending.
|
||||||
|
function play(view, money) {
|
||||||
|
var events = view.triv_events || [];
|
||||||
|
var final = view.trivia;
|
||||||
|
var settles = !!final && final.phase === "done";
|
||||||
|
var chain = Promise.resolve();
|
||||||
|
|
||||||
|
if (!settles) money();
|
||||||
|
|
||||||
|
stopClock();
|
||||||
|
|
||||||
|
events.forEach(function (e) {
|
||||||
|
chain = chain.then(function () {
|
||||||
|
switch (e.kind) {
|
||||||
|
case "ask":
|
||||||
|
// A fresh question. Everything about the last one goes.
|
||||||
|
verdictEl.classList.add("hidden");
|
||||||
|
renderQuestion(final);
|
||||||
|
renderLadder(final);
|
||||||
|
if (final && final.phase === "playing") startClock(final.left, final.limit);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case "right":
|
||||||
|
reveal(e.choice, e.correct);
|
||||||
|
if (final) {
|
||||||
|
// The rung lighting and the multiple climbing are one event,
|
||||||
|
// because they are one event: this is what the answer was worth.
|
||||||
|
climb({ multiple: e.multiple, rung: final.rung, rungs: final.rungs,
|
||||||
|
stands: final.stands, can_walk: true, phase: "playing" });
|
||||||
|
renderLadder(final);
|
||||||
|
}
|
||||||
|
return wait(900);
|
||||||
|
|
||||||
|
case "wrong":
|
||||||
|
reveal(e.choice, e.correct);
|
||||||
|
return wait(1100);
|
||||||
|
|
||||||
|
case "timeout":
|
||||||
|
reveal(-1, e.correct);
|
||||||
|
return wait(1100);
|
||||||
|
|
||||||
|
case "settle":
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return chain.then(function () {
|
||||||
|
if (!final) { paint(null); money(); return; }
|
||||||
|
|
||||||
|
if (!settles) {
|
||||||
|
renderMeter(final);
|
||||||
|
setPhase(final);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Over: the clock stops, the money moves, and only then does the bar catch up.
|
||||||
|
clearClock();
|
||||||
|
playing.classList.add("hidden");
|
||||||
|
renderMeter(final);
|
||||||
|
renderLadder(final);
|
||||||
|
verdict(final);
|
||||||
|
return settleChips(final)
|
||||||
|
.then(money)
|
||||||
|
.then(function () { return standing(final.bet); })
|
||||||
|
.then(function () { setPhase(final); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- talking to the table ---------------------------------------------------
|
||||||
|
|
||||||
|
function send(path, body, where) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
say("", null, where);
|
||||||
|
return window.PeteGames.post(path, body)
|
||||||
|
.then(function (view) {
|
||||||
|
return play(view, function () { window.PeteGames.apply(view); });
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
say(err.message, "bad", where);
|
||||||
|
return window.PeteGames.refresh().then(function (v) {
|
||||||
|
if (v && v.trivia) paint(v.trivia);
|
||||||
|
else { paint(null); spot.render(0); }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function () { busy = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function answer(i) {
|
||||||
|
if (busy || timedOut || !game || game.phase !== "playing") return;
|
||||||
|
stopClock();
|
||||||
|
lockAnswers();
|
||||||
|
send("/api/games/trivia/answer", { choice: i }, gameMsgEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (walkBtn) {
|
||||||
|
walkBtn.addEventListener("click", function () {
|
||||||
|
if (busy || !game || game.phase !== "playing" || !game.can_walk) return;
|
||||||
|
stopClock();
|
||||||
|
lockAnswers();
|
||||||
|
send("/api/games/trivia/answer", { walk: true }, gameMsgEl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1–4 answers the question. The key is printed on the button it answers.
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
|
||||||
|
if (!game || game.phase !== "playing" || busy) return;
|
||||||
|
var i = KEYS.indexOf(e.key);
|
||||||
|
if (i === -1) return;
|
||||||
|
var btn = answersEl.querySelector('.pete-answer[data-at="' + i + '"]');
|
||||||
|
if (!btn || btn.disabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
answer(i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- betting ----------------------------------------------------------------
|
||||||
|
|
||||||
|
function showBet() {
|
||||||
|
betAmount.textContent = bet.toLocaleString();
|
||||||
|
var money = window.PeteGames.view();
|
||||||
|
if (startBtn) startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickTier(slug) {
|
||||||
|
tier = slug;
|
||||||
|
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||||
|
b.dataset.on = b.dataset.tier === slug ? "1" : "0";
|
||||||
|
});
|
||||||
|
showBet();
|
||||||
|
}
|
||||||
|
|
||||||
|
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||||
|
b.addEventListener("click", function () {
|
||||||
|
if (busy) return;
|
||||||
|
pickTier(b.dataset.tier);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The chip you click is the chip that flies. Scoped to buttons: the bare
|
||||||
|
// [data-chip] spans in the corner are the house's rack, and it is not betting.
|
||||||
|
root.querySelectorAll("button[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();
|
||||||
|
|
||||||
|
var target = bet;
|
||||||
|
spot.amount = bet;
|
||||||
|
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
||||||
|
if (bet >= target) spot.render(target); // unless Clear got there first
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var clearBtn = root.querySelector("[data-bet-clear]");
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener("click", function () {
|
||||||
|
if (busy) return;
|
||||||
|
if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||||||
|
bet = 0;
|
||||||
|
showBet();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startBtn) {
|
||||||
|
startBtn.addEventListener("click", function () {
|
||||||
|
if (busy) return;
|
||||||
|
if (!tier) { say("Pick a difficulty first.", "bad"); return; }
|
||||||
|
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
|
||||||
|
// The stake stays on the spot for the whole ladder: it is what's at risk,
|
||||||
|
// and it is riding on every question until you take it back or lose it.
|
||||||
|
send("/api/games/trivia/start", { bet: bet, tier: tier });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pickTier(tier);
|
||||||
|
|
||||||
|
var resumed = false;
|
||||||
|
window.PeteGames.onUpdate(function (v) {
|
||||||
|
if (!resumed) {
|
||||||
|
resumed = true;
|
||||||
|
if (v.trivia) {
|
||||||
|
paint(v.trivia);
|
||||||
|
if (v.trivia.phase === "done") verdict(v.trivia);
|
||||||
|
} else {
|
||||||
|
paint(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showBet();
|
||||||
|
});
|
||||||
|
})();
|
||||||
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}}
|
||||||
133
internal/web/templates/blackjack.html
Normal file
133
internal/web/templates/blackjack.html
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
{{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-at="shoe" 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/casino-cards.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}}
|
||||||
|
|||||||
123
internal/web/templates/games.html
Normal file
123
internal/web/templates/games.html
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
{{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>
|
||||||
|
|
||||||
|
<a href="/games/hangman"
|
||||||
|
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">Hangman</h3>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60">Guess the phrase, keep the multiple.</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">
|
||||||
|
Short phrases pay up to 2.6×. You get {{.MaxWrong}} lives, and every wrong guess
|
||||||
|
takes a tenth off what a win is worth.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/games/solitaire"
|
||||||
|
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">Solitaire</h3>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60">Buy the deck, win it back a card at a time.</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">
|
||||||
|
Vegas rules. Your stake buys the deck and doesn't come back — every card you
|
||||||
|
get home pays a slice of it in. Cash the board whenever you like.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/games/trivia"
|
||||||
|
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">Trivia</h3>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60">Climb the ladder, or take the money.</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">
|
||||||
|
{{.Rungs}} questions, and every right answer multiplies what you're holding. A wrong
|
||||||
|
one loses the lot. Answer fast: the multiple decays as the clock runs.
|
||||||
|
</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}}
|
||||||
176
internal/web/templates/hangman.html
Normal file
176
internal/web/templates/hangman.html
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
{{define "title"}}Hangman · {{.Room.Name}}{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<div class="space-y-6" data-hangman>
|
||||||
|
|
||||||
|
<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">Hangman</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/50">{{.MaxWrong}} lives · every wrong guess costs you a tenth of the win</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "_chipbar" .}}
|
||||||
|
|
||||||
|
<!-- The felt. The gallows is on it because the gallows is the meter: it counts
|
||||||
|
down your lives and your winnings at the same time. -->
|
||||||
|
<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 gallows and the stake stack up on the left; the phrase and what it's
|
||||||
|
worth take the rest. The right column keeps clear of the rack in the
|
||||||
|
corner, which is why nothing in it is pushed to the far edge. -->
|
||||||
|
<div class="relative grid gap-x-10 gap-y-8 lg:grid-cols-[auto,1fr] lg:items-start">
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-6 lg:items-start">
|
||||||
|
<svg data-gallows viewBox="0 0 130 150" class="pete-gallows h-48 w-auto sm:h-56" role="img"
|
||||||
|
aria-labelledby="gallows-title">
|
||||||
|
<title id="gallows-title">The gallows</title>
|
||||||
|
<g class="pete-gallows-frame">
|
||||||
|
<path d="M8 145 H72" />
|
||||||
|
<path d="M28 145 V8" />
|
||||||
|
<path d="M28 8 H92" />
|
||||||
|
<path d="M92 8 V26" />
|
||||||
|
</g>
|
||||||
|
<g class="pete-gallows-body">
|
||||||
|
<circle data-part="0" cx="92" cy="38" r="12" />
|
||||||
|
<path data-part="1" d="M92 50 V88" />
|
||||||
|
<path data-part="2" d="M92 58 L74 76" />
|
||||||
|
<path data-part="3" d="M92 58 L110 76" />
|
||||||
|
<path data-part="4" d="M92 88 L76 114" />
|
||||||
|
<path data-part="5" d="M92 88 L108 114" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- The stake sits under the gallows it's riding on, the same spot the
|
||||||
|
blackjack table puts your bet on. -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<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>
|
||||||
|
<p data-lives class="text-xs font-bold uppercase tracking-wider text-white/50"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0 space-y-6">
|
||||||
|
|
||||||
|
<!-- What a win is worth right now. It falls as the figure fills in.
|
||||||
|
This row is the only one level with the house rack in the corner, so
|
||||||
|
it is the only one that has to keep clear of it — the board below
|
||||||
|
gets the full width, and needs it. -->
|
||||||
|
<div class="flex flex-wrap items-center gap-3 pr-24 sm:pr-28">
|
||||||
|
<div class="pete-meter" data-meter>
|
||||||
|
<span class="pete-meter-label">Pays</span>
|
||||||
|
<span data-multiple class="pete-meter-value">—</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-white/60">
|
||||||
|
<span data-stands class="font-bold tabular-nums text-white/90">—</span>
|
||||||
|
<span data-stands-label>if you get it</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The phrase. -->
|
||||||
|
<div data-board class="pete-board" aria-live="polite" aria-label="The phrase"></div>
|
||||||
|
|
||||||
|
<!-- Letters that missed. -->
|
||||||
|
<div class="flex min-h-[1.75rem] flex-wrap items-center gap-1.5">
|
||||||
|
<span data-wrong-label class="hidden text-xs font-bold uppercase tracking-wider text-white/40">Missed</span>
|
||||||
|
<div data-wrong class="flex flex-wrap gap-1.5"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- What just happened. -->
|
||||||
|
<div class="flex min-h-[2.75rem] items-center">
|
||||||
|
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Guessing: shown while a game is live. -->
|
||||||
|
<section data-guessing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
<div data-keyboard class="pete-keys"></div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
<label for="solve" class="sr-only">Guess the whole phrase</label>
|
||||||
|
<input id="solve" data-solve-input type="text" autocomplete="off" enterkeyhint="go"
|
||||||
|
placeholder="Or just say what it is…"
|
||||||
|
class="min-w-0 flex-1 rounded-full bg-[color:var(--ink)]/5 px-4 py-2.5 text-sm font-semibold
|
||||||
|
border-2 border-[color:var(--ink)]/10 focus:outline-none focus:border-[color:var(--accent)]">
|
||||||
|
<button type="button" data-solve
|
||||||
|
class="rounded-full bg-[color:var(--accent)] px-6 py-2.5 font-display font-bold text-white shadow-pete
|
||||||
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Solve
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
||||||
|
Type a letter to guess it. A wrong solve costs a life, same as a wrong letter.
|
||||||
|
</p>
|
||||||
|
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Betting: shown between games. -->
|
||||||
|
<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="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">How long a phrase?</div>
|
||||||
|
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
||||||
|
{{range .Tiers}}
|
||||||
|
<button type="button" data-tier="{{.Slug}}"
|
||||||
|
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<span class="font-display text-lg font-bold">{{.Name}}</span>
|
||||||
|
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 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-start
|
||||||
|
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||||
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Play
|
||||||
|
</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>
|
||||||
|
|
||||||
|
</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/hangman.js" defer></script>
|
||||||
|
{{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}}
|
||||||
|
|||||||
168
internal/web/templates/solitaire.html
Normal file
168
internal/web/templates/solitaire.html
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
{{define "title"}}Solitaire · {{.Room.Name}}{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<div class="space-y-6" data-solitaire>
|
||||||
|
|
||||||
|
<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">Solitaire</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/50">You buy the deck. Every card you get home buys some of it back.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "_chipbar" .}}
|
||||||
|
|
||||||
|
<!-- The felt. The board takes the room; the money lives in a rail down the
|
||||||
|
right of it, where the house rack can't collide with the foundations. -->
|
||||||
|
<section class="pete-felt pete-solitaire relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
|
||||||
|
|
||||||
|
<!-- The board. -->
|
||||||
|
<div class="min-w-0 space-y-4 sm:space-y-6">
|
||||||
|
|
||||||
|
<!-- The stock and the waste on the left, the foundations on the right,
|
||||||
|
exactly where a real layout puts them. -->
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="flex items-start gap-2 sm:gap-3">
|
||||||
|
<button type="button" data-stock class="pete-slot pete-stock" aria-label="Turn over the stock">
|
||||||
|
<span data-stock-count class="pete-slot-count">0</span>
|
||||||
|
<span data-stock-recycle class="pete-slot-recycle hidden" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path><polyline points="21 3 21 8 16 8"></polyline>
|
||||||
|
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path><polyline points="3 21 3 16 8 16"></polyline>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div data-waste class="pete-waste" aria-label="The waste"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-foundations class="flex items-start gap-1.5 sm:gap-2" aria-label="The foundations"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The seven columns. -->
|
||||||
|
<div data-tableau class="pete-tableau" aria-label="The tableau"></div>
|
||||||
|
|
||||||
|
<!-- What just happened. -->
|
||||||
|
<div class="flex min-h-[2.75rem] items-center">
|
||||||
|
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The rail: the house's rack, what you've banked, and the meter that
|
||||||
|
reads it. Everything the money does happens between these two. -->
|
||||||
|
<aside class="pete-rail">
|
||||||
|
<div class="pete-rack" data-at="rail" 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>
|
||||||
|
|
||||||
|
<div class="pete-spot" data-spot>
|
||||||
|
<span class="pete-spot-label">Banked</span>
|
||||||
|
<div class="pete-stack" data-stack></div>
|
||||||
|
<span data-spot-total class="pete-spot-total hidden"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pete-meter w-full justify-center" data-meter>
|
||||||
|
<span class="pete-meter-label">Home</span>
|
||||||
|
<span data-home class="pete-meter-value">0<span class="text-white/40">/{{.FullDeck}}</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-xs leading-relaxed text-white/55">
|
||||||
|
<span data-per-card class="font-bold text-white/85">—</span> a card<br>
|
||||||
|
<span data-break-even></span>
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Playing: shown while a board is live. -->
|
||||||
|
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="button" data-auto
|
||||||
|
class="rounded-full bg-[color:var(--ink)]/5 px-5 py-2.5 font-display font-bold border-2 border-[color:var(--ink)]/10
|
||||||
|
hover:bg-[color:var(--ink)]/10 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Send home ⤴
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-xs text-[color:var(--ink)]/45">
|
||||||
|
Click a card, then where it goes. Double-click sends it home.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button type="button" data-cash
|
||||||
|
class="ml-auto rounded-full bg-[color:var(--accent)] px-6 py-2.5 font-display font-bold text-white shadow-pete
|
||||||
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Cash the board · <span data-cash-amount class="tabular-nums">0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Buying a deck: shown between games. -->
|
||||||
|
<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="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Which deal?</div>
|
||||||
|
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
||||||
|
{{range .Deals}}
|
||||||
|
<button type="button" data-tier="{{.Slug}}"
|
||||||
|
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<span class="font-display text-lg font-bold">{{.Name}}</span>
|
||||||
|
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
|
||||||
|
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
|
||||||
|
Square with the house at {{.BreakEven}} cards home.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 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">The deck costs</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="Put {{.}} more down"
|
||||||
|
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
|
||||||
|
<span>{{.}}</span>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
<button type="button" data-bet-clear
|
||||||
|
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" data-start
|
||||||
|
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||||
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Buy the deck
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-xs text-[color:var(--ink)]/40">
|
||||||
|
The stake buys the deck outright, so it doesn't come back. What comes back is
|
||||||
|
whatever you get home, a fifty-second of the multiple at a time. Stop whenever
|
||||||
|
you like and keep it. There's no undo.
|
||||||
|
</p>
|
||||||
|
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script src="/static/js/casino-fx.js" defer></script>
|
||||||
|
<script src="/static/js/casino-cards.js" defer></script>
|
||||||
|
<script src="/static/js/games.js" defer></script>
|
||||||
|
<script src="/static/js/solitaire.js" defer></script>
|
||||||
|
{{end}}
|
||||||
149
internal/web/templates/trivia.html
Normal file
149
internal/web/templates/trivia.html
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
{{define "title"}}Trivia · {{.Room.Name}}{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<div class="space-y-6" data-trivia>
|
||||||
|
|
||||||
|
<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">Trivia</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/50">{{.Rungs}} questions · answer fast, or don't bother</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "_chipbar" .}}
|
||||||
|
|
||||||
|
<!-- The felt. The clock is the biggest thing on it, because the clock is the
|
||||||
|
game: a right answer is worth what it's worth *when you give it*. -->
|
||||||
|
<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 meter and the ladder. This row is the only one level with the house
|
||||||
|
rack in the corner, so it is the only one that has to keep clear of it. -->
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-3 pr-32 sm:pr-28">
|
||||||
|
<div class="pete-meter" data-meter>
|
||||||
|
<span class="pete-meter-label">Worth</span>
|
||||||
|
<span data-multiple class="pete-meter-value">—</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-white/60">
|
||||||
|
<span data-stands class="font-bold tabular-nums text-white/90">—</span>
|
||||||
|
<span data-stands-label>if you walk</span>
|
||||||
|
</p>
|
||||||
|
<div class="pete-ladder ml-auto" data-ladder aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The question. -->
|
||||||
|
<div class="mt-7 min-h-[16rem]" data-round>
|
||||||
|
|
||||||
|
<div class="pete-clock" data-clock>
|
||||||
|
<div class="pete-clock-fill" data-clock-fill></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<p data-category class="text-xs font-bold uppercase tracking-wider text-white/40"></p>
|
||||||
|
<p data-countdown class="font-display text-lg font-bold tabular-nums text-white/70"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 data-question class="mt-1 font-display text-xl font-bold leading-snug text-white sm:text-2xl" aria-live="polite"></h2>
|
||||||
|
|
||||||
|
<div data-answers class="mt-5 grid gap-2.5 sm:grid-cols-2"></div>
|
||||||
|
|
||||||
|
<div class="mt-5 flex min-h-[2.75rem] items-center">
|
||||||
|
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The stake, on the same spot every other table puts it. -->
|
||||||
|
<div class="mt-2 flex items-center gap-4">
|
||||||
|
<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>
|
||||||
|
<p data-rung class="text-xs font-bold uppercase tracking-wider text-white/50"></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Playing: shown while a ladder is live. -->
|
||||||
|
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/50">
|
||||||
|
Press <span class="font-bold">1</span>–<span class="font-bold">4</span>, or click one. A wrong answer, or the clock, loses the lot.
|
||||||
|
</p>
|
||||||
|
<button type="button" data-walk
|
||||||
|
class="ml-auto rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||||
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Take the money · <span data-walk-amount>0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Betting: shown between games. -->
|
||||||
|
<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="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">How hard?</div>
|
||||||
|
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
||||||
|
{{range .Quizzes}}
|
||||||
|
<button type="button" data-tier="{{.Slug}}"
|
||||||
|
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<span class="font-display text-lg font-bold">{{.Name}}</span>
|
||||||
|
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.2f" .Fast}}×</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
|
||||||
|
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
|
||||||
|
{{.Limit}}s a question · slowest answer still pays {{printf "%.2f" .Buzzer}}×
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 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-start
|
||||||
|
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||||
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Play
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
||||||
|
The first question is the price of sitting down: you can only walk once you've answered one.
|
||||||
|
</p>
|
||||||
|
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</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/trivia.js" defer></script>
|
||||||
|
{{end}}
|
||||||
126
internal/web/trivia_bank.go
Normal file
126
internal/web/trivia_bank.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/games/trivia"
|
||||||
|
"pete/internal/opentdb"
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keeping the trivia bank stocked.
|
||||||
|
//
|
||||||
|
// The bank is not consumed by play — a question drawn is still there afterwards
|
||||||
|
// — so this loop is about *variety*, not supply. It fills each difficulty up to
|
||||||
|
// a target and then has nothing to do, which is why a pass that finds the bank
|
||||||
|
// full costs three COUNT queries and no network at all.
|
||||||
|
|
||||||
|
// bankTarget is how many questions of each difficulty we want to hold. Twelve
|
||||||
|
// rungs drawn from four hundred is enough that a regular player doesn't start
|
||||||
|
// recognising them, and it's a size OpenTDB's pool can actually fill.
|
||||||
|
const bankTarget = 400
|
||||||
|
|
||||||
|
// bankMaxFetches bounds one pass. At OpenTDB's politeness interval this is a
|
||||||
|
// couple of minutes of drip, after which the loop goes back to sleep rather than
|
||||||
|
// hammering a free API for an hour to top up the last few questions.
|
||||||
|
const bankMaxFetches = 12
|
||||||
|
|
||||||
|
// bankInterval is how often we go back and look. The bank is a slow-moving
|
||||||
|
// thing: it only grows, and it only needs to grow once.
|
||||||
|
const bankInterval = 12 * time.Hour
|
||||||
|
|
||||||
|
// StartTriviaBank launches the refill loop if the casino is on. Safe to call
|
||||||
|
// unconditionally; a no-op when games are off.
|
||||||
|
func (s *Server) StartTriviaBank(ctx context.Context) {
|
||||||
|
if !s.gamesReady() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go s.runTriviaBank(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runTriviaBank(ctx context.Context) {
|
||||||
|
slog.Info("games: trivia bank refill started", "target", bankTarget, "interval", bankInterval)
|
||||||
|
|
||||||
|
s.refillTriviaBank(ctx)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(bankInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.refillTriviaBank(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refillTriviaBank tops each difficulty up toward the target, politely.
|
||||||
|
//
|
||||||
|
// Every failure here is survivable and none of them stop the loop: OpenTDB is a
|
||||||
|
// free API that is sometimes down, and a thin bank costs a player nothing worse
|
||||||
|
// than a "give it a minute" when they try to start a ladder.
|
||||||
|
func (s *Server) refillTriviaBank(ctx context.Context) {
|
||||||
|
client := opentdb.New()
|
||||||
|
fetches := 0
|
||||||
|
|
||||||
|
for _, t := range trivia.Tiers {
|
||||||
|
for fetches < bankMaxFetches {
|
||||||
|
have, err := storage.CountTrivia(t.Difficulty)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: trivia bank count", "difficulty", t.Difficulty, "err", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if have >= bankTarget {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
qs, err := client.Fetch(ctx, t.Difficulty, opentdb.Batch)
|
||||||
|
fetches++
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Warn("games: trivia bank fetch", "difficulty", t.Difficulty, "err", err)
|
||||||
|
// Whatever went wrong, waiting is the only sensible response: the
|
||||||
|
// likeliest cause is the rate limit, and retrying at once earns another.
|
||||||
|
if !sleepCtx(ctx, opentdb.Politeness) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
added, err := storage.AddTriviaQuestions(t.Difficulty, qs)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: trivia bank store", "difficulty", t.Difficulty, "err", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
slog.Info("games: trivia bank filled",
|
||||||
|
"difficulty", t.Difficulty, "fetched", len(qs), "new", added, "have", have+added)
|
||||||
|
|
||||||
|
// The API hands back random batches, so once the bank is deep the
|
||||||
|
// overlap gets heavy and a batch adds almost nothing new. When it adds
|
||||||
|
// nothing at all, this difficulty has given us what it has: stop asking.
|
||||||
|
if added == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !sleepCtx(ctx, opentdb.Politeness) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleepCtx waits, unless we're being shut down. Reports false if we are.
|
||||||
|
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
||||||
|
t := time.NewTimer(d)
|
||||||
|
defer t.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
case <-t.C:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
main.go
14
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,
|
||||||
@@ -294,6 +299,7 @@ func main() {
|
|||||||
go ws.Start(ctx)
|
go ws.Start(ctx)
|
||||||
ws.StartPushSender(ctx)
|
ws.StartPushSender(ctx)
|
||||||
ws.StartAdventureDigest(ctx)
|
ws.StartAdventureDigest(ctx)
|
||||||
|
ws.StartTriviaBank(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
717
pete_games_plan.md
Normal file
717
pete_games_plan.md
Normal file
@@ -0,0 +1,717 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
- **Deployed, 2026-07-14.** https://games.parodia.dev is live. What that took, since
|
||||||
|
the shape of it was not quite what this plan guessed:
|
||||||
|
- The edge is **Traefik**, not Caddy (`/mash/traefik/config/provider.yml`, root-owned,
|
||||||
|
file provider so it hot-reloads). The casino needed no router of its own — the
|
||||||
|
existing `pete` router's rule grew a second host:
|
||||||
|
``Host(`news.parodia.dev`) || Host(`games.parodia.dev`)``, and ACME issued the cert
|
||||||
|
on its own. DNS for the games host already pointed at the box.
|
||||||
|
- Authentik lives at **auth.parodia.dev**, and the app's OAuth2 provider is "Pete News"
|
||||||
|
(pk 12). It now holds both callbacks, strict: news and games. The provider's
|
||||||
|
`redirect_uris` is a list of objects, not strings.
|
||||||
|
- Server config gained `[web.games]` (enabled, host, `matrix_server = "parodia.dev"`)
|
||||||
|
and `web.auth.cookie_domain = ".parodia.dev"`, which is what makes a news sign-in a
|
||||||
|
games sign-in. Old host-only session cookies don't carry over — a signed-in user
|
||||||
|
signs in once more, and after that the session spans both.
|
||||||
|
- **gogobee is not on that box.** It runs on the LAN at `reala@192.168.1.212`, in a
|
||||||
|
screen session, out of `~/gogobee`, and it has no key for its own GitHub remote —
|
||||||
|
deploy it with `ssh -A` so the pull rides your agent. Its escrow loop needs no new
|
||||||
|
config: it is gated on the `FEATURE_PETE_NEWS` / `PETE_INGEST_*` env in `~/.env`
|
||||||
|
that adventure news already set. Restarted, it logs
|
||||||
|
`pete games: escrow loop started interval=3s`.
|
||||||
|
|
||||||
|
- **Hangman, and it plays for chips.** *(2026-07-14. This revises §7's "Phase 2 —
|
||||||
|
no escrow": the decision was that a free game in a casino reads as a demo, so
|
||||||
|
hangman stakes chips like everything else and reuses the money path whole.)*
|
||||||
|
- **The gallows is the payout meter.** You pick a tier, stake, and get six
|
||||||
|
lives. Every wrong guess draws a limb *and* takes a tenth off the base
|
||||||
|
multiple — one event, shown as one event. Short phrases pay 2.6×, medium 2.0×,
|
||||||
|
long 1.6× (short is hardest: fewer letters, less to go on). Floored at 1×, so
|
||||||
|
a win never hands back less than the stake, and the rake still comes out of
|
||||||
|
winnings only.
|
||||||
|
- `internal/games/hangman` — the same pure reducer as blackjack, phrases
|
||||||
|
embedded (`phrases.txt`, 205 of them, video-game flavoured, lifted from
|
||||||
|
gogobee). `State.Pays()` is the number the felt quotes *and* the number
|
||||||
|
settle() lands on: they were briefly two sums and the table advertised a
|
||||||
|
pre-rake payout it didn't honour. One function now, and a test that walks a
|
||||||
|
game asserting the quote equals the payout at every step.
|
||||||
|
- **The browser never sees the phrase.** Cells carry the letter or an empty
|
||||||
|
string — not the letter with a hidden flag — and the phrase itself is only
|
||||||
|
added to the payload once the game is over and it decides nothing.
|
||||||
|
- Two things the storage layer already gave us for free, and one it didn't:
|
||||||
|
`game_live_hands` is keyed on the *player*, so "one game at a time" holds
|
||||||
|
across games with no new code (a live hangman 409s a blackjack deal). But
|
||||||
|
`table()` used to unmarshal any live row as a blackjack hand — which does not
|
||||||
|
*fail* on a hangman row, it just silently yields an empty hand. It now
|
||||||
|
dispatches on `live.Game`.
|
||||||
|
- `commit()` in games_play.go is the shared settle path (seat → pay → audit →
|
||||||
|
clear → touch). Both games go through it so neither re-derives an ordering
|
||||||
|
that took a while to get right. `casinoRoutes()` is likewise the single route
|
||||||
|
list, because devcasino_test.go has to wire its own mux and a second copy is
|
||||||
|
a copy that stops including the newest game.
|
||||||
|
- Driven in a real browser, win and loss: a 200 stake at 2.34× paid 455 and the
|
||||||
|
bar landed on it; six wrong took the stake and nothing more; a reload
|
||||||
|
mid-phrase brought back the board, the limbs, the multiple, the spent keys and
|
||||||
|
the stake on the spot. Two layout bugs only the browser could show: the lives
|
||||||
|
counter ran under the house rack, and the board wrapped a word early because
|
||||||
|
the rack's clearance padding was on the whole column instead of the one row
|
||||||
|
level with it.
|
||||||
|
|
||||||
|
- **Solitaire, and it plays for chips.** *(2026-07-14, jumping the queue ahead of
|
||||||
|
trivia because the user asked for it.)*
|
||||||
|
- **Vegas scoring**, which is the only way solitaire has ever actually been a
|
||||||
|
gambling game. You do not win or lose the deal — you **buy the deck** for your
|
||||||
|
stake, and every card you get home to a foundation pays a fifty-second of the
|
||||||
|
tier's multiple back. Cash the board whenever you like and keep what you've
|
||||||
|
banked; a board that has gone dead is therefore a decision, not a wall. There
|
||||||
|
is no undo, because the stake is spent the moment the deck is bought and an
|
||||||
|
undo would be a way to walk a losing board backwards until it wins.
|
||||||
|
- Three deals, and the two dials are the whole difficulty of Klondike: **Patient**
|
||||||
|
(draw 1, unlimited passes, 1.4×, square at 38 cards), **Vegas** (draw 3, three
|
||||||
|
passes, 2.2×, square at 24), **Cutthroat** (draw 3, one pass, 3.4×, square at
|
||||||
|
16). `Tier.BreakEven()` is what the felt quotes, because "2.2×" tells a player
|
||||||
|
nothing about a game where the multiple is paid a card at a time.
|
||||||
|
- `internal/games/klondike` — the same pure reducer. `Pays()` is one function for
|
||||||
|
the same reason hangman's is. Two fuzzers hold the deck together: no card is
|
||||||
|
ever lost or duplicated by any sequence of moves, and the board stays
|
||||||
|
well-formed (every face-up run is a run, no column has cards face-down under
|
||||||
|
nothing). The first thing a test caught was a **recycle that reversed the
|
||||||
|
waste** — it flips as a block, so the card drawn first comes out first, and
|
||||||
|
reversing would have dealt a different game on every pass and broken the seed.
|
||||||
|
- **The browser never sees the stock or a face-down card.** Bigger than
|
||||||
|
blackjack's hole card: that's most of the deck. Columns send a face-down
|
||||||
|
*count*, never the cards. The events, unlike blackjack's, need no filtering —
|
||||||
|
every card they carry is one the move just turned face up.
|
||||||
|
- **The table re-renders and animates the difference (FLIP).** Blackjack plays
|
||||||
|
back a script because a hand only grows at one end; solitaire moves runs from
|
||||||
|
anywhere to anywhere and an auto-finish moves eleven cards at once. So
|
||||||
|
`solitaire.js` measures where every card is, re-renders the board the server
|
||||||
|
sent, and plays each card from its old place to its new one. The board on
|
||||||
|
screen is therefore always exactly the board the server says exists. The events
|
||||||
|
supply only what a diff can't: where a *newly revealed* card came from (the
|
||||||
|
stock, or a flip in place) and what the board is now worth.
|
||||||
|
- **The rules are mirrored in JS**, deliberately, and only to light up the columns
|
||||||
|
a held card can go to. The server still decides every move; a disagreement
|
||||||
|
snaps the board back to whatever it says. Being shown where a card goes is the
|
||||||
|
game teaching you; being told no after you commit is the game scolding you.
|
||||||
|
- Two things got extracted rather than copied, which is the rule this room runs
|
||||||
|
on: **`casino-cards.js`** (the deck — faces, pips, the flip; was inside
|
||||||
|
blackjack.js) and **`PeteFX.spot()`** (the pile of chips and the number under
|
||||||
|
it, which owns the "the number is a readout of the pile" rule so no table can
|
||||||
|
break it). Blackjack now uses both.
|
||||||
|
- **Driven in a browser, 2026-07-14, and it holds up.** Every worry on the list
|
||||||
|
came back clean. A Patient deck bought for 200 dealt a correct Klondike (28 cards
|
||||||
|
across the seven columns, 24 left in the stock), quoted `+5.4 a card` and `38 more
|
||||||
|
to break even` — which is the tier's arithmetic, not a guess — and the money
|
||||||
|
conserved end to end: 5,000 → 4,800 to buy the deck → one card home banked 5 →
|
||||||
|
4,805 cashed out. The FLIP does not jump on a re-render. The seven columns fit at
|
||||||
|
390px with no horizontal overflow (`docScrollW == clientW`), the rail stacks under
|
||||||
|
the board rather than colliding with it, and the console is silent.
|
||||||
|
- **And blackjack survived the rewire**, which was the real thing to check. Five
|
||||||
|
hands, and the felt agreed with `/api/games/table` on every one. The rake still
|
||||||
|
comes out of winnings only: a 400 win paid back 780 (the stake, plus 400 less 5%),
|
||||||
|
and a push returned all 600 with nothing taken.
|
||||||
|
- One thing to know before you go looking for a bug that isn't there: the bare
|
||||||
|
`<span data-chip>` elements are the *house rack's decoration*. Only
|
||||||
|
`button[data-chip]` carries a listener. A driver script that clicks `[data-chip]`
|
||||||
|
hits the rack, nothing happens, and it looks like the bet is broken. Blackjack's
|
||||||
|
action buttons are also `[data-move="stand"]`, not `[data-stand]`.
|
||||||
|
|
||||||
|
- **Trivia, and it plays for chips.** *(2026-07-14. Built, and now **played** — see
|
||||||
|
"Driven in a browser" at the bottom of this entry, which is where the two bugs
|
||||||
|
were.)*
|
||||||
|
- **A ladder.** Stake once, then answer a run of twelve. Every right answer
|
||||||
|
multiplies what you're holding, a wrong one loses the lot, and you may walk
|
||||||
|
with what you've built. Clearing all twelve ends the run and banks it — a
|
||||||
|
ladder with no top is a slot machine you can't stop playing, and eventually
|
||||||
|
every player loses everything to one bad question.
|
||||||
|
- **The clock is the game, and it is the anti-google mechanism.** Trivia answers
|
||||||
|
are lookupable, so a right answer is worth what it's worth *when you give it*:
|
||||||
|
the multiple decays from Fast to Buzzer across the tier's limit (easy 1.30→1.10
|
||||||
|
over 20s, medium 1.55→1.20 over 18s, hard 1.90→1.30 over 15s), and running out
|
||||||
|
of time loses exactly as much as being wrong. A timeout that merely cost you the
|
||||||
|
speed bonus would make "look it up in the other tab" the strongest way to play.
|
||||||
|
The countdown in the browser is decoration; the clock that scores is
|
||||||
|
`time.Now()` against the `AskedAt` the server stamped. A reload does not restart
|
||||||
|
it.
|
||||||
|
- **A pure reducer still, but the time is an argument** — `ApplyMove(state, move,
|
||||||
|
now)`. A reducer cannot own a timer, so it doesn't: the only thing that knows
|
||||||
|
what o'clock it is remains the caller, and the engine stays value-in, value-out.
|
||||||
|
- **You cannot walk off the first rung** (`ErrNothingBanked`). If you could, seeing
|
||||||
|
question one and walking would be a free look: stake, peek, walk, restake, and
|
||||||
|
reshuffle until the question is one you happen to know. The first question is the
|
||||||
|
price of sitting down.
|
||||||
|
- **The browser never learns which answer is right.** The four answers cross the
|
||||||
|
wire without the index; that index is in the engine state, on the server. It
|
||||||
|
comes back only in the event that *decides* the question, by which point knowing
|
||||||
|
it is worth nothing. The ladder's remaining questions are never sent at all.
|
||||||
|
- `internal/games/trivia` — engine, 11 tests. The one that matters most is the
|
||||||
|
same one hangman needed: the number the felt quotes (`Pays()`) is asserted equal
|
||||||
|
to the number `settle()` lands on, at every rung.
|
||||||
|
- **The bank is prefetched, not fetched per question** (`internal/opentdb`,
|
||||||
|
`storage.DrawTrivia`, table `trivia_questions`). A ladder asks a question every
|
||||||
|
fifteen seconds with money on a clock the player is scored against; a live fetch
|
||||||
|
would put OpenTDB's latency and rate limit *inside* that clock. The refill is a
|
||||||
|
slow background drip (`StartTriviaBank`, 400 per difficulty, one request per six
|
||||||
|
seconds, stops early when a batch adds nothing new), and a round never waits on
|
||||||
|
it. Answers are shuffled per-game against the game's own seed, so where the right
|
||||||
|
answer sits in the table tells a player nothing.
|
||||||
|
- **The dev rig seeds its own bank.** A fresh dev database has an empty bank and
|
||||||
|
every start 503s, so `TestDevCasino` now takes one real batch per difficulty
|
||||||
|
from OpenTDB (`seedTriviaBank`) — fifty questions each, four ladders' worth,
|
||||||
|
through the same fetch-decode-store path production uses. It does *not* run
|
||||||
|
`StartTriviaBank`: a rig that spends its first two minutes dripping four hundred
|
||||||
|
questions per difficulty out of a free API is a rig you cannot use.
|
||||||
|
- **Driven in a browser, 2026-07-14, and the clock and the money hold up.** The
|
||||||
|
ladder plays: a 200 stake on Easy dealt a real OpenTDB question with its
|
||||||
|
entities decoded, the clock bar drained honestly (847px → 711px over three
|
||||||
|
seconds, countdown 18.7s → 15.7s), two right answers compounded 1.00× → 1.26×
|
||||||
|
→ 1.58×, and walking paid exactly the 311 the felt had been quoting. The
|
||||||
|
reveal marks the wrong pick red and the right answer green. A reload mid-rung
|
||||||
|
brought the board back and — the thing that matters — the server's clock kept
|
||||||
|
running through it (17.5s left before, 16.2s after; it does not restart).
|
||||||
|
**The timeout lands as a timeout**, which was the loudest worry: going quiet
|
||||||
|
through a 20s question fired the auto-submit at zero, came back 200 with a
|
||||||
|
`timeout` event and "Out of time.", not a "that move isn't legal". The next
|
||||||
|
question's answer is never sent (`correct: -1` in the ask event); only the
|
||||||
|
decided one reveals.
|
||||||
|
- **Two bugs, and only a browser could have found either.**
|
||||||
|
1. **The spot printed double the stake after every settled game.** `standing()`
|
||||||
|
set `spot.amount` and *then* poured the chips on, and `pour` grows the pile
|
||||||
|
from whatever it is told is already there — so a 200 stake settled to a spot
|
||||||
|
reading 400, and a 400 one to 800. This is exactly the rule the felt is built
|
||||||
|
on ("the number under the pile is a readout of the pile") failing quietly:
|
||||||
|
the money was always right, the *number under the chips* was not. Blackjack
|
||||||
|
and hangman pour without pre-setting; trivia now does too.
|
||||||
|
2. **The house rack sat on top of the multiplier at 390px.** The rack is a 147px
|
||||||
|
block inset 5.75rem from the edge, and that inset is not a margin — it is the
|
||||||
|
width of *blackjack's shoe*, which the rack sits beside. On a phone that puts
|
||||||
|
it in the middle of the felt, on top of trivia's "1.53×". On small screens the
|
||||||
|
rack now shrinks and, where there is nothing in the corner, pulls into the
|
||||||
|
corner. Which rack is which is what `data-at` says: unmarked is alone in the
|
||||||
|
corner, `shoe` is blackjack (pull that one to the edge and it slides under the
|
||||||
|
deck — this was caught after doing exactly that), `rail` is solitaire, whose
|
||||||
|
rack isn't on the felt at all. All four tables re-checked at 390px and 1280px,
|
||||||
|
live games on the felt: no overlap with text, no overlap with the shoe, no
|
||||||
|
horizontal overflow, desktop geometry unchanged.
|
||||||
|
|
||||||
|
### Next, in order
|
||||||
|
|
||||||
|
1. Phase 3 (UNO), Phase 4 (hold'em) as below.
|
||||||
|
2. Trivia is played but not deployed. Hangman, solitaire and trivia are all still
|
||||||
|
sitting on main un-deployed — the server runs `StartTriviaBank`, so its bank
|
||||||
|
fills itself once the binary is out there, but the first player to try a ladder
|
||||||
|
in the first minute after a deploy gets the 503.
|
||||||
|
|
||||||
|
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