Compare commits
6 Commits
d29a311eff
...
3e9b93af55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e9b93af55 | ||
|
|
2d653bf439 | ||
|
|
c62d736223 | ||
|
|
feb353f789 | ||
|
|
5ca056bf20 | ||
|
|
fe2195e85f |
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
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))
|
||||||
|
}
|
||||||
@@ -242,6 +242,31 @@ CREATE TABLE IF NOT EXISTS game_live_hands (
|
|||||||
updated_at 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
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -9,6 +10,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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
|
// TestDevCasino is not a test. It is the casino, running, on a port, with one
|
||||||
@@ -30,6 +35,7 @@ func TestDevCasino(t *testing.T) {
|
|||||||
|
|
||||||
s := newCasino(t)
|
s := newCasino(t)
|
||||||
fund(t, 5000)
|
fund(t, 5000)
|
||||||
|
seedTriviaBank(t)
|
||||||
|
|
||||||
payload, _ := json.Marshal(SessionUser{
|
payload, _ := json.Marshal(SessionUser{
|
||||||
Sub: "sub-1", Username: "reala", Name: "Reala",
|
Sub: "sub-1", Username: "reala", Name: "Reala",
|
||||||
@@ -44,13 +50,7 @@ func TestDevCasino(t *testing.T) {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
||||||
mux.HandleFunc("GET /games", s.handleLobby)
|
s.casinoRoutes(mux)
|
||||||
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
|
|
||||||
mux.HandleFunc("GET /api/games/table", s.handleTable)
|
|
||||||
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
|
|
||||||
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
|
|
||||||
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
|
|
||||||
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
|
|
||||||
|
|
||||||
ln, err := net.Listen("tcp", addr)
|
ln, err := net.Listen("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,9 +63,49 @@ func TestDevCasino(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("\nCASINO http://localhost%s/games/blackjack\nCOOKIE %s=%s\n\n", addr, sessionCookie, cookie)
|
fmt.Printf("\nCASINO http://localhost%s/games\nCOOKIE %s=%s\n\n", addr, sessionCookie, cookie)
|
||||||
|
|
||||||
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||||
t.Cleanup(func() { _ = srv.Close() })
|
t.Cleanup(func() { _ = srv.Close() })
|
||||||
_ = srv.Serve(ln)
|
_ = srv.Serve(ln)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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]
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"pete/internal/games/blackjack"
|
"pete/internal/games/blackjack"
|
||||||
|
"pete/internal/games/hangman"
|
||||||
|
"pete/internal/games/klondike"
|
||||||
|
"pete/internal/games/trivia"
|
||||||
"pete/internal/storage"
|
"pete/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,8 +30,6 @@ type gameTeaser struct {
|
|||||||
var comingSoon = []gameTeaser{
|
var comingSoon = []gameTeaser{
|
||||||
{Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
|
{Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
|
||||||
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
|
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
|
||||||
{Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."},
|
|
||||||
{Name: "Hangman", Emoji: "🪢", Blurb: "Guess the phrase before the gallows finish."},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// betDenominations are the chips you build a bet out of.
|
// betDenominations are the chips you build a bet out of.
|
||||||
@@ -70,6 +71,42 @@ type gamesPage struct {
|
|||||||
RakePct int
|
RakePct int
|
||||||
Soon []gameTeaser
|
Soon []gameTeaser
|
||||||
Denominations []int64
|
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.
|
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
|
||||||
@@ -96,6 +133,12 @@ func (s *Server) gamesPage(r *http.Request) gamesPage {
|
|||||||
RakePct: int(blackjack.DefaultRules().RakePct * 100),
|
RakePct: int(blackjack.DefaultRules().RakePct * 100),
|
||||||
Soon: comingSoon,
|
Soon: comingSoon,
|
||||||
Denominations: betDenominations,
|
Denominations: betDenominations,
|
||||||
|
Tiers: hangman.Tiers,
|
||||||
|
MaxWrong: hangman.MaxWrong,
|
||||||
|
Deals: klondike.Tiers,
|
||||||
|
FullDeck: klondike.FullDeck,
|
||||||
|
Quizzes: trivia.Tiers,
|
||||||
|
Rungs: trivia.Rungs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,3 +155,24 @@ func (s *Server) handleBlackjack(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.render(w, "blackjack", s.gamesPage(r))
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package web
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
@@ -11,6 +12,9 @@ import (
|
|||||||
|
|
||||||
"pete/internal/games/blackjack"
|
"pete/internal/games/blackjack"
|
||||||
"pete/internal/games/cards"
|
"pete/internal/games/cards"
|
||||||
|
"pete/internal/games/hangman"
|
||||||
|
"pete/internal/games/klondike"
|
||||||
|
"pete/internal/games/trivia"
|
||||||
"pete/internal/storage"
|
"pete/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,18 +166,37 @@ func viewEvents(evs []blackjack.Event, phase blackjack.Phase) []eventView {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// tableView is the whole page state: the money, and the hand if there is one.
|
// 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 {
|
type tableView struct {
|
||||||
Chips int64 `json:"chips"`
|
Chips int64 `json:"chips"`
|
||||||
Pending int64 `json:"pending"` // buy-ins gogobee hasn't answered yet
|
Pending int64 `json:"pending"` // buy-ins gogobee hasn't answered yet
|
||||||
Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale
|
Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale
|
||||||
Cap int64 `json:"cap"`
|
Cap int64 `json:"cap"`
|
||||||
Hand *handView `json:"hand,omitempty"`
|
|
||||||
Events []eventView `json:"events,omitempty"` // only on a move, for the animation
|
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"`
|
Rake float64 `json:"rake_pct"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// table reads the player's money and any hand in progress.
|
// table reads the player's money and any game in progress.
|
||||||
func (s *Server) table(user string) (tableView, error) {
|
func (s *Server) table(user string) (tableView, error) {
|
||||||
st, err := storage.Chips(user)
|
st, err := storage.Chips(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -193,16 +216,57 @@ func (s *Server) table(user string) (tableView, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return tableView{}, err
|
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
|
var hand blackjack.State
|
||||||
if err := json.Unmarshal(live.State, &hand); err != nil {
|
if err := json.Unmarshal(live.State, &hand); err != nil {
|
||||||
// A hand we can't read is a hand nobody can play. Rather than wedge the
|
return s.dropUnreadable(user, v, err)
|
||||||
// player out of the casino forever, drop it and tell them.
|
|
||||||
slog.Error("games: unreadable live hand, discarding", "user", user, "err", err)
|
|
||||||
_ = storage.ClearLiveHand(user)
|
|
||||||
return v, nil
|
|
||||||
}
|
}
|
||||||
hv := viewHand(hand)
|
hv := viewHand(hand)
|
||||||
v.Hand = &hv
|
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
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,62 +502,78 @@ func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.persist(w, user, next, evs, live.Seed1, live.Seed2, false)
|
s.persist(w, user, next, evs, live.Seed1, live.Seed2, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// persist writes the hand back and answers the browser. A finished hand pays
|
// The games a live row can be. They're the storage key, so they're constants:
|
||||||
// out, goes in the audit log, and leaves the felt; an unfinished one is saved
|
// a typo here is a game nobody can ever load again.
|
||||||
// as it stands, so a redeploy mid-hand is survivable.
|
const (
|
||||||
//
|
gameBlackjack = "blackjack"
|
||||||
// fresh marks a hand that has just been dealt, which is the one case where the
|
gameHangman = "hangman"
|
||||||
// write may be refused: the primary key, not an earlier read, is what enforces
|
gameSolitaire = "solitaire"
|
||||||
// one hand at a time. A Deal that loses that race gets its stake back.
|
gameTrivia = "trivia"
|
||||||
func (s *Server) persist(w http.ResponseWriter, user string, st blackjack.State, evs []blackjack.Event, seed1, seed2 uint64, fresh bool) {
|
)
|
||||||
blob, err := json.Marshal(st)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("games: marshal hand", "user", user, "err", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seat the hand before doing anything else with it — even one that is already
|
// finished is what commit needs to know about a game it's writing back: enough
|
||||||
// over, because a natural settles the instant it's dealt. The insert is what
|
// to settle it, and nothing about how it's played. Both engines produce one.
|
||||||
// enforces one hand at a time, and it has to happen for *every* new hand: a
|
type finished struct {
|
||||||
// natural dealt on top of a hand already in progress would otherwise settle,
|
Game string
|
||||||
// clear the felt, and take the other hand's stake down with it.
|
Blob []byte // the engine's whole state, shoe or phrase and all
|
||||||
hand := storage.LiveHand{Game: "blackjack", State: blob, Seed1: seed1, Seed2: seed2}
|
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
|
save := storage.SaveLiveHand
|
||||||
if fresh {
|
if f.Fresh {
|
||||||
save = storage.StartLiveHand
|
save = storage.StartLiveHand
|
||||||
}
|
}
|
||||||
if err := save(user, hand); err != nil {
|
if err := save(user, live); err != nil {
|
||||||
if errors.Is(err, storage.ErrHandInProgress) {
|
if errors.Is(err, storage.ErrHandInProgress) {
|
||||||
// Somebody was already sitting here. This hand was never seated, so the
|
// Somebody was already sitting here. This game was never seated, so the
|
||||||
// chips it staked go back: the player is in one hand, not two.
|
// chips it staked go back: the player is in one game, not two.
|
||||||
_ = storage.Award(user, st.Bet)
|
_ = storage.Award(user, f.Bet)
|
||||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a hand"})
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"})
|
||||||
return
|
return tableView{}, false
|
||||||
}
|
}
|
||||||
slog.Error("games: save hand", "user", user, "err", err)
|
slog.Error("games: save game", "user", user, "game", f.Game, "err", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return tableView{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
if st.Phase == blackjack.PhaseDone {
|
if f.Done {
|
||||||
// Pay first, then clear. If Pete dies between the two, the player has been
|
// Pay first, then clear. If Pete dies between the two, the player has been
|
||||||
// paid and the worst case is a settled hand still showing on the felt —
|
// 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.
|
// which reads as done and can be cleared. The other order loses them a win.
|
||||||
if err := storage.Award(user, st.Payout); err != nil {
|
if err := storage.Award(user, f.Payout); err != nil {
|
||||||
slog.Error("games: award", "user", user, "payout", st.Payout, "err", err)
|
slog.Error("games: award", "user", user, "payout", f.Payout, "err", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return tableView{}, false
|
||||||
}
|
}
|
||||||
if err := storage.RecordHand(storage.Hand{
|
if err := storage.RecordHand(storage.Hand{
|
||||||
MatrixUser: user, Game: "blackjack",
|
MatrixUser: user, Game: f.Game,
|
||||||
Bet: st.Bet, Payout: st.Payout, Rake: st.Rake,
|
Bet: f.Bet, Payout: f.Payout, Rake: f.Rake,
|
||||||
Outcome: string(st.Outcome), Seed1: seed1, Seed2: seed2,
|
Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's hand
|
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 {
|
if err := storage.ClearLiveHand(user); err != nil {
|
||||||
slog.Error("games: clear hand", "user", user, "err", err)
|
slog.Error("games: clear game", "user", user, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,11 +583,32 @@ func (s *Server) persist(w http.ResponseWriter, user string, st blackjack.State,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("games: table", "user", user, "err", err)
|
slog.Error("games: table", "user", user, "err", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
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
|
return
|
||||||
}
|
}
|
||||||
// A settled hand is gone from storage, so the table view has no hand to show —
|
// 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.
|
// but the browser still needs the final cards to animate the reveal onto.
|
||||||
if st.Phase == blackjack.PhaseDone {
|
if done {
|
||||||
hv := viewHand(st)
|
hv := viewHand(st)
|
||||||
v.Hand = &hv
|
v.Hand = &hv
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
pages []string
|
pages []string
|
||||||
}{
|
}{
|
||||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
|
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
|
||||||
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack"}},
|
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia"}},
|
||||||
}
|
}
|
||||||
tpls := make(map[string]*template.Template)
|
tpls := make(map[string]*template.Template)
|
||||||
for _, set := range sets {
|
for _, set := range sets {
|
||||||
@@ -232,13 +232,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
// auth block, and gamesReady() also insists on a Matrix server name: without
|
// auth block, and gamesReady() also insists on a Matrix server name: without
|
||||||
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
||||||
if s.gamesReady() {
|
if s.gamesReady() {
|
||||||
mux.HandleFunc("GET /games", s.handleLobby)
|
s.casinoRoutes(mux)
|
||||||
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
|
|
||||||
mux.HandleFunc("GET /api/games/table", s.handleTable)
|
|
||||||
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
|
|
||||||
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
|
|
||||||
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
|
|
||||||
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.auth != nil {
|
if s.auth != nil {
|
||||||
|
|||||||
@@ -570,10 +570,13 @@ html[data-phase="night"] {
|
|||||||
|
|
||||||
/* One card. The wrapper does the flight, the inner face does the flip, so the
|
/* One card. The wrapper does the flight, the inner face does the flip, so the
|
||||||
two never fight over the same transform. */
|
two never fight over the same transform. */
|
||||||
|
/* The size is a variable because solitaire has seven columns to fit and
|
||||||
|
blackjack has two hands. Everything below is in terms of it, so a table can
|
||||||
|
shrink its cards without a second set of rules. */
|
||||||
.pete-card {
|
.pete-card {
|
||||||
perspective: 700px;
|
perspective: 700px;
|
||||||
height: 8.4rem;
|
height: var(--card-h, 8.4rem);
|
||||||
width: 6rem;
|
width: var(--card-w, 6rem);
|
||||||
animation: pete-deal 0.42s cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
animation: pete-deal 0.42s cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||||
}
|
}
|
||||||
.pete-card-inner {
|
.pete-card-inner {
|
||||||
@@ -857,6 +860,26 @@ html[data-phase="night"] {
|
|||||||
.pete-rack span[data-chip="100"] { --chip: #2b2118; }
|
.pete-rack span[data-chip="100"] { --chip: #2b2118; }
|
||||||
.pete-rack span[data-chip="500"] { --chip: #b079d6; }
|
.pete-rack span[data-chip="500"] { --chip: #b079d6; }
|
||||||
|
|
||||||
|
/* On a phone the rack has to get out of the way. At its desk size it is a
|
||||||
|
147px block sitting 92px in from the edge — a corner on a wide felt, but the
|
||||||
|
middle of the table on a 390px one, where it sat on top of trivia's
|
||||||
|
multiplier. So on a small screen it gets smaller, and where there's nothing
|
||||||
|
in the corner it pulls into the corner.
|
||||||
|
|
||||||
|
Where the rack sits is the one thing that differs per table, which is what
|
||||||
|
data-at says. Unmarked, it's alone in the corner. "shoe" means blackjack,
|
||||||
|
where the 5.75rem inset is not a margin but the width of the shoe it sits
|
||||||
|
beside — pull that one to the edge and it slides under the deck. "rail" means
|
||||||
|
solitaire, where the rack isn't on the felt at all (position: static). */
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.pete-rack:not([data-at="rail"]) {
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.35rem 0.4rem 0.3rem;
|
||||||
|
}
|
||||||
|
.pete-rack:not([data-at="rail"]) span { width: 1.15rem; }
|
||||||
|
.pete-rack:not([data-at]) { right: 1rem; }
|
||||||
|
}
|
||||||
|
|
||||||
/* The burst. A natural gets confetti; nothing else in the room does, which is
|
/* The burst. A natural gets confetti; nothing else in the room does, which is
|
||||||
what keeps it worth something. */
|
what keeps it worth something. */
|
||||||
.pete-spark {
|
.pete-spark {
|
||||||
@@ -877,6 +900,527 @@ html[data-phase="night"] {
|
|||||||
50% { opacity: 1; transform: translateY(-1px); }
|
50% { opacity: 1; transform: translateY(-1px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- hangman -------------------------------------------------------------
|
||||||
|
The gallows is the meter: it counts down your lives and your winnings at the
|
||||||
|
same time. So a miss has to *land* — the limb draws itself in along its own
|
||||||
|
length, the whole thing flinches, and the multiple falls. Three parts of one
|
||||||
|
event, and the game is about watching it happen to you. */
|
||||||
|
|
||||||
|
.pete-gallows {
|
||||||
|
overflow: visible;
|
||||||
|
fill: none;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.pete-gallows-frame path {
|
||||||
|
stroke: rgba(0, 0, 0, 0.35);
|
||||||
|
stroke-width: 6;
|
||||||
|
}
|
||||||
|
/* The rope. Same post, thinner, so it reads as something that would hold. */
|
||||||
|
.pete-gallows-frame path:last-child {
|
||||||
|
stroke: rgba(0, 0, 0, 0.3);
|
||||||
|
stroke-width: 3.5;
|
||||||
|
}
|
||||||
|
.pete-gallows-body > * {
|
||||||
|
stroke: #fff;
|
||||||
|
stroke-width: 5;
|
||||||
|
opacity: 0;
|
||||||
|
filter: drop-shadow(0 2px 0 rgba(0, 0, 0, 0.25));
|
||||||
|
}
|
||||||
|
.pete-gallows-body > [data-on="1"] { opacity: 1; }
|
||||||
|
|
||||||
|
/* Drawn, not faded in: the stroke runs on along its own path. 260 is longer
|
||||||
|
than any limb here, which is all a dash offset needs to be. */
|
||||||
|
.pete-part-draw {
|
||||||
|
stroke-dasharray: 260;
|
||||||
|
animation: pete-part 0.45s cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||||
|
}
|
||||||
|
@keyframes pete-part {
|
||||||
|
from { stroke-dashoffset: 260; }
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pete-shake { animation: pete-shake 0.42s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
|
||||||
|
@keyframes pete-shake {
|
||||||
|
10%, 90% { transform: translateX(-1.5px); }
|
||||||
|
20%, 80% { transform: translateX(3px); }
|
||||||
|
30%, 50%, 70% { transform: translateX(-5px); }
|
||||||
|
40%, 60% { transform: translateX(5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The phrase. Tiles wrap between words, never inside one. */
|
||||||
|
.pete-board {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem 1.1rem;
|
||||||
|
min-height: 5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.pete-word {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pete-tile {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 2.9rem;
|
||||||
|
width: 2.2rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
border-bottom: 3px solid rgba(0, 0, 0, 0.28);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
/* A letter you've earned sits proud of the ones you haven't. */
|
||||||
|
.pete-tile[data-up="1"] {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
color: #2b2118;
|
||||||
|
border-bottom-color: rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
/* Punctuation is scaffolding: it was never yours to guess, so it gets no tile
|
||||||
|
to guess it into. */
|
||||||
|
.pete-tile[data-punct="1"] {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0.7rem;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
.pete-tile-hit { animation: pete-tile-hit 0.34s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
||||||
|
@keyframes pete-tile-hit {
|
||||||
|
0% { transform: rotateX(90deg) scale(1.1); }
|
||||||
|
100% { transform: rotateX(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pete-missed {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 1.5rem;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What a win is worth, right now. */
|
||||||
|
.pete-meter {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.28);
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
.pete-meter-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
.pete-meter-value {
|
||||||
|
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
/* Down at the floor: a win now hands back the stake and nothing else. */
|
||||||
|
.pete-meter[data-cold="1"] .pete-meter-value { color: rgba(255, 255, 255, 0.55); }
|
||||||
|
.pete-meter[data-hit="1"] {
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(204, 61, 74, 0.9);
|
||||||
|
animation: pete-meter-hit 0.4s ease;
|
||||||
|
}
|
||||||
|
@keyframes pete-meter-hit {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
35% { transform: scale(0.94); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The keyboard. */
|
||||||
|
.pete-keys { display: grid; gap: 0.35rem; }
|
||||||
|
.pete-key-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.pete-key-row[data-digits="1"] { margin-top: 0.25rem; opacity: 0.75; }
|
||||||
|
.pete-key-row[data-digits="1"] .pete-key {
|
||||||
|
height: 2rem;
|
||||||
|
min-width: 1.8rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.pete-key {
|
||||||
|
height: 2.75rem;
|
||||||
|
min-width: 2.2rem;
|
||||||
|
flex: 0 1 2.4rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: color-mix(in srgb, var(--ink) 6%, transparent);
|
||||||
|
border: 2px solid color-mix(in srgb, var(--ink) 10%, transparent);
|
||||||
|
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
transition: transform 0.08s ease, background 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.pete-key:hover:not(:disabled) { background: color-mix(in srgb, var(--ink) 12%, transparent); }
|
||||||
|
.pete-key:active:not(:disabled) { transform: translateY(1px) scale(0.96); }
|
||||||
|
.pete-key:disabled { cursor: default; }
|
||||||
|
/* A key that's been spent looks spent — otherwise you spend it twice. */
|
||||||
|
.pete-key[data-state="hit"] {
|
||||||
|
background: #4caf7d;
|
||||||
|
border-color: #3d9367;
|
||||||
|
color: #fff;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.pete-key[data-state="miss"] {
|
||||||
|
background: color-mix(in srgb, var(--ink) 4%, transparent);
|
||||||
|
color: color-mix(in srgb, var(--ink) 30%, transparent);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.pete-key:disabled[data-state=""] { opacity: 0.4; }
|
||||||
|
|
||||||
|
/* Picking a length. The one you're on is lit. */
|
||||||
|
.pete-tier {
|
||||||
|
background: color-mix(in srgb, var(--ink) 4%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--ink) 10%, transparent);
|
||||||
|
}
|
||||||
|
.pete-tier:hover { background: color-mix(in srgb, var(--ink) 8%, transparent); }
|
||||||
|
.pete-tier[data-on="1"] {
|
||||||
|
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- trivia --------------------------------------------------------------
|
||||||
|
The clock is the game, so the clock is the biggest thing on the felt: a bar
|
||||||
|
that drains across the top of the question, going hot as it runs out. It is
|
||||||
|
*decoration* — the server timed the answer the moment it arrived — but it
|
||||||
|
has to be honest decoration, so it's driven from the seconds the server said
|
||||||
|
were left rather than from when the browser happened to paint. */
|
||||||
|
|
||||||
|
.pete-clock {
|
||||||
|
position: relative;
|
||||||
|
height: 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.pete-clock-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
transform-origin: left center;
|
||||||
|
/* Driven by a transform the browser can run on its own: a width animated on
|
||||||
|
every frame from JS is a layout on every frame. */
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
/* The last few seconds. The bar stops being a progress meter and starts being
|
||||||
|
a warning. */
|
||||||
|
.pete-clock[data-hot="1"] .pete-clock-fill { background: #cc3d4a; }
|
||||||
|
.pete-clock[data-hot="1"] { animation: pete-clock-pulse 0.7s ease-in-out infinite; }
|
||||||
|
@keyframes pete-clock-pulse {
|
||||||
|
0%, 100% { box-shadow: inset 0 0 0 2px rgba(204, 61, 74, 0.35); }
|
||||||
|
50% { box-shadow: inset 0 0 0 2px rgba(204, 61, 74, 0.95); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The four answers. Big targets, because the clock is already the hard part —
|
||||||
|
a question you lose to a mis-tap is a question about pointing. */
|
||||||
|
.pete-answer {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.26);
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||||
|
transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.pete-answer:hover:not(:disabled) {
|
||||||
|
background: rgba(0, 0, 0, 0.36);
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.28);
|
||||||
|
}
|
||||||
|
.pete-answer:active:not(:disabled) { transform: translateY(1px); }
|
||||||
|
.pete-answer:disabled { cursor: default; }
|
||||||
|
|
||||||
|
/* The letter down the side, so an answer can be picked with the keyboard and
|
||||||
|
the key you press is printed on the thing you're pressing it for. */
|
||||||
|
.pete-answer-key {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 1.75rem;
|
||||||
|
width: 1.75rem;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* How it went. Only ever set once the server has decided — the browser has no
|
||||||
|
idea which of these is right until it's told. */
|
||||||
|
.pete-answer[data-state="right"] {
|
||||||
|
background: rgba(46, 160, 103, 0.9);
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
.pete-answer[data-state="wrong"] {
|
||||||
|
background: rgba(204, 61, 74, 0.9);
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.35);
|
||||||
|
animation: pete-answer-no 0.45s ease;
|
||||||
|
}
|
||||||
|
/* The one they *should* have picked, shown alongside the one they did. */
|
||||||
|
.pete-answer[data-state="missed"] {
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(46, 160, 103, 0.95);
|
||||||
|
}
|
||||||
|
.pete-answer[data-state="dim"] { opacity: 0.4; }
|
||||||
|
@keyframes pete-answer-no {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
20% { transform: translateX(-6px); }
|
||||||
|
45% { transform: translateX(5px); }
|
||||||
|
70% { transform: translateX(-3px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The ladder: one pip per rung, filling as they're climbed. It is the only
|
||||||
|
picture of how far there is left to fall. */
|
||||||
|
.pete-ladder {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.pete-rung {
|
||||||
|
height: 0.5rem;
|
||||||
|
width: 1.1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
transition: background 0.3s ease, transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.pete-rung[data-on="1"] {
|
||||||
|
background: var(--accent);
|
||||||
|
transform: scaleY(1.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- solitaire -----------------------------------------------------------
|
||||||
|
Seven columns, four foundations, a stock and a waste, all of which have to
|
||||||
|
fit across the felt on a phone as well as a desk. So the card size is one
|
||||||
|
variable derived from the width available, and every gap and fan below is
|
||||||
|
derived from *that* — the board scales as one thing rather than as nine
|
||||||
|
things that each stop fitting at a different width.
|
||||||
|
|
||||||
|
The cards themselves don't move by CSS here. A move in solitaire can take a
|
||||||
|
card from anywhere to anywhere, so the table re-renders the board and then
|
||||||
|
animates each card from where it just was (see the FLIP note in
|
||||||
|
solitaire.js). CSS's job is only to say where things sit. */
|
||||||
|
|
||||||
|
.pete-solitaire {
|
||||||
|
--card-w: clamp(2.5rem, 10.6vw, 5.2rem);
|
||||||
|
--card-h: calc(var(--card-w) * 1.4);
|
||||||
|
--fan-up: calc(var(--card-h) * 0.29); /* how much of a face-up card shows under the next */
|
||||||
|
--fan-down: calc(var(--card-h) * 0.14); /* a face-down one shows less: there's nothing to read */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An empty place a card could be: the stock when it's spent, a foundation
|
||||||
|
waiting for its ace, a column waiting for a king. Same footprint as a card,
|
||||||
|
so nothing on the board reflows when one empties. */
|
||||||
|
.pete-slot {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: var(--card-h);
|
||||||
|
width: var(--card-w);
|
||||||
|
flex: none;
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
border: 2px dashed rgba(255, 255, 255, 0.25);
|
||||||
|
background: rgba(0, 0, 0, 0.12);
|
||||||
|
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||||
|
transition: border-color 0.18s ease, background 0.18s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
.pete-slot-glyph {
|
||||||
|
font-size: calc(var(--card-w) * 0.42);
|
||||||
|
line-height: 1;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
.pete-slot-glyph[data-red="1"] { color: rgba(255, 170, 170, 0.35); }
|
||||||
|
|
||||||
|
/* A card sitting *in* a slot — the top of a foundation. The slot keeps its
|
||||||
|
footprint whether or not there's a card on it, so an ace going home doesn't
|
||||||
|
reflow the row, and a drop target has somewhere to be while it's empty. */
|
||||||
|
.pete-slot > .pete-card {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The stock is a button, and it looks like the back of a card because that is
|
||||||
|
what it is: a pile you can turn over. */
|
||||||
|
.pete-stock {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: rgba(0, 0, 0, 0.15);
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, rgba(255,255,255,0.10) 0 6px, transparent 6px 12px),
|
||||||
|
linear-gradient(150deg, #b4553f, #8d3f2f);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pete-stock:hover { filter: brightness(1.08); }
|
||||||
|
.pete-stock:active { transform: translateY(1px) scale(0.98); }
|
||||||
|
/* Spent, but there's a pass left: it stops being a pile of cards and starts
|
||||||
|
being the gesture of turning the waste back over. */
|
||||||
|
.pete-stock[data-empty="1"] {
|
||||||
|
background: rgba(0, 0, 0, 0.12);
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
/* Spent, and no passes left. Not a button any more — and it says so by looking
|
||||||
|
like the dead thing it is rather than by silently refusing clicks. */
|
||||||
|
.pete-stock[data-dead="1"] {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.pete-stock[data-dead="1"]:hover { filter: none; }
|
||||||
|
.pete-stock[data-dead="1"]:active { transform: none; }
|
||||||
|
|
||||||
|
.pete-slot-count {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -0.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 0.05rem 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.pete-slot-recycle { color: rgba(255, 255, 255, 0.45); }
|
||||||
|
.pete-slot-recycle svg { height: 1.4rem; width: 1.4rem; }
|
||||||
|
|
||||||
|
/* The waste. Three cards fanned sideways, the top one on the right — you can
|
||||||
|
see what's under it, and only the top one is yours to take. */
|
||||||
|
.pete-waste {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
height: var(--card-h);
|
||||||
|
min-width: var(--card-w);
|
||||||
|
}
|
||||||
|
.pete-waste .pete-card + .pete-card {
|
||||||
|
margin-left: calc(var(--card-w) * -0.72);
|
||||||
|
}
|
||||||
|
.pete-waste .pete-card { position: relative; }
|
||||||
|
|
||||||
|
/* The seven columns. They share the width evenly and never scroll: a board you
|
||||||
|
have to scroll sideways is a board you can't plan on. */
|
||||||
|
.pete-tableau {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, var(--card-w));
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: start;
|
||||||
|
min-height: calc(var(--card-h) * 2.6);
|
||||||
|
}
|
||||||
|
.pete-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--card-h);
|
||||||
|
}
|
||||||
|
/* Cards overlap up the column, and how much of one shows depends on whether
|
||||||
|
there's anything to see. The rule keys off the card *above*, which is why
|
||||||
|
it's an adjacent-sibling selector and not something the JS has to compute. */
|
||||||
|
.pete-col .pete-card { position: relative; }
|
||||||
|
.pete-col .pete-card[data-face="down"] + .pete-card {
|
||||||
|
margin-top: calc(var(--fan-down) - var(--card-h));
|
||||||
|
}
|
||||||
|
.pete-col .pete-card[data-face="up"] + .pete-card {
|
||||||
|
margin-top: calc(var(--fan-up) - var(--card-h));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A card you can pick up says so. */
|
||||||
|
.pete-card[data-live="1"] { cursor: pointer; }
|
||||||
|
.pete-card[data-live="1"]:hover .pete-card-front {
|
||||||
|
filter: brightness(1.06);
|
||||||
|
}
|
||||||
|
/* The card (or run) in your hand: lifted off the felt, and glowing, so it's
|
||||||
|
obvious what a click on a column is about to move. */
|
||||||
|
.pete-card[data-held="1"] {
|
||||||
|
z-index: 5;
|
||||||
|
transform: translateY(-0.55rem);
|
||||||
|
transition: transform 0.14s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
.pete-card[data-held="1"] .pete-card-front {
|
||||||
|
box-shadow:
|
||||||
|
0 3px 0 rgba(0,0,0,0.18),
|
||||||
|
0 10px 22px rgba(0,0,0,0.32),
|
||||||
|
0 0 0 3px rgba(242, 181, 61, 0.9);
|
||||||
|
}
|
||||||
|
/* A place the held card would actually go. Lit *before* you click it, because
|
||||||
|
the alternative is learning the rules by being told no. */
|
||||||
|
.pete-slot[data-drop="1"],
|
||||||
|
.pete-col[data-drop="1"] .pete-slot,
|
||||||
|
.pete-col[data-drop="1"] .pete-card:last-child .pete-card-front {
|
||||||
|
border-color: rgba(242, 181, 61, 0.9);
|
||||||
|
box-shadow: 0 0 0 3px rgba(242, 181, 61, 0.45);
|
||||||
|
}
|
||||||
|
.pete-slot[data-drop="1"],
|
||||||
|
.pete-col[data-drop="1"] .pete-slot {
|
||||||
|
background: rgba(242, 181, 61, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A move that won't go. Said in the one language a board can speak. */
|
||||||
|
.pete-nope { animation: pete-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
|
||||||
|
|
||||||
|
/* A card arriving on a foundation lands with a flash: it is the only move in
|
||||||
|
the game that pays you, so it is the only one that gets a noise. */
|
||||||
|
.pete-home-flash { animation: pete-home 0.5s ease-out; }
|
||||||
|
@keyframes pete-home {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(242, 181, 61, 0.9); }
|
||||||
|
100% { box-shadow: 0 0 0 1.4rem rgba(242, 181, 61, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The rail: the house's rack, what you've banked, the meter. On a wide screen
|
||||||
|
it's a column down the right of the felt; on a narrow one it lies down and
|
||||||
|
sits under the board. */
|
||||||
|
.pete-rail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.pete-rail {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 9.5rem;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* In the rail the rack is just another thing in a column, so it drops the
|
||||||
|
absolute positioning it uses when it's pinned to the corner of a felt. */
|
||||||
|
.pete-rack[data-at="rail"] {
|
||||||
|
position: static;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.pete-card,
|
.pete-card,
|
||||||
.pete-card::after,
|
.pete-card::after,
|
||||||
@@ -884,6 +1428,18 @@ html[data-phase="night"] {
|
|||||||
.pete-dealer-think { animation: none; }
|
.pete-dealer-think { animation: none; }
|
||||||
.pete-card-inner { transition: none; }
|
.pete-card-inner { transition: none; }
|
||||||
.pete-hand[data-won="1"] .pete-card { animation: none; }
|
.pete-hand[data-won="1"] .pete-card { animation: none; }
|
||||||
|
.pete-part-draw,
|
||||||
|
.pete-shake,
|
||||||
|
.pete-tile-hit,
|
||||||
|
.pete-meter[data-hit="1"] { animation: none; }
|
||||||
|
.pete-nope,
|
||||||
|
.pete-home-flash { animation: none; }
|
||||||
|
.pete-card[data-held="1"] { transition: none; }
|
||||||
|
/* The clock still drains — it is information, not decoration — but it stops
|
||||||
|
pulsing at you, and a wrong answer stops shaking. */
|
||||||
|
.pete-clock[data-hot="1"],
|
||||||
|
.pete-answer[data-state="wrong"] { animation: none; }
|
||||||
|
.pete-rung { transition: none; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -44,11 +44,12 @@
|
|||||||
var spotTotalEl = root.querySelector("[data-spot-total]");
|
var spotTotalEl = root.querySelector("[data-spot-total]");
|
||||||
var houseEl = root.querySelector("[data-house]");
|
var houseEl = root.querySelector("[data-house]");
|
||||||
|
|
||||||
// Nothing is bet until a chip is on the felt. The number in the panel is a
|
// The spot owns the chips on the felt and the number under them — see PeteFX.
|
||||||
// readout of the pile, so it starts where the pile does — at nothing — rather
|
// Nothing is bet until a chip is on it, so `bet` starts at nothing rather than
|
||||||
// than at a default stake nobody put down.
|
// at a default stake nobody put down.
|
||||||
|
var spot = FX.spot({ spot: spotEl, stack: stackEl, total: spotTotalEl });
|
||||||
|
|
||||||
var bet = 0; // what you're building between hands
|
var bet = 0; // what you're building between hands
|
||||||
var staked = 0; // what is actually sitting on the spot right now
|
|
||||||
var busy = false; // a request is in flight, or cards are still landing
|
var busy = false; // a request is in flight, or cards are still landing
|
||||||
var hand = null; // the hand as the server last described it
|
var hand = null; // the hand as the server last described it
|
||||||
|
|
||||||
@@ -68,194 +69,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- drawing --------------------------------------------------------------
|
// ---- drawing --------------------------------------------------------------
|
||||||
|
|
||||||
var dealt = 0; // how many cards this table has put down, ever — the tilt seed
|
|
||||||
|
|
||||||
// cardEl builds one card. face === null means face-down: the card is dealt,
|
|
||||||
// but this browser has not been told what it is.
|
|
||||||
function cardEl(face) {
|
|
||||||
var wrap = document.createElement("div");
|
|
||||||
wrap.className = "pete-card";
|
|
||||||
wrap.dataset.face = face ? "up" : "down";
|
|
||||||
// Every card flies out of the shoe, which sits in the top-right of the felt.
|
|
||||||
// The offset is per-card, so a card landing further left flies further.
|
|
||||||
wrap.style.setProperty("--deal-x", "14rem");
|
|
||||||
wrap.style.setProperty("--deal-y", "-6rem");
|
|
||||||
// Where it comes to rest. A degree or two either way is the whole difference
|
|
||||||
// between cards that were dealt onto a table and cards that were laid out in
|
|
||||||
// a grid, and it costs one custom property.
|
|
||||||
wrap.style.setProperty("--tilt", FX.jitter(dealt++, 2.4).toFixed(2) + "deg");
|
|
||||||
|
|
||||||
var inner = document.createElement("div");
|
|
||||||
inner.className = "pete-card-inner";
|
|
||||||
|
|
||||||
var front = document.createElement("div");
|
|
||||||
front.className = "pete-card-front";
|
|
||||||
var back = document.createElement("div");
|
|
||||||
back.className = "pete-card-back";
|
|
||||||
|
|
||||||
inner.appendChild(front);
|
|
||||||
inner.appendChild(back);
|
|
||||||
wrap.appendChild(inner);
|
|
||||||
if (face) paintFace(front, face);
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- the face ------------------------------------------------------------
|
|
||||||
//
|
//
|
||||||
// A card is drawn, not typed. The first attempt set the pips as text — "♠" in a
|
// The deck itself — the faces, the pips, the flip — is PeteCards, shared with
|
||||||
// span — and at the size a card actually is, a suit character renders as a
|
// every other table in the room. Here a card is always dealt out of the shoe
|
||||||
// speck: the shape is whatever font happened to answer, it doesn't scale, and
|
// and always lands with a degree or two of tilt on it, which are this table's
|
||||||
// it can't be positioned to the half-row a real pip layout needs.
|
// two opinions about a card and the only ones it has.
|
||||||
//
|
|
||||||
// So each face is one SVG on a 100×140 field (the proportions of a real card),
|
|
||||||
// with the suits as vector shapes. Everything below is coordinates on that
|
|
||||||
// field, which is why the pips land where a printed deck puts them instead of
|
|
||||||
// where a flexbox felt like putting them.
|
|
||||||
|
|
||||||
var SUIT_ART = {
|
var CARDS = window.PeteCards;
|
||||||
"♠": '<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
|
function cardEl(face) { return CARDS.el(face); }
|
||||||
// row". [x, y] on the 100×140 field. The seven canonical rows sit at y = 27,
|
var turnOver = CARDS.turnOver;
|
||||||
// 41, 56, 70, 84, 99, 113; sevens, eights and tens carry a pip *between* two of
|
|
||||||
// them, which is the whole reason this is a table of coordinates and not a
|
|
||||||
// grid. Anything below the middle is printed upside down, so it is.
|
|
||||||
var R = [0, 27, 41.4, 55.7, 70, 84.3, 98.6, 113]; // 1-indexed, R[4] is the middle
|
|
||||||
var L = 30, C = 50, Rr = 70; // the three columns
|
|
||||||
|
|
||||||
var PIPS = {
|
|
||||||
"A": [[C, 70, 2.1]],
|
|
||||||
"2": [[C, R[1]], [C, R[7]]],
|
|
||||||
"3": [[C, R[1]], [C, R[4]], [C, R[7]]],
|
|
||||||
"4": [[L, R[1]], [Rr, R[1]], [L, R[7]], [Rr, R[7]]],
|
|
||||||
"5": [[L, R[1]], [Rr, R[1]], [C, R[4]], [L, R[7]], [Rr, R[7]]],
|
|
||||||
"6": [[L, R[1]], [Rr, R[1]], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
|
|
||||||
"7": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
|
|
||||||
"8": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
|
|
||||||
"9": [[L, R[1]], [Rr, R[1]], [L, R[3]], [Rr, R[3]], [C, R[4]], [L, R[5]], [Rr, R[5]], [L, R[7]], [Rr, R[7]]],
|
|
||||||
"10": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[3]], [Rr, R[3]], [L, R[5]], [Rr, R[5]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
|
|
||||||
};
|
|
||||||
|
|
||||||
var COURT = { "J": "Jack", "Q": "Queen", "K": "King" };
|
|
||||||
|
|
||||||
// One pip: the suit art, scaled and dropped at [x, y], turned over if it sits
|
|
||||||
// below the middle of the card.
|
|
||||||
function pipAt(suit, x, y, scale) {
|
|
||||||
var s = (scale || 1) * 0.17;
|
|
||||||
var turn = y > 70 ? " rotate(180 50 50)" : "";
|
|
||||||
return '<g transform="translate(' + x + ' ' + y + ') scale(' + s + ') translate(-50 -50)' + turn + '">' +
|
|
||||||
SUIT_ART[suit] + "</g>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// The corner index: rank over suit. Printed in both corners, the second one
|
|
||||||
// upside down, which is what lets you read a card from a fanned hand.
|
|
||||||
function index(face) {
|
|
||||||
var g =
|
|
||||||
'<g>' +
|
|
||||||
'<text x="12" y="24" class="pete-card-idx">' + face.rank + "</text>" +
|
|
||||||
'<g transform="translate(12 36) scale(0.13) translate(-50 -50)">' + SUIT_ART[face.suit] + "</g>" +
|
|
||||||
"</g>";
|
|
||||||
return g + '<g transform="rotate(180 50 70)">' + g + "</g>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// paintFace draws the card. The dealer's cards and yours use the same face,
|
|
||||||
// because they came out of the same shoe.
|
|
||||||
function paintFace(front, face) {
|
|
||||||
front.dataset.red = face.red ? "1" : "0";
|
|
||||||
|
|
||||||
var body = "";
|
|
||||||
if (COURT[face.rank]) {
|
|
||||||
// Court cards: a framed panel, the suit above the letter and again below it
|
|
||||||
// the other way up. A real court mirrors a *figure*; mirroring a letter just
|
|
||||||
// stacks two of them into a blob, which is exactly what the first attempt
|
|
||||||
// did. No portrait either — a drawn king would fight the room, and this
|
|
||||||
// reads instantly at the size a card actually is.
|
|
||||||
body =
|
|
||||||
'<rect x="20" y="22" width="60" height="96" rx="6" class="pete-card-panel"/>' +
|
|
||||||
pipAt(face.suit, 50, 38, 0.95) +
|
|
||||||
'<text x="50" y="82" class="pete-card-court">' + face.rank + "</text>" +
|
|
||||||
pipAt(face.suit, 50, 102, 0.95);
|
|
||||||
} else {
|
|
||||||
var spots = PIPS[face.rank] || [];
|
|
||||||
for (var i = 0; i < spots.length; i++) {
|
|
||||||
body += pipAt(face.suit, spots[i][0], spots[i][1], spots[i][2]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
front.innerHTML =
|
|
||||||
'<svg class="pete-card-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg" ' +
|
|
||||||
'role="img" aria-label="' + ariaFor(face) + '">' + index(face) + body + "</svg>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// "A♠" is not something a screen reader should be asked to pronounce.
|
|
||||||
function ariaFor(face) {
|
|
||||||
var SUITS = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" };
|
|
||||||
var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank);
|
|
||||||
var suit = SUITS[face.suit];
|
|
||||||
return suit ? name + " of " + suit : face.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
// turnOver flips a face-down card up, now that we've been told what it is.
|
|
||||||
function turnOver(wrap, face) {
|
|
||||||
if (!wrap) return;
|
|
||||||
paintFace(wrap.querySelector(".pete-card-front"), face);
|
|
||||||
wrap.dataset.face = "up";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- the money on the felt -------------------------------------------------
|
// ---- the money on the felt -------------------------------------------------
|
||||||
//
|
|
||||||
// `staked` is what the spot is holding. Every path that changes it also moves
|
|
||||||
// chips to say so, so the two can't come apart: renderStack draws the pile,
|
|
||||||
// and the fly* calls are what put it there.
|
|
||||||
|
|
||||||
function renderStack(amount) {
|
|
||||||
staked = amount || 0;
|
|
||||||
stackEl.innerHTML = "";
|
|
||||||
spotEl.dataset.live = staked > 0 ? "1" : "0";
|
|
||||||
|
|
||||||
if (!staked) {
|
|
||||||
spotTotalEl.classList.add("hidden");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
FX.chipsFor(staked).forEach(function (d, i) {
|
|
||||||
var c = FX.disc(d);
|
|
||||||
c.style.setProperty("--i", i);
|
|
||||||
c.style.setProperty("--spin", FX.jitter(i, 12).toFixed(1) + "deg");
|
|
||||||
c.style.animationDelay = pace(i * 40) + "ms";
|
|
||||||
stackEl.appendChild(c);
|
|
||||||
});
|
|
||||||
spotTotalEl.textContent = staked.toLocaleString();
|
|
||||||
spotTotalEl.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
// pour throws a run of chips from one place to another and grows the pile on
|
|
||||||
// the spot as each one lands — by the value of the chip that landed, so the
|
|
||||||
// total under the pile counts up the way the chips do. The last chip carries
|
|
||||||
// the remainder, because chipsFor caps how many chips it will make you watch
|
|
||||||
// and the pile still has to end on the real number.
|
|
||||||
function pour(from, to, amount, opts) {
|
|
||||||
if (amount <= 0) return Promise.resolve();
|
|
||||||
var base = staked;
|
|
||||||
var chips = FX.chipsFor(amount, 8);
|
|
||||||
var run = 0;
|
|
||||||
return FX.flyMany(from, to, chips, Object.assign({
|
|
||||||
onLand: function (d, i) {
|
|
||||||
run += d;
|
|
||||||
renderStack(base + (i === chips.length - 1 ? amount : run));
|
|
||||||
},
|
|
||||||
}, opts || {}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// stake moves chips from your pile onto the spot: the bet you build before a
|
// 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.
|
// deal, and the second bet a double puts down beside it.
|
||||||
function stake(amount, from) {
|
function stake(amount, from) {
|
||||||
return pour(from || purseEl, spotEl, amount);
|
return spot.pour(from || purseEl, amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// settleChips is what the felt does about the outcome, after the cards have
|
// settleChips is what the felt does about the outcome, after the cards have
|
||||||
@@ -268,25 +98,17 @@
|
|||||||
|
|
||||||
if (payout <= 0) {
|
if (payout <= 0) {
|
||||||
// The house takes it. The stack goes to the rack and doesn't come back.
|
// The house takes it. The stack goes to the rack and doesn't come back.
|
||||||
var lost = FX.chipsFor(final.bet, 8);
|
return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
||||||
var chain = FX.flyMany(spotEl, houseEl, lost, { gap: 45, lift: 0.6, fade: true });
|
|
||||||
renderStack(0);
|
|
||||||
return chain;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The house pays first, into the spot beside your stake, so you watch the
|
// The house pays first, into the spot beside your stake, so you watch the
|
||||||
// winnings arrive on top of the bet that earned them.
|
// winnings arrive on top of the bet that earned them.
|
||||||
var pay = pour(houseEl, spotEl, back, { gap: 60 });
|
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
|
// Paid, then swept up: the whole lot comes back to your pile, and only then
|
||||||
// does the number in the bar move.
|
// does the number in the bar move.
|
||||||
return pay
|
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
||||||
.then(function () { return wait(back > 0 ? 380 : 200); })
|
|
||||||
.then(function () {
|
|
||||||
var home = FX.flyMany(spotEl, purseEl, FX.chipsFor(payout, 8), { gap: 40, lift: 0.8 });
|
|
||||||
renderStack(0);
|
|
||||||
return home;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function totals(v) {
|
function totals(v) {
|
||||||
@@ -312,12 +134,12 @@
|
|||||||
function paint(v) {
|
function paint(v) {
|
||||||
dealerEl.innerHTML = "";
|
dealerEl.innerHTML = "";
|
||||||
playerEl.innerHTML = "";
|
playerEl.innerHTML = "";
|
||||||
if (!v) { setPhase(null); renderStack(0); return; }
|
if (!v) { setPhase(null); spot.render(0); return; }
|
||||||
|
|
||||||
v.player.forEach(function (c) { playerEl.appendChild(cardEl(c)); });
|
v.player.forEach(function (c) { playerEl.appendChild(cardEl(c)); });
|
||||||
v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); });
|
v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); });
|
||||||
if (v.hole) dealerEl.appendChild(cardEl(null));
|
if (v.hole) dealerEl.appendChild(cardEl(null));
|
||||||
renderStack(v.phase === "done" ? 0 : v.bet);
|
spot.render(v.phase === "done" ? 0 : v.bet);
|
||||||
totals(v);
|
totals(v);
|
||||||
setPhase(v);
|
setPhase(v);
|
||||||
}
|
}
|
||||||
@@ -386,8 +208,8 @@
|
|||||||
// first, and a deal whose bet was typed rather than stacked (you kept last
|
// 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
|
// hand's number and just pressed Deal). Either way the chips go down before
|
||||||
// the card they're buying does.
|
// the card they're buying does.
|
||||||
if (final && final.bet > staked) {
|
if (final && final.bet > spot.amount) {
|
||||||
var extra = final.bet - staked;
|
var extra = final.bet - spot.amount;
|
||||||
chain = chain.then(function () { return stake(extra); });
|
chain = chain.then(function () { return stake(extra); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,7 +321,7 @@
|
|||||||
say(err.message, "bad");
|
say(err.message, "bad");
|
||||||
// Whatever we thought was on the felt, the server is the authority on it.
|
// Whatever we thought was on the felt, the server is the authority on it.
|
||||||
return window.PeteGames.refresh().then(function (v) {
|
return window.PeteGames.refresh().then(function (v) {
|
||||||
if (v && !v.hand) renderStack(0);
|
if (v && !v.hand) spot.render(0);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.then(function () { busy = false; });
|
.then(function () { busy = false; });
|
||||||
@@ -517,7 +339,9 @@
|
|||||||
if (dealBtn) dealBtn.disabled = bet <= 0 || !money || money.chips < bet;
|
if (dealBtn) dealBtn.disabled = bet <= 0 || !money || money.chips < bet;
|
||||||
}
|
}
|
||||||
|
|
||||||
root.querySelectorAll("[data-chip]").forEach(function (btn) {
|
// 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 () {
|
btn.addEventListener("click", function () {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
var d = parseInt(btn.dataset.chip, 10);
|
var d = parseInt(btn.dataset.chip, 10);
|
||||||
@@ -531,12 +355,12 @@
|
|||||||
|
|
||||||
// The chip you clicked is the chip that flies: same colour, same size, off
|
// 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 —
|
// the button and onto the felt. The pile only grows once it gets there —
|
||||||
// but `staked` moves now, so a Deal pressed mid-flight still knows the chip
|
// but the spot's total moves now, so a Deal pressed mid-flight still knows
|
||||||
// is on its way and doesn't put a second one down.
|
// the chip is on its way and doesn't put a second one down.
|
||||||
var target = bet;
|
var target = bet;
|
||||||
staked = bet;
|
spot.amount = bet;
|
||||||
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
||||||
if (bet >= target) renderStack(target); // unless Clear got there first
|
if (bet >= target) spot.render(target); // unless Clear got there first
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -544,10 +368,9 @@
|
|||||||
var clearBtn = root.querySelector("[data-bet-clear]");
|
var clearBtn = root.querySelector("[data-bet-clear]");
|
||||||
if (clearBtn) {
|
if (clearBtn) {
|
||||||
clearBtn.addEventListener("click", function () {
|
clearBtn.addEventListener("click", function () {
|
||||||
if (busy || !staked) { bet = 0; showBet(); return; }
|
if (busy || !spot.amount) { bet = 0; showBet(); return; }
|
||||||
FX.flyMany(spotEl, purseEl, FX.chipsFor(staked, 8), { gap: 40, lift: 0.7 });
|
spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||||||
bet = 0;
|
bet = 0;
|
||||||
renderStack(0);
|
|
||||||
showBet();
|
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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -144,6 +144,78 @@
|
|||||||
return Promise.all(each);
|
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.
|
// burst: confetti out of a point. Saved for the things worth celebrating.
|
||||||
function burst(target, opts) {
|
function burst(target, opts) {
|
||||||
if (reduced) return Promise.resolve();
|
if (reduced) return Promise.resolve();
|
||||||
@@ -242,6 +314,7 @@
|
|||||||
jitter: jitter,
|
jitter: jitter,
|
||||||
fly: fly,
|
fly: fly,
|
||||||
flyMany: flyMany,
|
flyMany: flyMany,
|
||||||
|
spot: spot,
|
||||||
burst: burst,
|
burst: burst,
|
||||||
count: count,
|
count: count,
|
||||||
centre: centre,
|
centre: centre,
|
||||||
|
|||||||
@@ -62,8 +62,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// You cannot cash out mid-hand: the stake is already on the table.
|
// You cannot cash out mid-game: the stake is already on the table. `game` is
|
||||||
if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.hand;
|
// 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;
|
if (buyBtn) buyBtn.disabled = v.chips + v.pending >= v.cap;
|
||||||
|
|
||||||
listeners.forEach(function (fn) { fn(v); });
|
listeners.forEach(function (fn) { fn(v); });
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
always travelling between one of those and the spot in front of you. -->
|
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">
|
<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">
|
<div class="pete-rack" data-at="shoe" data-house aria-hidden="true">
|
||||||
<span data-chip="500" style="--stack: 5"></span>
|
<span data-chip="500" style="--stack: 5"></span>
|
||||||
<span data-chip="100" style="--stack: 7"></span>
|
<span data-chip="100" style="--stack: 7"></span>
|
||||||
<span data-chip="25" style="--stack: 4"></span>
|
<span data-chip="25" style="--stack: 4"></span>
|
||||||
@@ -127,6 +127,7 @@
|
|||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script src="/static/js/casino-fx.js" defer></script>
|
<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/games.js" defer></script>
|
||||||
<script src="/static/js/blackjack.js" defer></script>
|
<script src="/static/js/blackjack.js" defer></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -42,6 +42,54 @@
|
|||||||
</p>
|
</p>
|
||||||
</a>
|
</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}}
|
{{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="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">
|
<div class="flex items-center gap-3">
|
||||||
|
|||||||
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}}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
1
main.go
1
main.go
@@ -299,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,9 +147,188 @@ A multi-session build. This section is the handover; read it before anything els
|
|||||||
that adventure news already set. Restarted, it logs
|
that adventure news already set. Restarted, it logs
|
||||||
`pete games: escrow loop started interval=3s`.
|
`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
|
### Next, in order
|
||||||
|
|
||||||
1. Phase 2 (trivia, hangman), 3 (UNO), 4 (hold'em) as below.
|
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
|
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
|
engine has no move for it), the felt is roomy at desktop widths with only one seat on
|
||||||
|
|||||||
Reference in New Issue
Block a user