Hangman, and it plays for chips — which the plan had down as a free game, on the grounds that trivia has no euro coupling in gogobee. But a free game in a casino reads as a demo, so it stakes like everything else. The idea that makes it a casino game rather than hangman with a wager stapled on: the gallows is the payout meter. A wrong guess draws a limb *and* takes a tenth off what a win is worth, because those are the same event and showing them as one is the entire reason to bet on this. Short phrases pay 2.6x (fewer letters, less to go on), long ones 1.6x — the floor is 1x, so a win never hands back less than the stake, and the rake still comes out of winnings only. State.Pays() is the number the felt quotes and the number settle() lands on. They were briefly two sums, and the table spent an afternoon advertising a pre-rake payout it didn't honour. Two things the storage layer had already decided for us, and one it hadn't: game_live_hands is keyed on the player, so "one game at a time" holds across games for free (a live hangman 409s a blackjack deal, stake intact). But table() unmarshalled every live row as a blackjack hand, which does not fail on a hangman row — it quietly yields an empty hand. It dispatches on the game now. commit() is the settle path both games share, and casinoRoutes() the one route list, since the dev rig wires its own mux and a second copy is a copy that stops including the newest game. Driven in a browser, win and loss: 200 at 2.34x paid 455 and the bar landed on it; six wrong took the stake and no more; a reload mid-phrase brought back the board, the limbs, the multiple, the spent keys and the chips on the spot. The browser found the two bugs a Go test can't — a lives counter under the house rack, and a word wrapping early because the rack's clearance was on the whole column instead of the one row beside it.
148 lines
5.1 KiB
Go
148 lines
5.1 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"pete/internal/games/blackjack"
|
|
"pete/internal/games/hangman"
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// The casino's two pages. Both require a signed-in visitor — there is money in
|
|
// here, and a player has to be somebody gogobee's ledger can name.
|
|
//
|
|
// Neither page renders any game state server-side. The felt is drawn by the
|
|
// browser from /api/games/table, because a hand is a thing that *happens*: cards
|
|
// are dealt one at a time and the table plays that back. A server-rendered hand
|
|
// would arrive fully formed, which is the one thing a card table must never do.
|
|
|
|
// gameTeaser is a table that isn't open yet. They're on the lobby because an
|
|
// empty casino with one game reads as broken, and this reads as early.
|
|
type gameTeaser struct {
|
|
Name string
|
|
Emoji string
|
|
Blurb string
|
|
}
|
|
|
|
var comingSoon = []gameTeaser{
|
|
{Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
|
|
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
|
|
{Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."},
|
|
}
|
|
|
|
// betDenominations are the chips you build a bet out of.
|
|
var betDenominations = []int64{5, 25, 100, 500}
|
|
|
|
// The casino is not called Pete — the news app is Pete's, and this is somewhere
|
|
// you go. It has two names, and which one is over the door depends on the hour:
|
|
// the lights come on at six and the place turns into Casino Night Zone until
|
|
// dawn. Same tables, different room.
|
|
type room struct {
|
|
Slug string // drives the palette: html[data-room="…"]
|
|
Name string // what's on the sign
|
|
}
|
|
|
|
var (
|
|
roomDay = room{Slug: "casinopolis", Name: "Casinopolis"}
|
|
roomNight = room{Slug: "casino-night", Name: "Casino Night Zone"}
|
|
)
|
|
|
|
// roomAt picks the room for an hour of the day. Daylight is 6am to 6pm; the rest
|
|
// belongs to the neon. The browser re-runs this same rule against its own clock
|
|
// (games_layout.html), so a player in another timezone sees their own evening —
|
|
// this server-side pick only exists so the first paint isn't the wrong room.
|
|
func roomAt(hour int) room {
|
|
if hour >= 6 && hour < 18 {
|
|
return roomDay
|
|
}
|
|
return roomNight
|
|
}
|
|
|
|
// gamesPage is deliberately *not* pageData. The casino shares Pete's design
|
|
// language and nothing else — no channels, no weather, no sources, no push.
|
|
// Giving it its own page struct is what stops the news app's furniture drifting
|
|
// back in one convenient field at a time.
|
|
type gamesPage struct {
|
|
Room room
|
|
User *SessionUser
|
|
Cap int64
|
|
RakePct int
|
|
Soon []gameTeaser
|
|
Denominations []int64
|
|
Tiers []hangman.Tier // hangman's three lengths, and what each pays
|
|
MaxWrong int
|
|
}
|
|
|
|
// 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 /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)
|
|
}
|
|
|
|
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
|
|
// Anyone who is signed in but carries a session from before the casino existed
|
|
// has no username in it, so they get sent through sign-in too — which mints one.
|
|
func (s *Server) requirePlayer(w http.ResponseWriter, r *http.Request) bool {
|
|
if !s.gamesReady() {
|
|
http.NotFound(w, r)
|
|
return false
|
|
}
|
|
u := s.auth.userFromRequest(r)
|
|
if u != nil && u.MatrixUser(s.cfg.Games.MatrixServer) != "" {
|
|
return true
|
|
}
|
|
http.Redirect(w, r, "/auth/login?next="+r.URL.Path, http.StatusFound)
|
|
return false
|
|
}
|
|
|
|
func (s *Server) gamesPage(r *http.Request) gamesPage {
|
|
return gamesPage{
|
|
Room: roomAt(time.Now().Hour()),
|
|
User: s.auth.userFromRequest(r), // requirePlayer ran first, so this is non-nil
|
|
Cap: storage.MaxChipsOnTable,
|
|
RakePct: int(blackjack.DefaultRules().RakePct * 100),
|
|
Soon: comingSoon,
|
|
Denominations: betDenominations,
|
|
Tiers: hangman.Tiers,
|
|
MaxWrong: hangman.MaxWrong,
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleLobby(w http.ResponseWriter, r *http.Request) {
|
|
if !s.requirePlayer(w, r) {
|
|
return
|
|
}
|
|
s.render(w, "games", s.gamesPage(r))
|
|
}
|
|
|
|
func (s *Server) handleBlackjack(w http.ResponseWriter, r *http.Request) {
|
|
if !s.requirePlayer(w, r) {
|
|
return
|
|
}
|
|
s.render(w, "blackjack", s.gamesPage(r))
|
|
}
|
|
|
|
func (s *Server) handleHangman(w http.ResponseWriter, r *http.Request) {
|
|
if !s.requirePlayer(w, r) {
|
|
return
|
|
}
|
|
s.render(w, "hangman", s.gamesPage(r))
|
|
}
|