16 Commits

Author SHA1 Message Date
prosolis
6961f90634 games: the money moves
The table dealt cards but settled money by editing a number. So the felt got
the two things it was missing: a bet spot in front of you, and the house's rack
beside the shoe. Every chip is now always travelling between one of those and
the other.

You build a bet by throwing chips onto the spot — the chip you clicked is the
chip that flies. The stake sits there through the hand. The house pays out of
its rack into the spot, and the pile is then swept back to your stack. A loss
goes to the rack and does not come back.

Two rules hold it together. The number under the pile is a readout of the pile,
never the other way round: the bet starts at nothing rather than at a default
nobody put down, and a settled hand leaves your stake back up on the spot,
because otherwise the panel prints "your bet: 300" over an empty circle. And
the chip bar does not move until the chips that justify it have landed — a
counter that pays you before the dealer turns over is a counter that has told
you the ending.

casino-fx.js is the engine underneath: chips fly on an arc, out of a fixed
overlay so no container clips one crossing from a button to the felt. It knows
nothing about blackjack.

Also: cards land with weight and a degree or two of tilt, so a hand looks dealt
rather than typeset; the dealer takes a beat before drawing out; and a natural
gets confetti, which is the only thing in the room that does.

Driven in a real browser, which is the only way to review an animation — and
which is what caught the verdict pill rendering white on white in a dark room,
a chip rack sitting on top of the dealer, and Hit being offered over a table
that was still being paid out. devcasino_test.go is that harness, kept.
2026-07-14 00:33:49 -07:00
prosolis
b00da21a47 games: draw the card, don't type it
The last attempt built a card face out of text: a "♠" in a span for every
pip. At the size a card actually is, a suit character renders as a speck —
the shape is whatever font answered, it doesn't scale, and it can't be put
on the half-row a real pip layout needs. The result read worse than the
plain rank it replaced.

So each face is one SVG on a 100×140 field, suits as vector shapes, pips at
the coordinates a printed deck puts them. Courts get a framed panel with the
suit above the letter and again below it upside down — mirroring a letter,
which is what the first pass did, just stacks two of them into a blob; a real
court mirrors a figure.

Also restores .pete-card-back, which went out with the text rules it was
sitting among: without it a face-down card had no back at all, so the
dealer's hole card was invisible on the felt. Caught by driving a hand.
2026-07-13 23:49:17 -07:00
prosolis
8ec13eab5b games: the casino moves out, and gets a clock of its own
The tables were living in the news app's shell: Pete's face in the header
and the footer, the channel nav, search, the reader, the weather canvas,
the PWA. A casino is not a news page with a felt on it.

So it gets its own layout. What carries over is the design language — the
four palette vars, Fredoka/Nunito, the fat rounded cards, the dropped
shadow. What doesn't is every control it has no use for. gamesPage stops
embedding the news pageData, which is what keeps the furniture from
drifting back one convenient field at a time.

It keeps a clock, but tells a different joke with it: Casinopolis by day,
Casino Night Zone from six, palette and felt and the sign over the door all
changing together. The rule lives in roomAt() for the first paint and again
in the browser, so a player abroad gets their own evening.

And the cards are cards now — corner indices in both corners, the bottom
one upside down as printed, pips on the three-by-seven grid every real deck
has used for four hundred years, courts as a letter with the suit over each
shoulder. Driven in a real browser, both rooms, dealt through to a payout.
2026-07-13 23:40:33 -07:00
prosolis
c69fbb63db games: a blackjack table you can actually sit down at
The engine, the escrow and the wire were all in place; nothing had a browser on
the end of it. This is that end: a lobby, a table, and the five endpoints between
them.

The browser holds no game. It sends intents and gets back a view — the cards it
is entitled to see, and the script of how they arrived, one event per card off
the shoe. The dealer's hole card is not in the payload at all until the reveal,
because a field the client is told to ignore is a field somebody reads in
devtools. The shoe lives in game_live_hands, which also means a redeploy
mid-hand no longer costs a player their stake: the hand is still there when they
come back.

The money is ordered so nothing can be spent twice. The stake leaves the stack in
the same statement that checks it exists, before a card is dealt. Every new hand
is seated with a plain INSERT, so a double-clicked Deal is decided by the primary
key rather than by a read that raced — it loses, gets its chips back, and the
hand in progress is untouched. A double takes its raise up front and hands it
straight back if the engine refuses the move.

Cards are dealt rather than swapped in — they fly out of the shoe and turn over,
which was a requirement and not a flourish. The faces and the chips are still
plain; that's next.
2026-07-13 23:20:42 -07:00
prosolis
cb84e1d549 games: a news session that travels to the games box
preferred_username was being read from the ID token and thrown away after
serving as a display-name fallback. It is the whole identity story: MAS imports
it as the Matrix localpart, so it is also who the player is in the euro economy.
Keep it in the session, and derive @user:server from it.

The session cookie was host-only, so a sign-in on news never reached games.
Widen it with an opt-in web.auth.cookie_domain — but only the session cookie:
the OAuth round-trip cookie pairs with a redirect back to the host that started
the login and stays where it was set. And because the redirect must return to
that host, the redirect_uri is now derived per-request for hosts inside the
cookie domain, with the configured URL as the fallback for anything else — a
Host header we don't own is never echoed into a redirect.
2026-07-13 23:04:09 -07:00
prosolis
44613c4760 games: the wire the euros cross
Three bearer-authed endpoints and gogobee can work the border: poll what's
waiting, claim a row, report what happened to the money. The storage layer
underneath was already done; this is the transport, and deliberately nothing
more.

All three are idempotent, because the thing on the other end of them is a
retrying queue and the thing they move is money. A verdict delivered three
times creates chips once. A rejected buy-in moves nothing and clears the
pending amount so it stops eating the table cap. A cash-out gogobee couldn't
pay gives the chips back rather than vanishing them from both sides.

A verdict for a row Pete has never heard of is a 400, not a shrug: gogobee has
by then moved real euros against it, and no amount of retrying invents the
missing row. Under the contract the adventure seam set, a 400 parks it in
gogobee's queue where a human can find it.
2026-07-13 23:00:19 -07:00
prosolis
a442cfccaa games: the plan learns what we decided and what we built 2026-07-13 22:49:26 -07:00
prosolis
f9a98f72a6 games: the euro/chip border, and the ledger that keeps it honest
A euro is either in gogobee's balances or in Pete's chip escrow, never both. It
crosses only via a game_escrow row whose guid is the same idempotency key gogobee
hands to DebitIdem/CreditIdem, so a claim whose ack is lost on the wire can be
retried without the player paying twice.

The border exists because gogobee has no inbound API and isn't getting one, so it
polls. A bet that round-tripped through a poll loop would take seconds to be
dealt. Instead the loop runs twice per session — buy in, cash out — and every hand
between them plays against chips held here, with no economy call in the hot path.

Two rules do most of the work. Chips appear only when gogobee confirms it took the
euros, so a buy-in can't mint money out of a pending request. Chips are destroyed
the moment a cash-out opens, so a player can't bet chips whose euros are already
in flight — and if the credit fails, they come back rather than evaporating.

Also: the €10k table cap counts in-flight buy-ins, so it can't be cleared by
firing several at once; a reaper cashes out anyone idle for 30 minutes, because
chips in an abandoned session are euros in limbo; and every hand is logged with
its seed, so a disputed hand gets answered with a re-deal instead of an apology.
2026-07-13 22:48:55 -07:00
prosolis
8310b30439 games: a deck you can seed and a blackjack you can replay
The first two pieces of games.parodia.dev, both pure: no HTTP, no timers, no
euros, nothing that knows a player's name.

cards/ is the shared deck gogobee never had — blackjack carried its own, UNO
carried another, hold'em leaned on a third-party one. The RNG is threaded rather
than the package global, so a hand is reproducible from its seed. That's what
makes the engine testable, and what lets a disputed hand be dealt again exactly
as it fell.

blackjack/ is ApplyMove(state, move) -> (state, events, error), where an error
means the move was illegal and nothing else. gogobee's engine *was* the message
sender, so its errors meant "the send failed"; there was no seam to test against.
State is a plain value, so a hand survives a redeploy.

House terms match the Matrix table — six decks, 3:2, dealer hits soft 17 — plus a
5% rake. The rake comes off winnings only: a push returns the stake untouched and
a loss is never charged for the privilege.
2026-07-13 22:44:45 -07:00
prosolis
6ccd18452c css: rebuild — the stale-roster dimmer had no rule behind it
99574db added the live roster card to the adventure page but shipped the CSS
bundle it was built against, which is generated and checked in. Three utility
classes the new markup uses were never compiled: mb-3, divide-y, and opacity-60.

The last one isn't cosmetic. channel.html toggles opacity-60 from JS to dim the
card when the roster data has gone stale — that dimming IS the staleness signal,
and with no rule behind it a stale roster rendered identically to a live one.
Same failure as the ticker: the thing that's supposed to look wrong looks fine.

Pure `npm run build:css` output, byte-reproducible from the committed templates.
2026-07-13 20:05:16 -07:00
prosolis
e85ebe56f7 news: Pete learns to report a contract killing
gogobee's Mischief Makers ships four new event types — a hit going out, the
target walking away from it, the target not walking away, and the monster
turning up to an empty dungeon. Pete 400s an unknown event_type, gogobee retries
and then parks the bulletin forever, so this deploys first.

The anonymity is the story, not an implementation detail. A contract is
anonymous unless the buyer paid extra to sign it, so the lede reports the money
and pointedly not the name Pete doesn't have. A survival unseals the buyer
whether or not they signed — that exposure is the only brake on casual griefing,
and it's the one beat worth leading with. Being maimed buys you nothing: an
anonymous buyer stays anonymous when the contract lands.
2026-07-13 20:03:28 -07:00
prosolis
99574db3e9 news: the adventure page gets something that's actually happening
Every dispatch Pete publishes is an accomplishment — a death, a clear, a
milestone — and an accomplishment is a newspaper clipping the moment it lands.
No refresh interval fixes that. So the page never felt alive, and it never was
going to.

The board is the other kind of thing: state that is currently true. gogobee
pushes the whole roster, we replace ours with it, and it renders above the
clippings. An open tab re-polls so it keeps telling the truth.

Replace, never merge: anyone gogobee omits (opted out, no character) drops off
the public page. That omission IS the opt-out — a standing row showing class,
level and zone names the player anyway, so "an adventurer" would have been a fig
leaf.

The snapshot time lives in its own row, because an empty board is ambiguous:
nobody playing, or gogobee stopped talking to us. The page has to tell those
apart — one is a quiet realm, the other is a board that lies confidently, which
is worse than one that admits it lost the wire.

Also teaches Pete "departure", so a bored adventurer letting itself out is news.
2026-07-13 18:05:38 -07:00
prosolis
8cb5b38599 Adventure: stop signing my own posts
The source tag credits an outlet Pete is relaying (`ars technica`). On
his own reporting it rendered as a trailing `pete` under a message he
already sent - him signing his own name. Drop Source on adventure posts
and omit the tag line entirely when there's nothing to credit; RSS posts
keep theirs.
2026-07-12 22:01:12 -07:00
prosolis
a614077cff Adventure: Pete learns the word "retreat"
gogobee has started filing a `retreat` bulletin — an expedition that ended with
the player walking out alive. Pete had no case for it, and handleAdventureIngest
answers an unrecognized event_type with 400.

That failure is silent and total. gogobee's sender treats any non-2xx as a
failure, retries eight times over roughly two hours, and then parks the row for
good. A gogobee emitting `retreat` at a Pete who doesn't know the word would not
log an error anyone reads — it would just quietly drop every retreat the realm
ever files.

So Pete has to learn it BEFORE gogobee starts saying it. Deploy order matters
here, and it is Pete first.

The copy leads with everyone coming home, because that is the true and the kind
part: a retreat is a bad day, not a funeral. Bulletin tier — it rides the daily
digest and does not interrupt the room. Announcing every failed run live would
be a firehose, and an unkind one.

https://claude.ai/code/session_01B2MwktU4RgfWkar8HM3zZn
2026-07-12 10:33:53 -07:00
prosolis
82d1c6ebeb Adventure: gate live beats on [adventure], not posting.enabled
posting.enabled governs the RSS pipeline's Matrix chatter. Adventure facts
are push-based -- they arrive from gogobee at ingest, are never polled,
classified or paced, and are excluded from the round-robin rotation -- so
folding them under the same flag made 'news on the web only, adventure live
in the games room' inexpressible. Gate advPost on [adventure].enabled + a
configured channel instead; a nil poster still shuts off both the live beats
and the digest loop.
2026-07-11 08:25:29 -07:00
prosolis
10bcc78c51 Merge adventure-news: gogobee adventure news section 2026-07-11 08:07:58 -07:00
41 changed files with 7109 additions and 47 deletions

View File

@@ -58,6 +58,12 @@ client_secret = "${PETE_OIDC_CLIENT_SECRET}"
redirect_url = "https://news.parodia.dev/auth/callback"
# HMAC key that signs the session cookie. Generate with: openssl rand -hex 32
session_secret = "${PETE_SESSION_SECRET}"
# Share the session across sibling hosts, so signing in on news.parodia.dev also
# signs you in on games.parodia.dev. This widens the cookie to every host under
# the domain, so leave it empty to keep the session host-only. Each host that
# starts a login also needs its own redirect URI registered in Authentik
# (<host>/auth/callback) — the login round-trip returns to the host it began on.
cookie_domain = ""
# Optional Web Push digests. When enabled, signed-in users can opt in (from the
# feed-settings panel) to a periodic "N new stories" notification, delivered via
@@ -96,6 +102,16 @@ label = "Amy (US, female)"
id = "en_US-ryan-high"
label = "Ryan (US, male, HQ)"
# The casino (games.parodia.dev). Signed-in only — there is real money in it — so
# it needs [web.auth] above, and web.auth.cookie_domain if the games host is a
# different subdomain from the news one. Chips are 1:1 with gogobee euros and
# cross the border through the escrow endpoints, which gogobee polls; matrix_server
# is how a player is named to that ledger (@<authentik username>:<matrix_server>).
[web.games]
enabled = false
host = "games.parodia.dev"
matrix_server = "parodia.dev"
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
# There is no automatic classification — Pete posts each story to its configured channel.
# Optional: language = "en" drops feed items whose per-item <language> tag is

View File

@@ -53,13 +53,14 @@ const defaultDigestHour = 17
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
type WebConfig struct {
Enabled bool `toml:"enabled"`
ListenAddr string `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080"
SiteTitle string `toml:"site_title"` // display name in the header
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
Enabled bool `toml:"enabled"`
ListenAddr string `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080"
SiteTitle string `toml:"site_title"` // display name in the header
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
Games GamesConfig `toml:"games"` // optional casino (games.parodia.dev)
// AdminSubs is the allowlist of OIDC subjects allowed to view the
// owner-facing source-health dashboard at /status. Empty means the page is
// inaccessible to everyone (returns 404). Requires auth to be enabled.
@@ -108,6 +109,23 @@ type VoiceConfig struct {
Label string `toml:"label"` // menu label, e.g. "Ryan — US, male"
}
// GamesConfig wires the casino. It is signed-in only — there is money in it —
// so it does nothing without web.auth, and it needs the Matrix server name
// because that is how a player's identity reaches gogobee's euro ledger: an
// Authentik username is the Matrix localpart, and the Matrix id is the account.
type GamesConfig struct {
Enabled bool `toml:"enabled"`
// Host is the public hostname the casino answers on, e.g.
// "games.parodia.dev". Requests arriving on it are served the casino at "/";
// everywhere else the same pages live under /games. Empty means no host
// branching, which is the normal state of affairs in local development.
Host string `toml:"host"`
// MatrixServer is the server name half of a player's Matrix id
// ("parodia.dev" -> @reala:parodia.dev). Without it, no player can be
// identified in the economy and the casino stays shut.
MatrixServer string `toml:"matrix_server"`
}
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
// When enabled, signed-in users get their preferences stored server-side keyed
// by the OIDC subject; anonymous visitors keep using browser localStorage.
@@ -118,6 +136,12 @@ type AuthConfig struct {
ClientSecret string `toml:"client_secret"`
RedirectURL string `toml:"redirect_url"` // e.g. https://news.parodia.dev/auth/callback
SessionSecret string `toml:"session_secret"` // HMAC key for signing the session cookie
// CookieDomain widens the session cookie beyond the host that set it, so a
// sign-in on news.parodia.dev is also a sign-in on games.parodia.dev. Set it
// to ".parodia.dev" to share the session across every parodia.dev host —
// which is every host, including the landing site, so it is opt-in rather
// than the default. Empty keeps the cookie host-only.
CookieDomain string `toml:"cookie_domain"`
}
type MatrixConfig struct {

View File

@@ -0,0 +1,346 @@
// Package blackjack is a pure blackjack engine.
//
// It knows nothing about HTTP, sockets, timers, euros or players' names. You
// hand it a state and a move, it hands you back a new state and the list of
// things that just happened. Everything else — who is sitting there, what their
// chips are, when their clock runs out — belongs to the shell in internal/games/table.
//
// That seam is the one thing gogobee's blackjack never had: there, the engine
// *was* the message sender, so an "error" meant a Matrix send had failed rather
// than that a player had tried something illegal. Here an error means exactly
// one thing: the move was not legal in this state.
//
// The state is a plain value. It serializes, so a hand survives a redeploy, and
// it replays, so a disputed hand can be dealt again from its seed.
package blackjack
import (
"errors"
"math"
"math/rand/v2"
"pete/internal/games/cards"
)
// Errors an illegal move can produce. Callers can match on these to tell a
// player "not now" rather than "something broke".
var (
ErrHandOver = errors.New("blackjack: the hand is already over")
ErrNotYourTurn = errors.New("blackjack: it is not the player's turn to act")
ErrUnknownMove = errors.New("blackjack: unknown move")
ErrCantDouble = errors.New("blackjack: double is only allowed on the opening two cards")
ErrDeckExhausted = errors.New("blackjack: the shoe is empty")
ErrBadBet = errors.New("blackjack: bet must be positive")
)
// Phase is whose turn it is.
type Phase string
const (
PhasePlayer Phase = "player" // the player is acting
PhaseDealer Phase = "dealer" // transient: the dealer is drawing out
PhaseDone Phase = "done" // settled, Outcome and Payout are final
)
// Outcome is how a finished hand finished, from the player's point of view.
type Outcome string
const (
OutcomeNone Outcome = ""
OutcomeBlackjack Outcome = "blackjack" // natural 21, paid 3:2
OutcomeWin Outcome = "win"
OutcomeLose Outcome = "lose"
OutcomePush Outcome = "push" // tie, stake returned
OutcomeBust Outcome = "bust" // player went over 21
OutcomeDealerBust Outcome = "dealer_bust"
)
// Won reports whether this outcome pays the player more than their stake back.
func (o Outcome) Won() bool {
return o == OutcomeWin || o == OutcomeBlackjack || o == OutcomeDealerBust
}
// Rules are the table's terms. They're part of the state rather than a global,
// so a hand always settles under the rules it was dealt under — even if the
// house changes them mid-session.
type Rules struct {
Decks int `json:"decks"` // shoe size
BlackjackPays float64 `json:"blackjack_pays"` // 1.5 = the honest 3:2
DealerHitsSoft17 bool `json:"dealer_hits_soft17"` // gogobee's dealer does
RakePct float64 `json:"rake_pct"` // house cut, taken from winnings only
}
// DefaultRules match the blackjack gogobee has been dealing in Matrix for years:
// six decks, 3:2 on a natural, dealer hits soft 17. The rake is the one new term
// — see Settle for exactly what it touches.
func DefaultRules() Rules {
return Rules{Decks: 6, BlackjackPays: 1.5, DealerHitsSoft17: true, RakePct: 0.05}
}
// State is one hand of heads-up blackjack: one player, one dealer. Splitting
// isn't in v1, so there's exactly one player hand.
type State struct {
Rules Rules `json:"rules"`
Deck cards.Deck `json:"deck"` // the shoe, top card first — never shown to the browser
Player []cards.Card `json:"player"`
Dealer []cards.Card `json:"dealer"`
Bet int64 `json:"bet"` // chips at risk; doubles on a double-down
Doubled bool `json:"doubled"`
Phase Phase `json:"phase"`
Outcome Outcome `json:"outcome"`
// Payout is what returns to the player's chip stack when the hand is done:
// stake plus winnings, net of rake. Zero on a loss. Rake is the house's cut,
// recorded so the ledger can account for every chip that left the table.
Payout int64 `json:"payout"`
Rake int64 `json:"rake"`
}
// Event is something the table can narrate or animate. The engine emits them
// instead of drawing anything itself.
type Event struct {
Kind string `json:"kind"` // "deal" | "player_card" | "dealer_card" | "reveal" | "settle"
Card *cards.Card `json:"card,omitempty"`
Text string `json:"text,omitempty"`
}
// Move is a player action.
type Move string
const (
Hit Move = "hit"
Stand Move = "stand"
Double Move = "double"
)
// HandValue totals a hand, counting each ace as 11 until that would bust, then
// demoting them one at a time. soft reports whether an ace is still counting as
// 11 — which is what makes "soft 17" a different thing from 17.
func HandValue(hand []cards.Card) (total int, soft bool) {
aces := 0
for _, c := range hand {
switch {
case c.Rank == cards.Ace:
aces++
total += 11
case c.Rank >= 10:
total += 10
default:
total += int(c.Rank)
}
}
for total > 21 && aces > 0 {
total -= 10 // demote an ace from 11 to 1
aces--
}
return total, aces > 0
}
// IsBlackjack reports a natural: 21 on the opening two cards. A 21 assembled
// from three cards is not one, and does not get paid 3:2.
func IsBlackjack(hand []cards.Card) bool {
if len(hand) != 2 {
return false
}
v, _ := HandValue(hand)
return v == 21
}
// New deals a fresh hand: two to the player, two to the dealer. If either side
// has a natural the hand is already over and the returned State is settled — a
// player with blackjack never gets asked whether they'd like to hit.
func New(bet int64, r Rules, rng *rand.Rand) (State, []Event, error) {
if bet <= 0 {
return State{}, nil, ErrBadBet
}
if r.Decks < 1 {
r.Decks = 1
}
deck := cards.NewDeck(r.Decks)
deck.Shuffle(rng)
s := State{Rules: r, Deck: deck, Bet: bet, Phase: PhasePlayer}
evs := []Event{{Kind: "deal"}}
for i := 0; i < 2; i++ {
if err := s.draw(&s.Player, "player_card", &evs); err != nil {
return State{}, nil, err
}
if err := s.draw(&s.Dealer, "dealer_card", &evs); err != nil {
return State{}, nil, err
}
}
// A natural on either side ends it before the player ever acts.
if IsBlackjack(s.Player) || IsBlackjack(s.Dealer) {
s.settle(&evs)
}
return s, evs, nil
}
// draw takes one card off the shoe onto the given hand and records the event.
// Pointer receiver: it mutates the deck and the hand together, and neither may
// end up applied to a stale copy of the state.
func (s *State) draw(hand *[]cards.Card, kind string, evs *[]Event) error {
c, ok := s.Deck.Draw()
if !ok {
return ErrDeckExhausted
}
*hand = append(*hand, c)
card := c
*evs = append(*evs, Event{Kind: kind, Card: &card})
return nil
}
// ApplyMove is the whole engine: a legal move in, a new state and the events it
// produced out. An error means the move was illegal and the state is unchanged.
//
// s is taken by value, so the caller's state is only replaced on success.
func ApplyMove(s State, m Move) (State, []Event, error) {
if s.Phase == PhaseDone {
return s, nil, ErrHandOver
}
// A copied State still shares its slices' backing arrays with the original.
// Two moves applied from the same starting state would then append cards over
// each other. Clone first: the caller's state is genuinely untouched, and a
// state can be replayed as many times as we like.
s = s.clone()
if s.Phase != PhasePlayer {
return s, nil, ErrNotYourTurn
}
if m == Double && len(s.Player) != 2 {
// Doubling means doubling the stake for exactly one more card. Only ever
// legal on the opening two — after that you're just describing a hit.
return s, nil, ErrCantDouble
}
if m != Hit && m != Stand && m != Double {
return s, nil, ErrUnknownMove
}
evs := []Event{}
if m == Double {
s.Bet *= 2
s.Doubled = true
}
if m == Hit || m == Double {
if err := s.draw(&s.Player, "player_card", &evs); err != nil {
return s, nil, err
}
if v, _ := HandValue(s.Player); v > 21 {
s.settle(&evs) // bust; the dealer never has to play
return s, evs, nil
}
if m == Hit {
return s, evs, nil // still the player's turn
}
}
// Stand, or a double that survived its card: the dealer draws out.
s.Phase = PhaseDealer
s.dealerPlay(&evs)
return s, evs, nil
}
// dealerPlay draws the dealer out to the house rule, then settles. The dealer
// has no choices to make — that's the game — so this needs no move.
func (s *State) dealerPlay(evs *[]Event) {
*evs = append(*evs, Event{Kind: "reveal"}) // the hole card turns over
for {
v, soft := HandValue(s.Dealer)
hitSoft17 := s.Rules.DealerHitsSoft17 && v == 17 && soft
if v >= 17 && !hitSoft17 {
break
}
if err := s.draw(&s.Dealer, "dealer_card", evs); err != nil {
break // shoe ran dry mid-draw; settle on what's on the table
}
}
s.settle(evs)
}
// settle decides the outcome and the payout, and is the only place chips are
// computed.
//
// The rake comes off winnings, never off the stake: a player who pushes gets
// exactly their bet back, and a player who loses is never charged for the
// privilege. The house only takes a cut of money the house was going to hand
// over anyway. That's a rake, as opposed to a fee for showing up.
func (s *State) settle(evs *[]Event) {
playerVal, _ := HandValue(s.Player)
dealerVal, _ := HandValue(s.Dealer)
playerBJ := IsBlackjack(s.Player)
dealerBJ := IsBlackjack(s.Dealer)
// profit is what the player wins on top of their stake. Negative means the
// stake is gone.
var profit int64
switch {
case playerVal > 21:
s.Outcome = OutcomeBust
profit = -s.Bet
case playerBJ && dealerBJ:
s.Outcome = OutcomePush
case playerBJ:
s.Outcome = OutcomeBlackjack
profit = int64(math.Floor(float64(s.Bet) * s.Rules.BlackjackPays))
case dealerBJ:
s.Outcome = OutcomeLose
profit = -s.Bet
case dealerVal > 21:
s.Outcome = OutcomeDealerBust
profit = s.Bet
case playerVal > dealerVal:
s.Outcome = OutcomeWin
profit = s.Bet
case playerVal == dealerVal:
s.Outcome = OutcomePush
default:
s.Outcome = OutcomeLose
profit = -s.Bet
}
if profit > 0 {
s.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct))
if s.Rake < 0 {
s.Rake = 0
}
profit -= s.Rake
}
if profit < 0 {
s.Payout = 0 // stake is lost; nothing comes back
} else {
s.Payout = s.Bet + profit
}
s.Phase = PhaseDone
*evs = append(*evs, Event{Kind: "settle", Text: string(s.Outcome)})
}
// Net is what the hand did to the player's chip stack: payout minus the stake
// they put up. Negative on a loss, zero on a push.
func (s State) Net() int64 {
if s.Phase != PhaseDone {
return 0
}
return s.Payout - s.Bet
}
// CanDouble reports whether Double is legal right now — the shell asks this to
// decide whether to light the button up.
func (s State) CanDouble() bool {
return s.Phase == PhasePlayer && len(s.Player) == 2
}
// clone deep-copies the slices so a derived state shares no backing array with
// the one it came from.
func (s State) clone() State {
s.Deck = append(cards.Deck(nil), s.Deck...)
s.Player = append([]cards.Card(nil), s.Player...)
s.Dealer = append([]cards.Card(nil), s.Dealer...)
return s
}

View File

@@ -0,0 +1,415 @@
package blackjack
import (
"encoding/json"
"testing"
"pete/internal/games/cards"
)
// hand builds a hand from "A♠"-ish shorthand: rank letters/numbers only.
func hand(ranks ...cards.Rank) []cards.Card {
h := make([]cards.Card, len(ranks))
for i, r := range ranks {
h[i] = cards.Card{Rank: r, Suit: cards.Spades}
}
return h
}
func TestHandValue(t *testing.T) {
tests := []struct {
name string
hand []cards.Card
want int
soft bool
}{
{"two aces are 12, not 22", hand(cards.Ace, cards.Ace), 12, true},
{"ace plus king is a soft 21", hand(cards.Ace, cards.King), 21, true},
{"faces are all ten", hand(cards.Jack, cards.Queen), 20, false},
{"ace demotes to save the hand", hand(cards.Ace, 9, 5), 15, false},
{"three aces and an eight", hand(cards.Ace, cards.Ace, cards.Ace, 8), 21, true},
{"soft 17 is an ace and a six", hand(cards.Ace, 6), 17, true},
{"hard 17 has no ace", hand(cards.King, 7), 17, false},
{"a bust stays busted", hand(cards.King, cards.Queen, 5), 25, false},
{"empty hand", nil, 0, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, soft := HandValue(tc.hand)
if got != tc.want || soft != tc.soft {
t.Fatalf("HandValue = (%d, soft=%v), want (%d, soft=%v)", got, soft, tc.want, tc.soft)
}
})
}
}
func TestIsBlackjack_OnlyOnTwoCards(t *testing.T) {
if !IsBlackjack(hand(cards.Ace, cards.King)) {
t.Fatal("A+K is a natural")
}
// 21 built from three cards is not a natural and must not be paid 3:2.
if IsBlackjack(hand(7, 7, 7)) {
t.Fatal("7+7+7 is 21 but not a blackjack")
}
if IsBlackjack(hand(cards.Ace)) {
t.Fatal("one card is not a blackjack")
}
}
// settleWith forces a finished hand and reads back the money, bypassing the
// deal so the payout math can be checked case by case.
func settleWith(t *testing.T, r Rules, bet int64, player, dealer []cards.Card) State {
t.Helper()
s := State{Rules: r, Bet: bet, Player: player, Dealer: dealer}
evs := []Event{}
s.settle(&evs)
if s.Phase != PhaseDone {
t.Fatal("settle left the hand unfinished")
}
return s
}
func TestSettle_PayoutsAndRake(t *testing.T) {
r := DefaultRules() // 3:2, 5% rake
tests := []struct {
name string
player []cards.Card
dealer []cards.Card
wantOutcome Outcome
wantPayout int64 // chips returned to the stack
wantRake int64
}{
{
// 100 stake, 100 profit, 5 raked → 195 back, net +95.
name: "a plain win is raked on the profit only",
player: hand(cards.King, 9), dealer: hand(cards.King, 8),
wantOutcome: OutcomeWin, wantPayout: 195, wantRake: 5,
},
{
// 3:2 on 100 is 150 profit, 7 raked (floor of 7.5) → 243 back.
name: "a natural pays 3:2 less rake",
player: hand(cards.Ace, cards.King), dealer: hand(cards.King, 8),
wantOutcome: OutcomeBlackjack, wantPayout: 243, wantRake: 7,
},
{
name: "a push returns the stake untouched — the house takes nothing",
player: hand(cards.King, 9), dealer: hand(cards.Queen, 9),
wantOutcome: OutcomePush, wantPayout: 100, wantRake: 0,
},
{
name: "two naturals push",
player: hand(cards.Ace, cards.King), dealer: hand(cards.Ace, cards.Queen),
wantOutcome: OutcomePush, wantPayout: 100, wantRake: 0,
},
{
name: "a loss pays nothing and is not charged a rake",
player: hand(cards.King, 8), dealer: hand(cards.King, 9),
wantOutcome: OutcomeLose, wantPayout: 0, wantRake: 0,
},
{
name: "a bust pays nothing even if the dealer would have busted too",
player: hand(cards.King, 8, 9), dealer: hand(cards.King, 6, 9),
wantOutcome: OutcomeBust, wantPayout: 0, wantRake: 0,
},
{
name: "dealer blackjack beats the player's twenty",
player: hand(cards.King, cards.Queen), dealer: hand(cards.Ace, cards.Jack),
wantOutcome: OutcomeLose, wantPayout: 0, wantRake: 0,
},
{
name: "dealer bust pays even money less rake",
player: hand(cards.King, 5), dealer: hand(cards.King, 6, 9),
wantOutcome: OutcomeDealerBust, wantPayout: 195, wantRake: 5,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := settleWith(t, r, 100, tc.player, tc.dealer)
if s.Outcome != tc.wantOutcome {
t.Errorf("outcome = %q, want %q", s.Outcome, tc.wantOutcome)
}
if s.Payout != tc.wantPayout {
t.Errorf("payout = %d, want %d", s.Payout, tc.wantPayout)
}
if s.Rake != tc.wantRake {
t.Errorf("rake = %d, want %d", s.Rake, tc.wantRake)
}
// The invariant the ledger depends on: every chip the player staked
// either comes back, goes to the house as rake, or is lost to the table.
if s.Payout < 0 || s.Rake < 0 {
t.Errorf("negative chips: payout=%d rake=%d", s.Payout, s.Rake)
}
})
}
}
func TestSettle_RakeNeverTouchesTheStake(t *testing.T) {
// A 100% rake is absurd, but it must still never claw back a player's own
// stake: the worst a rake can do is take all the winnings.
r := Rules{Decks: 6, BlackjackPays: 1.5, RakePct: 1.0}
s := settleWith(t, r, 100, hand(cards.King, 9), hand(cards.King, 8))
if s.Payout != 100 {
t.Fatalf("payout = %d, want the stake back (100)", s.Payout)
}
if s.Net() != 0 {
t.Fatalf("net = %d, want 0", s.Net())
}
}
func TestNew_DealsFourCardsAndAskThePlayer(t *testing.T) {
rng := cards.NewRNG(1, 2)
s, evs, err := New(50, DefaultRules(), rng)
if err != nil {
t.Fatal(err)
}
if len(s.Player) != 2 || len(s.Dealer) != 2 {
t.Fatalf("dealt %d/%d cards, want 2/2", len(s.Player), len(s.Dealer))
}
if len(s.Deck) != 6*52-4 {
t.Fatalf("shoe has %d cards left, want %d", len(s.Deck), 6*52-4)
}
if len(evs) == 0 || evs[0].Kind != "deal" {
t.Fatal("no deal event")
}
// Unless somebody was dealt a natural, it's the player's move.
if !IsBlackjack(s.Player) && !IsBlackjack(s.Dealer) && s.Phase != PhasePlayer {
t.Fatalf("phase = %q, want %q", s.Phase, PhasePlayer)
}
}
func TestNew_RejectsNonPositiveBet(t *testing.T) {
for _, bet := range []int64{0, -100} {
if _, _, err := New(bet, DefaultRules(), cards.NewRNG(1, 2)); err == nil {
t.Fatalf("bet %d was accepted", bet)
}
}
}
func TestNew_NaturalSettlesImmediately(t *testing.T) {
// Search seeds for a deal that gives the player a natural, then assert the
// hand is already over — a player holding blackjack is never asked to hit.
for seed := uint64(1); seed < 200; seed++ {
s, _, err := New(100, DefaultRules(), cards.NewRNG(seed, seed))
if err != nil {
t.Fatal(err)
}
if IsBlackjack(s.Player) {
if s.Phase != PhaseDone {
t.Fatalf("seed %d: player has a natural but phase = %q", seed, s.Phase)
}
if _, _, err := ApplyMove(s, Hit); err != ErrHandOver {
t.Fatalf("seed %d: hitting a settled natural gave %v, want ErrHandOver", seed, err)
}
return
}
}
t.Skip("no natural dealt in 200 seeds")
}
func TestApplyMove_HitUntilBustSettles(t *testing.T) {
s, _, err := New(100, DefaultRules(), cards.NewRNG(7, 7))
if err != nil {
t.Fatal(err)
}
if s.Phase == PhaseDone {
t.Skip("dealt a natural; not the hand under test")
}
for i := 0; i < 12 && s.Phase == PhasePlayer; i++ {
s, _, err = ApplyMove(s, Hit)
if err != nil {
t.Fatal(err)
}
}
if s.Phase != PhaseDone {
t.Fatal("hitting a dozen times never ended the hand")
}
if v, _ := HandValue(s.Player); v <= 21 {
t.Fatalf("player stopped at %d without busting — the loop should have gone over", v)
}
if s.Outcome != OutcomeBust || s.Payout != 0 {
t.Fatalf("outcome=%q payout=%d, want bust/0", s.Outcome, s.Payout)
}
// A busted player must not have made the dealer draw.
if len(s.Dealer) != 2 {
t.Fatalf("dealer drew %d cards against a busted player", len(s.Dealer)-2)
}
}
func TestApplyMove_StandRunsTheDealerOut(t *testing.T) {
s, _, err := New(100, DefaultRules(), cards.NewRNG(3, 9))
if err != nil {
t.Fatal(err)
}
if s.Phase == PhaseDone {
t.Skip("dealt a natural")
}
s, evs, err := ApplyMove(s, Stand)
if err != nil {
t.Fatal(err)
}
if s.Phase != PhaseDone {
t.Fatalf("phase = %q after stand, want done", s.Phase)
}
v, soft := HandValue(s.Dealer)
if v < 17 {
t.Fatalf("dealer stood on %d, must draw below 17", v)
}
if v == 17 && soft {
t.Fatal("dealer stood on soft 17; the house rule says hit")
}
var reveal bool
for _, e := range evs {
if e.Kind == "reveal" {
reveal = true
}
}
if !reveal {
t.Fatal("dealer played without a reveal event")
}
}
func TestApplyMove_DoubleTakesOneCardThenStands(t *testing.T) {
s, _, err := New(100, DefaultRules(), cards.NewRNG(11, 4))
if err != nil {
t.Fatal(err)
}
if s.Phase == PhaseDone {
t.Skip("dealt a natural")
}
if !s.CanDouble() {
t.Fatal("double should be legal on the opening two cards")
}
s, _, err = ApplyMove(s, Double)
if err != nil {
t.Fatal(err)
}
if !s.Doubled || s.Bet != 200 {
t.Fatalf("bet = %d doubled = %v, want 200/true", s.Bet, s.Doubled)
}
if len(s.Player) != 3 {
t.Fatalf("player has %d cards after a double, want exactly 3", len(s.Player))
}
if s.Phase != PhaseDone {
t.Fatal("a double must end the player's turn")
}
}
func TestApplyMove_DoubleIsIllegalAfterHitting(t *testing.T) {
s, _, err := New(100, DefaultRules(), cards.NewRNG(5, 5))
if err != nil {
t.Fatal(err)
}
if s.Phase == PhaseDone {
t.Skip("dealt a natural")
}
s, _, err = ApplyMove(s, Hit)
if err != nil {
t.Fatal(err)
}
if s.Phase != PhasePlayer {
t.Skip("busted on the hit; not the hand under test")
}
before := s.Bet
after, _, err := ApplyMove(s, Double)
if err != ErrCantDouble {
t.Fatalf("double after a hit gave %v, want ErrCantDouble", err)
}
if after.Bet != before {
t.Fatalf("a rejected double still moved the bet: %d -> %d", before, after.Bet)
}
if s.CanDouble() {
t.Fatal("CanDouble says yes on a three-card hand")
}
}
func TestApplyMove_RejectsGarbage(t *testing.T) {
s, _, err := New(100, DefaultRules(), cards.NewRNG(2, 8))
if err != nil {
t.Fatal(err)
}
if _, _, err := ApplyMove(s, Move("surrender")); err != ErrUnknownMove {
t.Fatalf("got %v, want ErrUnknownMove", err)
}
}
// The engine's state has to survive a redeploy: no timers, no pointers, no
// unexported fields that JSON would quietly drop.
func TestState_RoundTripsThroughJSON(t *testing.T) {
s, _, err := New(100, DefaultRules(), cards.NewRNG(13, 21))
if err != nil {
t.Fatal(err)
}
if s.Phase == PhaseDone {
t.Skip("dealt a natural")
}
blob, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
var back State
if err := json.Unmarshal(blob, &back); err != nil {
t.Fatal(err)
}
// Play both forward identically; a state that survives the trip settles the same.
live, _, err := ApplyMove(s, Stand)
if err != nil {
t.Fatal(err)
}
revived, _, err := ApplyMove(back, Stand)
if err != nil {
t.Fatal(err)
}
if live.Outcome != revived.Outcome || live.Payout != revived.Payout {
t.Fatalf("revived hand settled differently: %q/%d vs %q/%d",
revived.Outcome, revived.Payout, live.Outcome, live.Payout)
}
}
// Same seed, same shoe — this is what lets a disputed hand be re-dealt.
func TestNew_IsReproducibleFromItsSeed(t *testing.T) {
a, _, err := New(100, DefaultRules(), cards.NewRNG(42, 42))
if err != nil {
t.Fatal(err)
}
b, _, err := New(100, DefaultRules(), cards.NewRNG(42, 42))
if err != nil {
t.Fatal(err)
}
if cards.Hand(a.Player) != cards.Hand(b.Player) || cards.Hand(a.Dealer) != cards.Hand(b.Dealer) {
t.Fatalf("same seed dealt different hands: %s/%s vs %s/%s",
cards.Hand(a.Player), cards.Hand(a.Dealer), cards.Hand(b.Player), cards.Hand(b.Dealer))
}
}
// A State handed to ApplyMove twice must produce two independent hands. If the
// engine let derived states share a backing array, the second deal would scribble
// over the first one's cards — and a player could watch a card change under them.
func TestApplyMove_DerivedStatesDoNotShareCards(t *testing.T) {
s, _, err := New(100, DefaultRules(), cards.NewRNG(23, 5))
if err != nil {
t.Fatal(err)
}
if s.Phase == PhaseDone {
t.Skip("dealt a natural")
}
before := cards.Hand(s.Player)
a, _, err := ApplyMove(s, Hit)
if err != nil {
t.Fatal(err)
}
aHand := cards.Hand(a.Player)
if _, _, err := ApplyMove(s, Hit); err != nil { // same start, applied again
t.Fatal(err)
}
if got := cards.Hand(a.Player); got != aHand {
t.Fatalf("the first hand changed under us: %q became %q", aHand, got)
}
if got := cards.Hand(s.Player); got != before {
t.Fatalf("ApplyMove mutated the state it was given: %q became %q", before, got)
}
}

View File

@@ -0,0 +1,126 @@
// Package cards holds the deck primitives every card game on Pete shares.
//
// gogobee never had this: blackjack carried its own deck, UNO carried another,
// and hold'em leaned on a third-party one. Three shuffles, three bugs to fix
// three times. The games ported over here consolidate onto this instead.
//
// Two rules hold throughout:
//
// The RNG is threaded, never global. Every shuffle takes an explicit *rand.Rand,
// so a hand is reproducible from its seed — which is what makes the engines
// testable, and what lets us re-deal a disputed hand and show the player exactly
// what the shoe did.
//
// A Deck is a plain value. No pointers into it, no timers hanging off it, so a
// game in progress serializes to JSON and survives a redeploy.
package cards
import "math/rand/v2"
// Suit is one of the four French suits.
type Suit uint8
const (
Spades Suit = iota
Hearts
Diamonds
Clubs
)
// Rank runs Ace(1) through King(13). Ace is low here; games that want it high
// (blackjack's soft 11, hold'em's wheel) say so themselves.
type Rank uint8
const (
Ace Rank = 1
Jack Rank = 11
Queen Rank = 12
King Rank = 13
)
var (
suitGlyphs = [4]string{"♠", "♥", "♦", "♣"}
rankNames = [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
)
// Card is one playing card. The short JSON keys keep a serialized shoe small —
// a six-deck blackjack state is 312 of these.
type Card struct {
Rank Rank `json:"r"`
Suit Suit `json:"s"`
}
// String renders the card the way a table shows it: "A♠", "10♥".
func (c Card) String() string {
if c.Rank < Ace || c.Rank > King || c.Suit > Clubs {
return "??"
}
return rankNames[c.Rank] + suitGlyphs[c.Suit]
}
// Red reports whether the card is a red suit — the one thing every renderer
// needs and nobody should re-derive.
func (c Card) Red() bool { return c.Suit == Hearts || c.Suit == Diamonds }
// Deck is an ordered pile of cards. The next card to come off is at index 0.
type Deck []Card
// NewDeck builds n standard 52-card decks in fixed order. Shuffle before use:
// an unshuffled deck is a bug at a table, but it's exactly what a test wants.
func NewDeck(n int) Deck {
if n < 1 {
n = 1
}
d := make(Deck, 0, 52*n)
for i := 0; i < n; i++ {
for s := Spades; s <= Clubs; s++ {
for r := Ace; r <= King; r++ {
d = append(d, Card{Rank: r, Suit: s})
}
}
}
return d
}
// Shuffle permutes the deck in place using the supplied RNG. Passing a seeded
// *rand.Rand gives the same shuffle every time, which is the whole point.
func (d Deck) Shuffle(rng *rand.Rand) {
rng.Shuffle(len(d), func(i, j int) { d[i], d[j] = d[j], d[i] })
}
// Draw takes the top card. ok is false when the deck is spent; the caller
// decides whether that means reshuffle or fold, because the two games that hit
// it disagree.
func (d *Deck) Draw() (c Card, ok bool) {
if len(*d) == 0 {
return Card{}, false
}
c = (*d)[0]
*d = (*d)[1:]
return c, true
}
// Hand renders a run of cards for display: "A♠ 10♥".
func Hand(cs []Card) string {
s := ""
for i, c := range cs {
if i > 0 {
s += " "
}
s += c.String()
}
return s
}
// NewRNG seeds a generator from two uint64s. Games store the seed alongside the
// hand so a finished hand can be replayed exactly as it was dealt.
func NewRNG(seed1, seed2 uint64) *rand.Rand {
return rand.New(rand.NewPCG(seed1, seed2))
}
func (s Suit) String() string {
if s > Clubs {
return "?"
}
return suitGlyphs[s]
}

View File

@@ -0,0 +1,102 @@
package cards
import "testing"
func TestNewDeck_IsAFullShoe(t *testing.T) {
d := NewDeck(6)
if len(d) != 312 {
t.Fatalf("six decks hold %d cards, want 312", len(d))
}
seen := map[Card]int{}
for _, c := range d {
seen[c]++
}
if len(seen) != 52 {
t.Fatalf("%d distinct cards, want 52", len(seen))
}
for c, n := range seen {
if n != 6 {
t.Fatalf("%s appears %d times in a six-deck shoe, want 6", c, n)
}
}
}
func TestNewDeck_ClampsToAtLeastOne(t *testing.T) {
if len(NewDeck(0)) != 52 {
t.Fatal("a zero-deck shoe should still hold one deck")
}
}
func TestShuffle_SameSeedSameOrder(t *testing.T) {
a, b := NewDeck(1), NewDeck(1)
a.Shuffle(NewRNG(99, 1))
b.Shuffle(NewRNG(99, 1))
for i := range a {
if a[i] != b[i] {
t.Fatalf("same seed diverged at %d: %s vs %s", i, a[i], b[i])
}
}
// And a different seed must not give the same order, or the RNG isn't wired up.
c := NewDeck(1)
c.Shuffle(NewRNG(100, 1))
same := true
for i := range a {
if a[i] != c[i] {
same = false
break
}
}
if same {
t.Fatal("a different seed produced an identical shuffle")
}
}
func TestShuffle_KeepsEveryCard(t *testing.T) {
d := NewDeck(1)
d.Shuffle(NewRNG(4, 4))
seen := map[Card]bool{}
for _, c := range d {
seen[c] = true
}
if len(d) != 52 || len(seen) != 52 {
t.Fatalf("shuffle lost cards: %d cards, %d distinct", len(d), len(seen))
}
}
func TestDraw_TakesFromTheTopAndRunsOut(t *testing.T) {
d := NewDeck(1)
top := d[0]
c, ok := d.Draw()
if !ok || c != top {
t.Fatalf("drew %s (ok=%v), want the top card %s", c, ok, top)
}
if len(d) != 51 {
t.Fatalf("deck has %d cards after one draw, want 51", len(d))
}
for len(d) > 0 {
d.Draw()
}
if _, ok := d.Draw(); ok {
t.Fatal("an empty deck kept dealing")
}
}
func TestCard_String(t *testing.T) {
tests := []struct {
card Card
want string
}{
{Card{Ace, Spades}, "A♠"},
{Card{10, Hearts}, "10♥"},
{Card{King, Clubs}, "K♣"},
{Card{Rank: 99, Suit: Spades}, "??"},
}
for _, tc := range tests {
if got := tc.card.String(); got != tc.want {
t.Errorf("String() = %q, want %q", got, tc.want)
}
}
if !(Card{Ace, Hearts}).Red() || (Card{Ace, Spades}).Red() {
t.Error("Red() disagrees about which suits are red")
}
}

View File

@@ -37,7 +37,9 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
if s.Lede != "" {
plainParts = append(plainParts, s.Lede)
}
plainParts = append(plainParts, formatSourceTag(s.Source, s.Platforms, false))
if tag := formatSourceTag(s.Source, s.Platforms, false); tag != "" {
plainParts = append(plainParts, tag)
}
plain = strings.Join(plainParts, "\n")
// HTML body
@@ -55,25 +57,32 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
if s.Lede != "" {
htmlParts = append(htmlParts, html.EscapeString(s.Lede))
}
htmlParts = append(htmlParts, formatSourceTag(s.Source, s.Platforms, true))
if tag := formatSourceTag(s.Source, s.Platforms, true); tag != "" {
htmlParts = append(htmlParts, tag)
}
htmlBody = strings.Join(htmlParts, "<br/>")
return plain, htmlBody
}
// formatSourceTag builds the source + platform tags line.
// formatSourceTag builds the source + platform tags line. An empty source is
// omitted rather than tagged: Pete's own reporting has no outlet to credit, and
// an empty tag would read as him signing his own name.
func formatSourceTag(source string, platforms []string, isHTML bool) string {
if isHTML {
parts := []string{fmt.Sprintf("<code>%s</code>", html.EscapeString(strings.ToLower(source)))}
for _, p := range platforms {
parts = append(parts, fmt.Sprintf("<code>%s</code>", html.EscapeString(p)))
}
return strings.Join(parts, " \u00b7 ")
var parts []string
if source != "" {
parts = append(parts, strings.ToLower(source))
}
parts := []string{fmt.Sprintf("`%s`", strings.ToLower(source))}
for _, p := range platforms {
parts = append(parts, fmt.Sprintf("`%s`", p))
parts = append(parts, platforms...)
if len(parts) == 0 {
return ""
}
for i, p := range parts {
if isHTML {
parts[i] = fmt.Sprintf("<code>%s</code>", html.EscapeString(p))
} else {
parts[i] = fmt.Sprintf("`%s`", p)
}
}
return strings.Join(parts, " \u00b7 ")
}

601
internal/storage/games.go Normal file
View File

@@ -0,0 +1,601 @@
package storage
import (
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"time"
)
// The chip ledger and the euro/chip border.
//
// Chips are euros that have crossed into the casino. They are 1:1 with euros and
// they are not a second wallet: every chip that exists came from a euro gogobee
// debited, and every chip destroyed becomes a euro gogobee credits back. Pete
// never writes a euro balance. The border is crossed only by a game_escrow row,
// whose guid is the idempotency key gogobee hands to DebitIdem/CreditIdem — so a
// claim whose acknowledgement is lost on the wire can be retried without the
// player paying for it twice.
//
// The whole reason for the border is latency. gogobee has no inbound API and is
// not getting one, so it polls; a bet that round-tripped through a poll loop
// would take seconds to be dealt. Instead the poll loop runs twice per *session*
// — buy in, cash out — and every hand in between plays against chips held here,
// at full speed, with no economy call in the hot path.
// MaxChipsOnTable caps how many chips a player can hold at once. A buy-in that
// would push them over is refused before it ever reaches gogobee.
//
// This is the inflation brake. A web casino runs orders of magnitude more hands
// per hour than a Matrix-paced one ever did, so whatever the house edge is, it
// compounds far faster in both directions. The cap bounds the worst case for a
// single sitting; the rake (see the blackjack engine) bleeds the rest back out.
const MaxChipsOnTable int64 = 10_000
// EscrowStaleAfter is how long a claimed-but-unsettled escrow row waits before
// the poll endpoint offers it again. gogobee can die between claiming a row and
// pushing its result; without a re-offer, the player's money sits in limbo
// forever. Re-claiming is safe precisely because the guid makes it idempotent.
const EscrowStaleAfter = 90 * time.Second
// SessionIdleAfter is when the reaper decides a player has walked away and cashes
// their chips back to euros on their behalf. Chips in an abandoned session are
// euros in limbo, and limbo is not a state a player's money should be in.
const SessionIdleAfter = 30 * time.Minute
// Escrow kinds and states. These strings cross the wire to gogobee, so they are
// part of the contract — see §4 of pete_games_plan.md.
const (
KindBuyIn = "buyin"
KindCashOut = "cashout"
EscrowRequested = "requested" // the player asked; gogobee hasn't seen it yet
EscrowClaimed = "claimed" // gogobee has it and is moving the euros
EscrowFunded = "funded" // buy-in landed; the chips are spendable
EscrowRejected = "rejected" // buy-in refused; no chips, no euros moved
EscrowSettled = "settled" // cash-out landed; chips destroyed, euros credited
)
var (
ErrInsufficientChips = errors.New("games: not enough chips")
ErrOverTableCap = errors.New("games: that would put more than the cap on the table")
ErrBadAmount = errors.New("games: amount must be positive")
ErrNoSuchEscrow = errors.New("games: no such escrow row")
)
// Escrow is one crossing of the euro/chip border.
type Escrow struct {
GUID string `json:"guid"`
MatrixUser string `json:"matrix_user"`
Kind string `json:"kind"`
Amount int64 `json:"amount"`
State string `json:"state,omitempty"`
Reason string `json:"reason,omitempty"`
BalanceAfter float64 `json:"balance_after,omitempty"`
CreatedAt int64 `json:"created_at,omitempty"`
}
// ChipStack is what a player has on the table right now.
type ChipStack struct {
Chips int64 // spendable
// Pending is chips asked for but not yet funded — a buy-in gogobee hasn't
// claimed or settled. Shown as "buying chips…", never spendable.
Pending int64
EuroBalance float64 // advisory, from the last gogobee push; may be minutes stale
LastPlayed int64
}
// newGUID mints an escrow id. It's the idempotency key for a real money move, so
// it comes from crypto/rand rather than anything a caller could collide with.
func newGUID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("games: mint guid: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// Chips reports a player's stack. A player who has never played has no row and
// reads as an empty stack rather than an error.
func Chips(user string) (ChipStack, error) {
var st ChipStack
var euro sql.NullFloat64
err := Get().QueryRow(
`SELECT chips, euro_balance, last_played FROM game_chips WHERE matrix_user = ?`, user,
).Scan(&st.Chips, &euro, &st.LastPlayed)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return ChipStack{}, fmt.Errorf("games: read chips: %w", err)
}
st.EuroBalance = euro.Float64
if err := Get().QueryRow(
`SELECT COALESCE(SUM(amount), 0) FROM game_escrow
WHERE matrix_user = ? AND kind = ? AND state IN (?, ?)`,
user, KindBuyIn, EscrowRequested, EscrowClaimed,
).Scan(&st.Pending); err != nil {
return ChipStack{}, fmt.Errorf("games: read pending buy-ins: %w", err)
}
return st, nil
}
// RequestBuyIn opens a buy-in: the player wants `amount` euros turned into chips.
// No chips exist yet — they appear only when gogobee confirms it took the euros.
// The table cap is enforced here, against chips already held *plus* buy-ins still
// in flight, so a player can't clear the cap by firing several at once.
func RequestBuyIn(user string, amount int64) (Escrow, error) {
if amount <= 0 {
return Escrow{}, ErrBadAmount
}
st, err := Chips(user)
if err != nil {
return Escrow{}, err
}
if st.Chips+st.Pending+amount > MaxChipsOnTable {
return Escrow{}, ErrOverTableCap
}
guid, err := newGUID()
if err != nil {
return Escrow{}, err
}
now := nowUnix()
if _, err := Get().Exec(
`INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
guid, user, KindBuyIn, amount, EscrowRequested, now, now,
); err != nil {
return Escrow{}, fmt.Errorf("games: request buy-in: %w", err)
}
return Escrow{GUID: guid, MatrixUser: user, Kind: KindBuyIn, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil
}
// RequestCashOut opens a cash-out: chips are destroyed *now*, and the matching
// euros arrive when gogobee claims the row.
//
// Destroying them up front is what keeps the invariant true. If the chips lingered
// until gogobee confirmed, a player could bet them while the cash-out was in
// flight and the same euro would exist on both sides of the border. If the credit
// somehow fails, RefundCashOut puts the chips back.
func RequestCashOut(user string, amount int64) (Escrow, error) {
if amount <= 0 {
return Escrow{}, ErrBadAmount
}
guid, err := newGUID()
if err != nil {
return Escrow{}, err
}
now := nowUnix()
tx, err := Get().Begin()
if err != nil {
return Escrow{}, fmt.Errorf("games: begin cash-out: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
// Conditional update: the chips leave only if they're actually there.
res, err := tx.Exec(
`UPDATE game_chips SET chips = chips - ?, updated_at = ?
WHERE matrix_user = ? AND chips >= ?`,
amount, now, user, amount,
)
if err != nil {
return Escrow{}, fmt.Errorf("games: debit chips: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return Escrow{}, ErrInsufficientChips
}
if _, err := tx.Exec(
`INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
guid, user, KindCashOut, amount, EscrowRequested, now, now,
); err != nil {
return Escrow{}, fmt.Errorf("games: request cash-out: %w", err)
}
if err := tx.Commit(); err != nil {
return Escrow{}, fmt.Errorf("games: commit cash-out: %w", err)
}
return Escrow{GUID: guid, MatrixUser: user, Kind: KindCashOut, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil
}
// PendingEscrow is what gogobee's poll loop reads: everything waiting to be moved.
//
// It returns rows nobody has claimed, *and* rows claimed long enough ago that we
// have to assume gogobee died holding them. Re-offering a claimed row is safe
// because the guid is idempotent end to end: if gogobee already moved the euros,
// the retry is a no-op that reports the same answer.
func PendingEscrow(limit int) ([]Escrow, error) {
if limit <= 0 {
limit = 100
}
stale := nowUnix() - int64(EscrowStaleAfter.Seconds())
rows, err := Get().Query(
`SELECT guid, matrix_user, kind, amount, state, created_at
FROM game_escrow
WHERE state = ?
OR (state = ? AND COALESCE(claimed_at, 0) < ?)
ORDER BY created_at
LIMIT ?`,
EscrowRequested, EscrowClaimed, stale, limit,
)
if err != nil {
return nil, fmt.Errorf("games: pending escrow: %w", err)
}
defer rows.Close()
var out []Escrow
for rows.Next() {
var e Escrow
if err := rows.Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("games: scan escrow: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// ClaimEscrow marks a row as taken by gogobee. Claiming is idempotent and is not
// a lock: a row already claimed can be claimed again (that's how a stale re-offer
// works), but a row already *finished* cannot be, which is what stops a settled
// cash-out from being paid a second time.
func ClaimEscrow(guid string) (Escrow, error) {
now := nowUnix()
res, err := Get().Exec(
`UPDATE game_escrow SET state = ?, claimed_at = ?, updated_at = ?
WHERE guid = ? AND state IN (?, ?)`,
EscrowClaimed, now, now, guid, EscrowRequested, EscrowClaimed,
)
if err != nil {
return Escrow{}, fmt.Errorf("games: claim escrow: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
// Either it doesn't exist or it's already finished. Tell the caller which.
e, err := EscrowByGUID(guid)
if err != nil {
return Escrow{}, err
}
return e, nil
}
return EscrowByGUID(guid)
}
// EscrowByGUID reads one row.
func EscrowByGUID(guid string) (Escrow, error) {
var e Escrow
var reason sql.NullString
var bal sql.NullFloat64
err := Get().QueryRow(
`SELECT guid, matrix_user, kind, amount, state, reason, balance_after, created_at
FROM game_escrow WHERE guid = ?`, guid,
).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &reason, &bal, &e.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Escrow{}, ErrNoSuchEscrow
}
if err != nil {
return Escrow{}, fmt.Errorf("games: read escrow: %w", err)
}
e.Reason, e.BalanceAfter = reason.String, bal.Float64
return e, nil
}
// SettleEscrow applies gogobee's verdict on a claimed row, and is the only place
// chips are created or finally destroyed.
//
// buy-in, ok -> chips appear (funded)
// buy-in, !ok -> nothing happens, nothing moved (rejected)
// cash-out, ok -> chips stay destroyed, euros paid (settled)
// cash-out, !ok -> chips come back (funded — the player never lost them)
//
// It is idempotent: gogobee's push queue retries, so the same verdict can arrive
// more than once and only the first one may move chips. A row that has already
// reached a terminal state is a no-op, not an error.
func SettleEscrow(guid string, ok bool, reason string, balanceAfter float64) (Escrow, error) {
now := nowUnix()
tx, err := Get().Begin()
if err != nil {
return Escrow{}, fmt.Errorf("games: begin settle: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
var e Escrow
var st string
if err := tx.QueryRow(
`SELECT guid, matrix_user, kind, amount, state FROM game_escrow WHERE guid = ?`, guid,
).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &st); errors.Is(err, sql.ErrNoRows) {
return Escrow{}, ErrNoSuchEscrow
} else if err != nil {
return Escrow{}, fmt.Errorf("games: settle lookup: %w", err)
}
// Terminal already — a retried push. Report what we decided the first time.
if st == EscrowFunded || st == EscrowRejected || st == EscrowSettled {
if err := tx.Commit(); err != nil {
return Escrow{}, fmt.Errorf("games: commit settle: %w", err)
}
return EscrowByGUID(guid)
}
final := EscrowFunded
switch {
case e.Kind == KindBuyIn && ok:
if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil {
return Escrow{}, err
}
case e.Kind == KindBuyIn && !ok:
final = EscrowRejected // gogobee took nothing, so we create nothing
case e.Kind == KindCashOut && ok:
final = EscrowSettled // the chips were destroyed when the row was opened
case e.Kind == KindCashOut && !ok:
// gogobee couldn't pay. The chips were already destroyed on our side, so
// give them back rather than vanishing the player's money.
if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil {
return Escrow{}, err
}
}
if _, err := tx.Exec(
`UPDATE game_escrow SET state = ?, reason = ?, balance_after = ?, updated_at = ?
WHERE guid = ?`,
final, reason, balanceAfter, now, guid,
); err != nil {
return Escrow{}, fmt.Errorf("games: settle update: %w", err)
}
// The euro balance gogobee just reported is the freshest one we'll get.
// Advisory only — we display it, we never decide anything with it.
if _, err := tx.Exec(
`UPDATE game_chips SET euro_balance = ?, updated_at = ? WHERE matrix_user = ?`,
balanceAfter, now, e.MatrixUser,
); err != nil {
return Escrow{}, fmt.Errorf("games: cache euro balance: %w", err)
}
if err := tx.Commit(); err != nil {
return Escrow{}, fmt.Errorf("games: commit settle: %w", err)
}
return EscrowByGUID(guid)
}
// addChips credits a stack inside an open transaction, creating the row if the
// player has never held chips before.
func addChips(tx *sql.Tx, user string, amount int64, now int64) error {
if _, err := tx.Exec(
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(matrix_user) DO UPDATE SET chips = chips + excluded.chips, updated_at = excluded.updated_at`,
user, amount, now, now,
); err != nil {
return fmt.Errorf("games: credit chips: %w", err)
}
return nil
}
// Stake takes chips off a player's stack to put them at risk on a hand. It is the
// conditional-update kind of debit: the chips leave in the same statement that
// checks they're there, so two hands opened at once can't spend the same chip.
func Stake(user string, amount int64) error {
if amount <= 0 {
return ErrBadAmount
}
now := nowUnix()
res, err := Get().Exec(
`UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ?
WHERE matrix_user = ? AND chips >= ?`,
amount, now, now, user, amount,
)
if err != nil {
return fmt.Errorf("games: stake: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrInsufficientChips
}
return nil
}
// Award returns chips to a player when a hand settles: stake plus winnings, net
// of rake, exactly as the engine computed it. A losing hand awards nothing and
// should not call this.
func Award(user string, amount int64) error {
if amount <= 0 {
return nil
}
now := nowUnix()
if _, err := Get().Exec(
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(matrix_user) DO UPDATE SET
chips = chips + excluded.chips, last_played = excluded.last_played, updated_at = excluded.updated_at`,
user, amount, now, now,
); err != nil {
return fmt.Errorf("games: award chips: %w", err)
}
return nil
}
// Hand is one settled hand, as the audit log keeps it.
type Hand struct {
MatrixUser string
Game string
Bet int64
Payout int64
Rake int64
Outcome string
Seed1 uint64
Seed2 uint64
}
// RecordHand writes a finished hand to the audit trail. The seeds are the point:
// with them, any hand in the log can be dealt again exactly as it fell, which is
// how a dispute gets answered with a fact instead of an apology.
func RecordHand(h Hand) error {
if _, err := Get().Exec(
`INSERT INTO game_hands (matrix_user, game, bet, payout, rake, outcome, seed1, seed2, played_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
h.MatrixUser, h.Game, h.Bet, h.Payout, h.Rake, h.Outcome,
int64(h.Seed1), int64(h.Seed2), nowUnix(),
); err != nil {
return fmt.Errorf("games: record hand: %w", err)
}
return nil
}
// IdleStacks lists players holding chips who stopped playing a while ago. The
// reaper cashes these out on their behalf: chips in an abandoned session are
// euros in limbo, and they should be back in the player's balance where they can
// see them.
func IdleStacks(idleFor time.Duration) ([]ChipStack, []string, error) {
cutoff := nowUnix() - int64(idleFor.Seconds())
rows, err := Get().Query(
`SELECT matrix_user, chips, last_played FROM game_chips
WHERE chips > 0 AND last_played > 0 AND last_played < ?`, cutoff,
)
if err != nil {
return nil, nil, fmt.Errorf("games: idle stacks: %w", err)
}
defer rows.Close()
var stacks []ChipStack
var users []string
for rows.Next() {
var st ChipStack
var user string
if err := rows.Scan(&user, &st.Chips, &st.LastPlayed); err != nil {
return nil, nil, fmt.Errorf("games: scan idle stack: %w", err)
}
stacks = append(stacks, st)
users = append(users, user)
}
return stacks, users, rows.Err()
}
// ReapIdleSessions cashes out everyone who walked away, and reports how many it
// sent home. Safe to run on a timer: a player who comes back simply buys in again,
// and a cash-out that's already in flight can't be opened twice because the chips
// are gone from the stack the moment the first one is.
func ReapIdleSessions(idleFor time.Duration) (int, error) {
stacks, users, err := IdleStacks(idleFor)
if err != nil {
return 0, err
}
reaped := 0
for i, user := range users {
if _, err := RequestCashOut(user, stacks[i].Chips); err != nil {
// One player's stack failing to reap shouldn't strand everyone else's.
if !errors.Is(err, ErrInsufficientChips) {
return reaped, fmt.Errorf("games: reap %s: %w", user, err)
}
continue
}
reaped++
}
return reaped, nil
}
// Touch marks a player as active, so the reaper leaves them alone. Called on any
// deliberate action at a table — not on a page load, or an open tab would keep a
// walked-away player's chips hostage forever.
func Touch(user string) {
exec("games: touch session",
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
nowUnix(), nowUnix(), user)
}
// ---- the hand in progress -------------------------------------------------
var (
// ErrNoLiveHand means the player isn't in a hand right now.
ErrNoLiveHand = errors.New("games: no hand in progress")
// ErrHandInProgress means they already are, and may not be dealt another.
ErrHandInProgress = errors.New("games: already in a hand")
)
// LiveHand is a hand a player is in the middle of. State is the engine's own
// State, serialized whole — the shoe is in there, which is exactly why this row
// never leaves the server.
type LiveHand struct {
Game string
State []byte
Seed1 uint64
Seed2 uint64
}
// StartLiveHand seats a *new* hand, and refuses if the player is already in one.
// The plain INSERT is the point: it is the primary key, not a prior read, that
// decides. Two Deal clicks racing each other would otherwise both see an empty
// felt, both take a stake, and the second would overwrite the first — taking the
// player's chips for a hand that no longer exists anywhere.
func StartLiveHand(user string, h LiveHand) error {
res, err := Get().Exec(
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(matrix_user) DO NOTHING`,
user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), nowUnix(),
)
if err != nil {
return fmt.Errorf("games: start live hand: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrHandInProgress
}
return nil
}
// SaveLiveHand stores the hand a player is in, replacing any earlier one. The
// player's stake has already left their stack by the time this is called, so
// the write is what makes the hand recoverable if Pete restarts mid-deal.
func SaveLiveHand(user string, h LiveHand) error {
now := nowUnix()
if _, err := Get().Exec(
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(matrix_user) DO UPDATE SET
game = excluded.game, state = excluded.state,
seed1 = excluded.seed1, seed2 = excluded.seed2, updated_at = excluded.updated_at`,
user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), now,
); err != nil {
return fmt.Errorf("games: save live hand: %w", err)
}
return nil
}
// LoadLiveHand returns the hand a player is in, or ErrNoLiveHand.
func LoadLiveHand(user string) (LiveHand, error) {
var h LiveHand
var state string
var s1, s2 int64
err := Get().QueryRow(
`SELECT game, state, seed1, seed2 FROM game_live_hands WHERE matrix_user = ?`, user,
).Scan(&h.Game, &state, &s1, &s2)
if errors.Is(err, sql.ErrNoRows) {
return LiveHand{}, ErrNoLiveHand
}
if err != nil {
return LiveHand{}, fmt.Errorf("games: load live hand: %w", err)
}
h.State, h.Seed1, h.Seed2 = []byte(state), uint64(s1), uint64(s2)
return h, nil
}
// ClearLiveHand ends a hand. Called when it settles — the audit log in
// game_hands is what survives it.
func ClearLiveHand(user string) error {
if _, err := Get().Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil {
return fmt.Errorf("games: clear live hand: %w", err)
}
return nil
}
// HouseTake is the total rake collected since a given time — the number that
// answers "is this economy inflating".
func HouseTake(since int64) (int64, error) {
var total int64
if err := Get().QueryRow(
`SELECT COALESCE(SUM(rake), 0) FROM game_hands WHERE played_at >= ?`, since,
).Scan(&total); err != nil {
return 0, fmt.Errorf("games: house take: %w", err)
}
return total, nil
}

View File

@@ -0,0 +1,494 @@
package storage
import (
"errors"
"testing"
"time"
)
const player = "@reala:parodia.dev"
// fund runs a buy-in all the way through the happy path, so a test that needs
// chips on the table can just say so.
func fund(t *testing.T, user string, amount int64) {
t.Helper()
e, err := RequestBuyIn(user, amount)
if err != nil {
t.Fatal(err)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil {
t.Fatal(err)
}
}
func chipsOf(t *testing.T, user string) int64 {
t.Helper()
st, err := Chips(user)
if err != nil {
t.Fatal(err)
}
return st.Chips
}
func TestBuyIn_ChipsOnlyExistOnceGogobeeConfirms(t *testing.T) {
setupTestDB(t)
e, err := RequestBuyIn(player, 500)
if err != nil {
t.Fatal(err)
}
if e.State != EscrowRequested {
t.Fatalf("state = %q, want %q", e.State, EscrowRequested)
}
// Requested is not funded. Nothing is spendable yet — gogobee hasn't taken
// the euros, so creating chips here would mint money out of nothing.
st, err := Chips(player)
if err != nil {
t.Fatal(err)
}
if st.Chips != 0 {
t.Fatalf("chips = %d before settlement, want 0", st.Chips)
}
if st.Pending != 500 {
t.Fatalf("pending = %d, want 500", st.Pending)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
t.Fatal(err)
}
st, err = Chips(player)
if err != nil {
t.Fatal(err)
}
if st.Chips != 500 {
t.Fatalf("chips = %d after funding, want 500", st.Chips)
}
if st.Pending != 0 {
t.Fatalf("pending = %d after funding, want 0", st.Pending)
}
if st.EuroBalance != 4500 {
t.Fatalf("advisory euro balance = %v, want 4500", st.EuroBalance)
}
}
func TestBuyIn_RejectedCreatesNoChips(t *testing.T) {
setupTestDB(t)
e, err := RequestBuyIn(player, 500)
if err != nil {
t.Fatal(err)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 12)
if err != nil {
t.Fatal(err)
}
if got.State != EscrowRejected {
t.Fatalf("state = %q, want %q", got.State, EscrowRejected)
}
if c := chipsOf(t, player); c != 0 {
t.Fatalf("chips = %d after a rejected buy-in, want 0", c)
}
}
// The push queue retries. A verdict that lands twice must only move chips once.
func TestSettle_IsIdempotent(t *testing.T) {
setupTestDB(t)
e, err := RequestBuyIn(player, 500)
if err != nil {
t.Fatal(err)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
t.Fatalf("settle %d: %v", i, err)
}
}
if c := chipsOf(t, player); c != 500 {
t.Fatalf("chips = %d after three identical pushes, want 500", c)
}
}
// A late, contradictory push must not overturn a settled row — otherwise a
// delayed "rejected" could confiscate chips the player already won hands with.
func TestSettle_TerminalStateWins(t *testing.T) {
setupTestDB(t)
e, err := RequestBuyIn(player, 500)
if err != nil {
t.Fatal(err)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
t.Fatal(err)
}
got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 0)
if err != nil {
t.Fatal(err)
}
if got.State != EscrowFunded {
t.Fatalf("state = %q, want the original %q", got.State, EscrowFunded)
}
if c := chipsOf(t, player); c != 500 {
t.Fatalf("chips = %d, want 500 — a late rejection took funded chips", c)
}
}
func TestCashOut_ChipsLeaveImmediately(t *testing.T) {
setupTestDB(t)
fund(t, player, 1000)
e, err := RequestCashOut(player, 400)
if err != nil {
t.Fatal(err)
}
// The chips are gone the moment the row opens. If they lingered until gogobee
// confirmed, the player could bet them while the euros were also in flight —
// the same euro on both sides of the border.
if c := chipsOf(t, player); c != 600 {
t.Fatalf("chips = %d immediately after cash-out, want 600", c)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
got, err := SettleEscrow(e.GUID, true, "", 4400)
if err != nil {
t.Fatal(err)
}
if got.State != EscrowSettled {
t.Fatalf("state = %q, want %q", got.State, EscrowSettled)
}
if c := chipsOf(t, player); c != 600 {
t.Fatalf("chips = %d after settlement, want 600", c)
}
}
func TestCashOut_FailedCreditGivesTheChipsBack(t *testing.T) {
setupTestDB(t)
fund(t, player, 1000)
e, err := RequestCashOut(player, 400)
if err != nil {
t.Fatal(err)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
// gogobee couldn't pay. The chips were already destroyed here, so they have to
// come back — the player's money cannot simply evaporate at the border.
if _, err := SettleEscrow(e.GUID, false, "ledger_error", 0); err != nil {
t.Fatal(err)
}
if c := chipsOf(t, player); c != 1000 {
t.Fatalf("chips = %d after a failed cash-out, want the full 1000 back", c)
}
}
func TestCashOut_CannotExceedTheStack(t *testing.T) {
setupTestDB(t)
fund(t, player, 100)
if _, err := RequestCashOut(player, 500); !errors.Is(err, ErrInsufficientChips) {
t.Fatalf("err = %v, want ErrInsufficientChips", err)
}
if c := chipsOf(t, player); c != 100 {
t.Fatalf("chips = %d after a refused cash-out, want 100", c)
}
}
func TestBuyIn_TableCapCountsChipsAndInFlightBuyIns(t *testing.T) {
setupTestDB(t)
if _, err := RequestBuyIn(player, MaxChipsOnTable+1); !errors.Is(err, ErrOverTableCap) {
t.Fatalf("err = %v, want ErrOverTableCap", err)
}
fund(t, player, MaxChipsOnTable-1000)
// A second buy-in that fits is fine.
if _, err := RequestBuyIn(player, 1000); err != nil {
t.Fatal(err)
}
// A third, while the second is still in flight, must not clear the cap by
// racing — pending buy-ins count against it.
if _, err := RequestBuyIn(player, 1); !errors.Is(err, ErrOverTableCap) {
t.Fatalf("err = %v, want ErrOverTableCap — in-flight buy-ins must count", err)
}
}
func TestRequest_RejectsNonPositiveAmounts(t *testing.T) {
setupTestDB(t)
fund(t, player, 100)
for _, amount := range []int64{0, -50} {
if _, err := RequestBuyIn(player, amount); !errors.Is(err, ErrBadAmount) {
t.Errorf("buy-in %d: err = %v, want ErrBadAmount", amount, err)
}
if _, err := RequestCashOut(player, amount); !errors.Is(err, ErrBadAmount) {
t.Errorf("cash-out %d: err = %v, want ErrBadAmount", amount, err)
}
}
if c := chipsOf(t, player); c != 100 {
t.Fatalf("chips = %d, want 100", c)
}
}
func TestStakeAndAward(t *testing.T) {
setupTestDB(t)
fund(t, player, 500)
if err := Stake(player, 100); err != nil {
t.Fatal(err)
}
if c := chipsOf(t, player); c != 400 {
t.Fatalf("chips = %d after staking 100, want 400", c)
}
// A win pays the stake back plus the profit, net of rake.
if err := Award(player, 195); err != nil {
t.Fatal(err)
}
if c := chipsOf(t, player); c != 595 {
t.Fatalf("chips = %d after a 195 payout, want 595", c)
}
// You cannot bet chips you don't have.
if err := Stake(player, 10_000); !errors.Is(err, ErrInsufficientChips) {
t.Fatalf("err = %v, want ErrInsufficientChips", err)
}
if c := chipsOf(t, player); c != 595 {
t.Fatalf("chips = %d after a refused stake, want 595", c)
}
}
func TestStake_UnknownPlayerHasNothingToBet(t *testing.T) {
setupTestDB(t)
if err := Stake("@stranger:parodia.dev", 10); !errors.Is(err, ErrInsufficientChips) {
t.Fatalf("err = %v, want ErrInsufficientChips", err)
}
}
func TestPendingEscrow_OffersUnclaimedAndAbandonedRows(t *testing.T) {
setupTestDB(t)
fresh, err := RequestBuyIn(player, 100)
if err != nil {
t.Fatal(err)
}
abandoned, err := RequestBuyIn("@other:parodia.dev", 200)
if err != nil {
t.Fatal(err)
}
pending, err := PendingEscrow(10)
if err != nil {
t.Fatal(err)
}
if len(pending) != 2 {
t.Fatalf("%d pending rows, want 2", len(pending))
}
// Claim both: neither should be offered again while gogobee is working on them.
for _, e := range []Escrow{fresh, abandoned} {
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
}
pending, err = PendingEscrow(10)
if err != nil {
t.Fatal(err)
}
if len(pending) != 0 {
t.Fatalf("%d rows offered while claimed, want 0", len(pending))
}
// Now pretend gogobee died holding one of them. A claim that never came back
// must be re-offered, or the player's money sits in limbo forever.
stale := nowUnix() - int64(EscrowStaleAfter.Seconds()) - 1
if _, err := Get().Exec(`UPDATE game_escrow SET claimed_at = ? WHERE guid = ?`, stale, abandoned.GUID); err != nil {
t.Fatal(err)
}
pending, err = PendingEscrow(10)
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 || pending[0].GUID != abandoned.GUID {
t.Fatalf("stale claim was not re-offered: got %d rows", len(pending))
}
}
func TestClaim_CannotReopenAFinishedRow(t *testing.T) {
setupTestDB(t)
fund(t, player, 300)
e, err := RequestCashOut(player, 300)
if err != nil {
t.Fatal(err)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil {
t.Fatal(err)
}
// Re-claiming a settled cash-out must not walk it back to claimed, or gogobee
// would pay the same euros out twice.
got, err := ClaimEscrow(e.GUID)
if err != nil {
t.Fatal(err)
}
if got.State != EscrowSettled {
t.Fatalf("state = %q after re-claiming a settled row, want %q", got.State, EscrowSettled)
}
}
func TestEscrow_UnknownGUID(t *testing.T) {
setupTestDB(t)
if _, err := EscrowByGUID("nope"); !errors.Is(err, ErrNoSuchEscrow) {
t.Fatalf("err = %v, want ErrNoSuchEscrow", err)
}
if _, err := SettleEscrow("nope", true, "", 0); !errors.Is(err, ErrNoSuchEscrow) {
t.Fatalf("err = %v, want ErrNoSuchEscrow", err)
}
}
func TestReaper_CashesOutTheWalkedAway(t *testing.T) {
setupTestDB(t)
fund(t, player, 700)
// Still playing: the reaper must leave them alone.
Touch(player)
n, err := ReapIdleSessions(SessionIdleAfter)
if err != nil {
t.Fatal(err)
}
if n != 0 || chipsOf(t, player) != 700 {
t.Fatalf("reaped %d active players (chips now %d)", n, chipsOf(t, player))
}
// Now they've been gone an hour.
old := nowUnix() - int64((2 * time.Hour).Seconds())
if _, err := Get().Exec(`UPDATE game_chips SET last_played = ? WHERE matrix_user = ?`, old, player); err != nil {
t.Fatal(err)
}
n, err = ReapIdleSessions(SessionIdleAfter)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("reaped %d, want 1", n)
}
if c := chipsOf(t, player); c != 0 {
t.Fatalf("chips = %d after the reaper ran, want 0 — they should be euros now", c)
}
// And it must be a real cash-out, waiting for gogobee to pay it out.
pending, err := PendingEscrow(10)
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 || pending[0].Kind != KindCashOut || pending[0].Amount != 700 {
t.Fatalf("reaper did not queue a 700 cash-out: %+v", pending)
}
// Running again finds nothing left to reap.
if n, err := ReapIdleSessions(SessionIdleAfter); err != nil || n != 0 {
t.Fatalf("second sweep reaped %d (err=%v), want 0", n, err)
}
}
func TestRecordHand_AndHouseTake(t *testing.T) {
setupTestDB(t)
hands := []Hand{
{MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 195, Rake: 5, Outcome: "win", Seed1: 42, Seed2: 7},
{MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 0, Rake: 0, Outcome: "bust", Seed1: 43, Seed2: 8},
{MatrixUser: player, Game: "blackjack", Bet: 200, Payout: 486, Rake: 14, Outcome: "blackjack", Seed1: 44, Seed2: 9},
}
for _, h := range hands {
if err := RecordHand(h); err != nil {
t.Fatal(err)
}
}
take, err := HouseTake(0)
if err != nil {
t.Fatal(err)
}
if take != 19 {
t.Fatalf("house take = %d, want 19", take)
}
// The seeds have to survive the round trip, or a disputed hand can't be re-dealt.
var s1, s2 int64
if err := Get().QueryRow(
`SELECT seed1, seed2 FROM game_hands WHERE outcome = 'blackjack'`,
).Scan(&s1, &s2); err != nil {
t.Fatal(err)
}
if s1 != 44 || s2 != 9 {
t.Fatalf("seeds came back as (%d, %d), want (44, 9)", s1, s2)
}
}
// The invariant, end to end: every euro that entered the casino is either a chip
// on the table or a euro on its way home. None are minted, none evaporate.
func TestBorder_ChipsAreConserved(t *testing.T) {
setupTestDB(t)
fund(t, player, 1000)
// Play a losing hand and a winning one.
if err := Stake(player, 100); err != nil {
t.Fatal(err)
} // 900
if err := Stake(player, 100); err != nil {
t.Fatal(err)
} // 800
if err := Award(player, 195); err != nil {
t.Fatal(err)
} // 995 — one loss, one win less rake
if c := chipsOf(t, player); c != 995 {
t.Fatalf("chips = %d, want 995", c)
}
e, err := RequestCashOut(player, 995)
if err != nil {
t.Fatal(err)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
if _, err := SettleEscrow(e.GUID, true, "", 4995); err != nil {
t.Fatal(err)
}
if c := chipsOf(t, player); c != 0 {
t.Fatalf("chips = %d after cashing out everything, want 0", c)
}
// 1000 in, 995 out, 5 lost to the table and the rake. Nothing left stranded.
st, err := Chips(player)
if err != nil {
t.Fatal(err)
}
if st.Pending != 0 {
t.Fatalf("pending = %d, want 0", st.Pending)
}
}

108
internal/storage/roster.go Normal file
View File

@@ -0,0 +1,108 @@
package storage
import (
"database/sql"
"log/slog"
)
// RosterEntry is one adventurer's currently-true state, as of the last snapshot
// gogobee pushed. Not an event — nothing here is a thing that *happened*.
type RosterEntry struct {
Token string `json:"token"`
Name string `json:"name"`
Level int `json:"level"`
ClassRace string `json:"class_race"`
Status string `json:"status"` // "expedition" | "idle"
Zone string `json:"zone,omitempty"`
Region string `json:"region,omitempty"`
Day int `json:"day,omitempty"`
IdleHours int `json:"idle_hours,omitempty"`
SnapshotAt int64 `json:"snapshot_at"`
}
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
//
// Replace — never merge. A player who dropped out of gogobee's snapshot (deleted
// character, or a fresh `!news optout`) must vanish from the board, and an
// upsert would leave them standing there forever. The transaction means a reader
// mid-swap sees the old board or the new one, never a half-empty realm.
func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
tx, err := Get().Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`DELETE FROM adventure_roster`); err != nil {
return err
}
stmt, err := tx.Prepare(`
INSERT INTO adventure_roster
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, e := range entries {
if _, err := stmt.Exec(e.Token, e.Name, e.Level, e.ClassRace, e.Status,
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt); err != nil {
return err
}
}
// Stamp the snapshot even when it carried zero adventurers — that is a
// legitimate state (quiet realm) and must not read as "gogobee went away".
if _, err := tx.Exec(`
INSERT INTO adventure_roster_meta (id, snapshot_at) VALUES (1, ?)
ON CONFLICT(id) DO UPDATE SET snapshot_at = excluded.snapshot_at`, snapshotAt); err != nil {
return err
}
return tx.Commit()
}
// LoadRoster returns the current board: everyone on an expedition first (most
// recently departed at the top of that group), then the idle, longest-idle last.
// Also returns the snapshot time so the caller can decide whether the wire has
// gone quiet — see rosterStale.
func LoadRoster() ([]RosterEntry, int64, error) {
rows, err := Get().Query(`
SELECT token, name, level, COALESCE(class_race, ''), status,
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at
FROM adventure_roster
ORDER BY status = 'expedition' DESC, day DESC, idle_hours ASC, level DESC, name ASC`)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []RosterEntry
for rows.Next() {
var e RosterEntry
if err := rows.Scan(&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt); err != nil {
return nil, 0, err
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return out, RosterSnapshotAt(), nil
}
// RosterSnapshotAt reports when the board was last refreshed, 0 if gogobee has
// never pushed one. Read from the meta row, not the entries, so a snapshot that
// legitimately carried nobody still counts as a snapshot.
func RosterSnapshotAt() int64 {
var at sql.NullInt64
err := Get().QueryRow(`SELECT snapshot_at FROM adventure_roster_meta WHERE id = 1`).Scan(&at)
if err == sql.ErrNoRows {
return 0
}
if err != nil {
slog.Error("RosterSnapshotAt query failed", "err", err)
return 0
}
return at.Int64
}

View File

@@ -21,6 +21,37 @@ CREATE TABLE IF NOT EXISTS stories (
published_at INTEGER
);
-- adventure_roster is a *snapshot*, not a log: gogobee POSTs the whole live
-- board and it replaces this table wholesale. Rows are state that is currently
-- true ("Josie is in holymachina"), which is the one thing the story feed can
-- never be — every dispatch there is an accomplishment, and an accomplishment is
-- a clipping the moment it lands.
--
-- token is gogobee's per-player roster token, not a Matrix handle and not a
-- story GUID. Players who ran "!news optout" are omitted from the snapshot
-- upstream and so never appear here at all.
CREATE TABLE IF NOT EXISTS adventure_roster (
token TEXT PRIMARY KEY,
name TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
class_race TEXT,
status TEXT NOT NULL, -- "expedition" | "idle"
zone TEXT,
region TEXT,
day INTEGER NOT NULL DEFAULT 0, -- expedition day, 0 if idle
idle_hours INTEGER NOT NULL DEFAULT 0, -- hours since last player action
snapshot_at INTEGER NOT NULL -- when gogobee took the snapshot
);
-- The snapshot time lives outside the rows because an *empty* board is
-- ambiguous: either nobody is playing, or gogobee has stopped talking to us. A
-- MAX(snapshot_at) over zero rows can't tell those apart, and the page must —
-- one is "quiet realm", the other is "the wire is down, trust nothing here".
CREATE TABLE IF NOT EXISTS adventure_roster_meta (
id INTEGER PRIMARY KEY CHECK (id = 1),
snapshot_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS post_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT NOT NULL,
@@ -128,6 +159,89 @@ CREATE TABLE IF NOT EXISTS story_views (
PRIMARY KEY (story_id, day)
);
-- ---------------------------------------------------------------------------
-- games.parodia.dev
--
-- The invariant the whole casino rests on: a euro is either in gogobee's
-- euro_balances or in Pete's chip escrow, never both. It crosses between them
-- only via a GUID-idempotent claim, and Pete never writes a euro balance —
-- gogobee does, when it claims the escrow row and tells us how it went.
-- ---------------------------------------------------------------------------
-- A player's chips: euros that have crossed into the casino and haven't crossed
-- back yet. 1:1 with euros. Keyed by Matrix user id, because that's the identity
-- gogobee's ledger uses and the one an Authentik username maps onto.
CREATE TABLE IF NOT EXISTS game_chips (
matrix_user TEXT PRIMARY KEY,
chips INTEGER NOT NULL DEFAULT 0,
-- Advisory only, and stale by design: the last euro balance gogobee told us
-- about. Displayed, never trusted. The authoritative check is the debit at
-- claim time, which happens on gogobee's box against gogobee's ledger.
euro_balance REAL,
last_played INTEGER NOT NULL DEFAULT 0, -- unix; the reaper reads this
updated_at INTEGER NOT NULL DEFAULT 0
);
-- One crossing of the euro/chip border, in either direction.
--
-- requested -> claimed -> funded (buy-in: gogobee debited, chips spendable)
-- -> rejected (buy-in: insufficient funds, no chips)
-- requested -> claimed -> settled (cash-out: chips gone, euros credited)
--
-- The guid is the idempotency key end to end: it's what gogobee passes to
-- DebitIdem/CreditIdem, so a claim whose ack is lost on the wire can be retried
-- without the player paying twice.
CREATE TABLE IF NOT EXISTS game_escrow (
guid TEXT PRIMARY KEY,
matrix_user TEXT NOT NULL,
kind TEXT NOT NULL, -- 'buyin' | 'cashout'
amount INTEGER NOT NULL, -- euros == chips
state TEXT NOT NULL, -- see the ladder above
reason TEXT, -- 'insufficient_funds', when rejected
balance_after REAL, -- gogobee's euro balance after the move
created_at INTEGER NOT NULL,
claimed_at INTEGER, -- when gogobee took it; drives the re-poll
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_game_escrow_state ON game_escrow(state, created_at);
CREATE INDEX IF NOT EXISTS idx_game_escrow_user ON game_escrow(matrix_user, created_at DESC);
-- Every hand played, for money. This is the audit trail: seeds so a disputed
-- hand can be re-dealt exactly as it fell, rake so the house's take is
-- accountable, and enough shape to answer "how fast is this economy actually
-- moving" before the answer becomes a problem.
CREATE TABLE IF NOT EXISTS game_hands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
matrix_user TEXT NOT NULL,
game TEXT NOT NULL, -- 'blackjack'
bet INTEGER NOT NULL,
payout INTEGER NOT NULL, -- chips returned, net of rake
rake INTEGER NOT NULL,
outcome TEXT NOT NULL,
seed1 INTEGER NOT NULL, -- the shoe, reproducible
seed2 INTEGER NOT NULL,
played_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_game_hands_user ON game_hands(matrix_user, played_at DESC);
CREATE INDEX IF NOT EXISTS idx_game_hands_played ON game_hands(played_at);
-- The hand a player is in the middle of. One per player: you cannot be dealt a
-- second hand while chips are riding on the first.
--
-- The state column is the engine's State, serialized whole — shoe included. It
-- lives here rather than in memory because Pete redeploys often, and a player
-- whose stake has already been taken must find their cards where they left them
-- rather than a table that has forgotten them. It is also why the deck never
-- goes to the browser: the authoritative shoe is this row, on the server.
CREATE TABLE IF NOT EXISTS game_live_hands (
matrix_user TEXT PRIMARY KEY,
game TEXT NOT NULL, -- 'blackjack'
state TEXT NOT NULL, -- JSON: the engine's State
seed1 INTEGER NOT NULL, -- carried to the audit log when it settles
seed2 INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
CREATE 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);

View File

@@ -151,12 +151,14 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
// media), and the link's og:image carries the preview instead.
// No Source: the source tag exists to credit an outlet Pete is relaying
// (`ars technica`). On his own reporting it renders as him signing his
// own name under his own message.
s.advPost(AdvPost{
GUID: f.GUID,
Headline: headline,
Lede: lede,
ArticleURL: articleURL,
Source: advSource,
Channel: s.adv.Channel,
})
}
@@ -188,6 +190,18 @@ func advEventMeta(eventType string) (label, emoji string) {
return "Pete's duels", "🤝"
case "milestone":
return "Milestone", "🏅"
case "retreat":
return "Pulled out", "🎒"
case "departure":
return "Wandered off", "🚪"
case "mischief_contract":
return "Coin on their head", "😈"
case "mischief_survived":
return "They walked away", "🛡️"
case "mischief_downed":
return "The contract landed", "💀"
case "mischief_fizzled":
return "Nobody home", "🚪"
}
return "Dispatch", "📣"
}
@@ -375,6 +389,57 @@ func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
case "death":
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
case "retreat":
// An expedition that came apart without killing anyone. Until gogobee
// started sending these, the feed had no way to say "it went badly and
// everyone lived" — so it never said it, and the classes that retreat
// often simply never appeared. Warm, not a failure notice: everyone came
// home, and that is the part Pete leads with.
howFar := "barely a day in"
if f.Count > 1 {
howFar = fmt.Sprintf("%d days in", f.Count)
}
return fmt.Sprintf("%s backed out of %s.", f.Subject, f.Zone),
fmt.Sprintf("%s turned around %s — %s got the better of them this time%s, and they made the call to walk out rather than push it. Everybody came home breathing, which is the bit that counts. That dungeon'll still be there next week.",
f.Subject, howFar, f.Zone, atLevel), true
case "departure":
// A bored adventurer let themselves out. Nobody sent them — they got
// restless waiting on a player who wasn't coming, took the cheap supplies
// they could afford, and went. Pete plays it straight and a little fond;
// the joke tells itself, and the player it's about may well be reading.
return fmt.Sprintf("%s got bored and left without waiting.", f.Subject),
fmt.Sprintf("No orders, no escort, no fuss — %s packed the cheapest kit on the shelf and set off into %s%s. Nobody told them to. Nobody talked them out of it either. We'll let you know how it goes.", f.Subject, f.Zone, atLevel), true
case "mischief_contract":
// Somebody paid to have a monster sent after an adventurer who is out in a
// dungeon right now. Anonymous unless the buyer paid extra to sign it, and
// the anonymity is the story: Pete reports the money, not the name he
// doesn't have. Opponent carries the buyer only when it's public.
if f.Opponent != "" {
return fmt.Sprintf("%s has put %s on %s's head.", f.Opponent, f.Stakes, f.Subject),
fmt.Sprintf("No secret about it — %s paid for a %s to go find %s out in whatever hole they're currently down, and signed the thing. It's out there looking right now. If %s comes back breathing, they keep a cut of that money.",
f.Opponent, strings.ToLower(f.Boss), f.Subject, f.Subject), true
}
return fmt.Sprintf("Someone's put %s on %s's head.", f.Stakes, f.Subject),
fmt.Sprintf("Word came in quiet: %s has been paid for a %s to go looking for %s, and whoever paid it isn't saying so. It's already out there. Survive it and the money's theirs — and we all find out who signed the cheque.",
f.Stakes, strings.ToLower(f.Boss), f.Subject), true
case "mischief_survived":
// The unseal. A survival is the only thing that names an anonymous buyer,
// and it is the whole brake on casual griefing — so Pete leads with it.
return fmt.Sprintf("%s walked away from it. It was %s who paid.", f.Subject, f.Opponent),
fmt.Sprintf("%s came for %s, and %s is the one still standing — %s richer for the trouble. The contract's been opened up, and the name inside it is %s. Make of that what you will, folks.",
f.Boss, f.Subject, f.Subject, f.Stakes, f.Opponent), true
case "mischief_downed":
who := "Nobody's saying who paid for it"
if f.Opponent != "" {
who = fmt.Sprintf("%s paid for it, and put their name on it", f.Opponent)
}
return fmt.Sprintf("%s didn't walk away.", f.Subject),
fmt.Sprintf("A %s found %s mid-run%s and put them on the floor. They're being carried home — alive, which is more than the contract asked for, but that expedition's finished. %s.",
f.Boss, f.Subject, atLevel, who), true
case "mischief_fizzled":
return fmt.Sprintf("The monster sent for %s arrived to an empty dungeon.", f.Subject),
fmt.Sprintf("Somebody spent good money to have %s ambushed, and %s had already gone home. It wandered the halls for a bit and left. Most of the fee's been refunded. The rest, the town's keeping.",
f.Subject, f.Subject), true
case "arrival":
return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true

View File

@@ -94,7 +94,6 @@ func (s *Server) postDailyDigest(now time.Time) {
Headline: headline,
Lede: lede,
ArticleURL: s.digestURL(date),
Source: advSource,
Channel: s.adv.Channel,
})

View File

@@ -0,0 +1,55 @@
package web
import (
"strings"
"testing"
"pete/internal/storage"
)
// The retreat bulletin — gogobee's newest event type.
//
// The failure this guards against is silent and total: handleAdventureIngest
// answers an unrecognized event_type with 400, and gogobee's sender treats any
// non-2xx as a failure, retries eight times over ~two hours, and then PARKS the
// row forever. So a gogobee that emits `retreat` against a Pete that doesn't
// know the word doesn't log an error anyone reads — it just quietly drops every
// retreat the realm ever files. Pete has to learn the word first, and this test
// is what says he has.
func TestAdventureIngest_AcceptsRetreat(t *testing.T) {
const token = "s3cret-token"
s, posted := newAdvServer(t, token)
f := AdvFact{
GUID: "retreat:abc:1000", EventType: "retreat", Tier: "bulletin",
Actors: []string{"Brannigan"}, Subject: "Brannigan",
Zone: "the Underforge", Level: 12, Count: 3, Outcome: "retreated",
OccurredAt: 1000,
}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d body=%s — Pete rejected a retreat, so gogobee will "+
"retry it eight times and park it forever", rw.Code, rw.Body.String())
}
got, err := storage.GetStoryByGUID("retreat:abc:1000")
if err != nil || got == nil {
t.Fatalf("retreat was accepted but not stored: %v", err)
}
// It has to actually say what happened, in Pete's voice, using only the
// supplied facts.
body := got.Headline + " " + got.Lede
for _, want := range []string{"Brannigan", "the Underforge"} {
if !strings.Contains(body, want) {
t.Errorf("rendered retreat is missing %q: %q", want, body)
}
}
if !strings.Contains(got.Lede, "3 days in") {
t.Errorf("the day count never made it into the copy: %q", got.Lede)
}
// Bulletin, not priority: a retreat goes in the daily digest, it does not
// interrupt the room. Pete announcing every failed run live would be a
// firehose — and an unkind one.
if len(*posted) != 0 {
t.Errorf("a bulletin was posted live: %+v", *posted)
}
}

View File

@@ -311,3 +311,61 @@ func TestAdventureDisabled(t *testing.T) {
t.Errorf("disabled: status = %d, want 404", rw.Code)
}
}
// TestRenderMischief: gogobee's four mischief event types must all render. An
// unknown event_type is a 400 at ingest, which gogobee retries and then parks
// forever — so "Pete deploys first" only helps if Pete actually knows the types.
//
// It also pins the anonymity contract, which is the feature's whole social
// engine: an unsigned contract must not name the buyer, and a survival must.
func TestRenderMischief(t *testing.T) {
anon := AdvFact{EventType: "mischief_contract", Tier: "priority",
Subject: "Josie", Boss: "Elite", Stakes: "€350", Level: 14}
hl, lede, ok := renderAdventure(anon)
if !ok {
t.Fatal("mischief_contract did not render — ingest would 400")
}
if strings.Contains(hl+lede, "Brannigan") {
t.Error("anonymous contract leaked a buyer name")
}
if !strings.Contains(hl, "€350") {
t.Errorf("contract headline lost the stakes: %q", hl)
}
signed := anon
signed.Opponent = "Brannigan"
hl, _, ok = renderAdventure(signed)
if !ok || !strings.Contains(hl, "Brannigan") {
t.Errorf("signed contract should name the buyer: %q (ok=%v)", hl, ok)
}
// The unseal: a survival names the buyer whether or not they signed.
survived := AdvFact{EventType: "mischief_survived", Tier: "priority",
Subject: "Josie", Opponent: "Brannigan", Boss: "Bone Colossus", Stakes: "€228"}
hl, _, ok = renderAdventure(survived)
if !ok || !strings.Contains(hl, "Brannigan") {
t.Errorf("survival must unseal the buyer: %q (ok=%v)", hl, ok)
}
// A downed target with an anonymous buyer stays anonymous — being maimed
// doesn't buy you the name.
downed := AdvFact{EventType: "mischief_downed", Tier: "priority",
Subject: "Josie", Boss: "Bone Colossus", Level: 14}
hl, lede, ok = renderAdventure(downed)
if !ok {
t.Fatal("mischief_downed did not render")
}
if strings.Contains(hl+lede, "Brannigan") {
t.Error("anonymous buyer named on a downed contract")
}
if _, _, ok := renderAdventure(AdvFact{EventType: "mischief_fizzled", Subject: "Josie", Stakes: "€315"}); !ok {
t.Error("mischief_fizzled did not render")
}
for _, et := range []string{"mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled"} {
if lbl, _ := advEventMeta(et); lbl == "Dispatch" {
t.Errorf("%s has no permalink label", et)
}
}
}

View File

@@ -11,6 +11,7 @@ import (
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
@@ -32,6 +33,7 @@ type Authenticator struct {
oauth *oauth2.Config
verifier *oidc.IDTokenVerifier
secret []byte
domain string // cookie Domain; empty means host-only
}
// SessionUser is the identity carried in the signed session cookie.
@@ -39,7 +41,23 @@ type SessionUser struct {
Sub string `json:"sub"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Exp int64 `json:"exp"`
// Username is the Authentik preferred_username, which MAS imported as the
// Matrix localpart — so it is also who this person is in the game economy.
// Sessions signed before games existed don't carry it; MatrixUser returns
// "" for those and the caller sends them back through sign-in.
Username string `json:"username,omitempty"`
Exp int64 `json:"exp"`
}
// MatrixUser maps the session to a Matrix ID on the given server name, e.g.
// "reala" on "parodia.dev" -> "@reala:parodia.dev". Empty if either half is
// missing, which callers must treat as "not identified in the economy".
func (u *SessionUser) MatrixUser(serverName string) string {
name := strings.ToLower(strings.TrimSpace(u.Username))
if name == "" || serverName == "" {
return ""
}
return "@" + name + ":" + serverName
}
// Display is the friendly name shown in the header (name, else email, else sub).
@@ -88,6 +106,7 @@ func newAuthenticator(ctx context.Context, cfg config.AuthConfig) (*Authenticato
},
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
secret: []byte(cfg.SessionSecret),
domain: strings.TrimSpace(cfg.CookieDomain),
}, nil
}
@@ -150,11 +169,23 @@ func (a *Authenticator) userFromRequest(r *http.Request) *SessionUser {
return &u
}
// cookieDomain is the Domain attribute for a given cookie. Only the session
// cookie is widened: the OAuth round-trip cookie stays host-only, because it
// pairs with a redirect back to the host that started the login and has no
// business being readable from anywhere else.
func (a *Authenticator) cookieDomain(name string) string {
if name == sessionCookie {
return a.domain
}
return ""
}
func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
Domain: a.cookieDomain(name),
Expires: time.Now().Add(ttl),
MaxAge: int(ttl.Seconds()),
HttpOnly: true,
@@ -166,10 +197,42 @@ func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl
func (a *Authenticator) clearCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: "/", MaxAge: -1,
Domain: a.cookieDomain(name),
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})
}
// oauthFor returns the OAuth config to use for this request. The configured
// redirect_url names one host (news), but a login that starts on games has to
// come back to games — otherwise the browser is dumped on the news site with
// its "next" path pointing at a page that lives elsewhere. So when the request
// arrives on a host inside the shared cookie domain, keep the redirect on that
// host, reusing the configured URL's scheme and path. Every host used this way
// must be registered as a redirect URI in Authentik.
func (a *Authenticator) oauthFor(r *http.Request) *oauth2.Config {
if a.domain == "" || !hostInDomain(r.Host, a.domain) {
return a.oauth
}
u, err := url.Parse(a.oauth.RedirectURL)
if err != nil || u.Host == r.Host {
return a.oauth
}
cfg := *a.oauth
cfg.RedirectURL = u.Scheme + "://" + r.Host + u.Path
return &cfg
}
// hostInDomain reports whether host sits inside a cookie domain like
// ".parodia.dev" (which also covers the bare "parodia.dev").
func hostInDomain(host, domain string) bool {
if i := strings.IndexByte(host, ':'); i >= 0 {
host = host[:i] // strip port
}
host = strings.ToLower(host)
bare := strings.TrimPrefix(strings.ToLower(domain), ".")
return host == bare || strings.HasSuffix(host, "."+bare)
}
// ---- handlers -------------------------------------------------------------
// handleLogin starts the OIDC authorization-code flow.
@@ -182,7 +245,7 @@ func (a *Authenticator) handleLogin(w http.ResponseWriter, r *http.Request) {
}
payload, _ := json.Marshal(st)
a.setCookie(w, oauthCookie, a.sign(payload), oauthTTL)
http.Redirect(w, r, a.oauth.AuthCodeURL(st.State, oidc.Nonce(st.Nonce)), http.StatusFound)
http.Redirect(w, r, a.oauthFor(r).AuthCodeURL(st.State, oidc.Nonce(st.Nonce)), http.StatusFound)
}
// handleCallback completes the flow: validates state, exchanges the code,
@@ -212,7 +275,7 @@ func (a *Authenticator) handleCallback(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
tok, err := a.oauth.Exchange(ctx, r.URL.Query().Get("code"))
tok, err := a.oauthFor(r).Exchange(ctx, r.URL.Query().Get("code"))
if err != nil {
slog.Error("auth: code exchange failed", "err", err)
http.Error(w, "sign-in failed", http.StatusBadGateway)
@@ -247,7 +310,13 @@ func (a *Authenticator) handleCallback(w http.ResponseWriter, r *http.Request) {
if name == "" {
name = claims.PreferredUsername
}
u := SessionUser{Sub: claims.Sub, Name: name, Email: claims.Email, Exp: time.Now().Add(sessionTTL).Unix()}
u := SessionUser{
Sub: claims.Sub,
Name: name,
Email: claims.Email,
Username: claims.PreferredUsername,
Exp: time.Now().Add(sessionTTL).Unix(),
}
sess, _ := json.Marshal(u)
a.setCookie(w, sessionCookie, a.sign(sess), sessionTTL)
slog.Info("auth: user signed in", "sub", claims.Sub, "name", name)

View File

@@ -1,6 +1,12 @@
package web
import "testing"
import (
"net/http/httptest"
"testing"
"time"
"golang.org/x/oauth2"
)
func TestSignVerifyRoundTrip(t *testing.T) {
a := &Authenticator{secret: []byte("test-secret-key-at-least-16")}
@@ -53,3 +59,95 @@ func TestSafeNext(t *testing.T) {
}
}
}
func TestMatrixUser(t *testing.T) {
cases := []struct {
name string
u SessionUser
want string
}{
{"lowercased", SessionUser{Username: "Reala"}, "@reala:parodia.dev"},
{"trimmed", SessionUser{Username: " reala "}, "@reala:parodia.dev"},
{"old session with no username", SessionUser{Sub: "abc", Name: "Reala"}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.u.MatrixUser("parodia.dev"); got != tc.want {
t.Fatalf("MatrixUser = %q, want %q", got, tc.want)
}
})
}
if got := (&SessionUser{Username: "reala"}).MatrixUser(""); got != "" {
t.Fatalf("no server name should yield no identity, got %q", got)
}
}
func TestHostInDomain(t *testing.T) {
cases := []struct {
host, domain string
want bool
}{
{"games.parodia.dev", ".parodia.dev", true},
{"news.parodia.dev:8080", ".parodia.dev", true},
{"parodia.dev", ".parodia.dev", true},
{"GAMES.PARODIA.DEV", ".parodia.dev", true},
{"evil.com", ".parodia.dev", false},
// The suffix check must not match a domain that merely ends in the
// same letters: notparodia.dev is a different site entirely.
{"notparodia.dev", ".parodia.dev", false},
{"games.parodia.dev.evil.com", ".parodia.dev", false},
}
for _, tc := range cases {
if got := hostInDomain(tc.host, tc.domain); got != tc.want {
t.Errorf("hostInDomain(%q, %q) = %v, want %v", tc.host, tc.domain, got, tc.want)
}
}
}
func TestOAuthForKeepsLoginOnTheHostItStartedOn(t *testing.T) {
base := &oauth2.Config{RedirectURL: "https://news.parodia.dev/auth/callback"}
a := &Authenticator{oauth: base, domain: ".parodia.dev"}
req := httptest.NewRequest("GET", "/auth/login", nil)
req.Host = "games.parodia.dev"
if got := a.oauthFor(req).RedirectURL; got != "https://games.parodia.dev/auth/callback" {
t.Fatalf("games login should come back to games, got %q", got)
}
req.Host = "news.parodia.dev"
if got := a.oauthFor(req).RedirectURL; got != base.RedirectURL {
t.Fatalf("news login should use the configured URL, got %q", got)
}
// A Host we don't own must never be echoed back into a redirect URI.
req.Host = "evil.com"
if got := a.oauthFor(req).RedirectURL; got != base.RedirectURL {
t.Fatalf("foreign host must fall back to the configured URL, got %q", got)
}
// With no cookie domain configured there is nothing to share, so the
// configured redirect stands whatever the Host header says.
off := &Authenticator{oauth: base}
req.Host = "games.parodia.dev"
if got := off.oauthFor(req).RedirectURL; got != base.RedirectURL {
t.Fatalf("host-only mode must not rewrite the redirect, got %q", got)
}
}
func TestSessionCookieIsSharedButOAuthCookieIsNot(t *testing.T) {
a := &Authenticator{secret: []byte("test-secret-key-at-least-16"), domain: ".parodia.dev"}
rec := httptest.NewRecorder()
a.setCookie(rec, sessionCookie, "v", time.Minute)
a.setCookie(rec, oauthCookie, "v", time.Minute)
got := map[string]string{}
for _, c := range rec.Result().Cookies() {
got[c.Name] = c.Domain
}
if got[sessionCookie] != "parodia.dev" {
t.Fatalf("session cookie domain = %q, want it shared across parodia.dev", got[sessionCookie])
}
if got[oauthCookie] != "" {
t.Fatalf("oauth cookie must stay host-only, got domain %q", got[oauthCookie])
}
}

View File

@@ -0,0 +1,71 @@
package web
import (
"encoding/json"
"fmt"
"io/fs"
"net"
"net/http"
"os"
"testing"
"time"
)
// TestDevCasino is not a test. It is the casino, running, on a port, with one
// signed-in player who has chips — so the table can be driven in a real browser,
// which is the only honest way to review an animation.
//
// Skipped unless you ask for it:
//
// PETE_DEV_CASINO=:7788 go test ./internal/web -run TestDevCasino -timeout 0
//
// It prints the session cookie to plant. The routes are wired here rather than
// taken from New(), because New() decides whether the casino exists at the
// moment it builds the mux, and the test rig only signs the player in afterwards.
func TestDevCasino(t *testing.T) {
addr := os.Getenv("PETE_DEV_CASINO")
if addr == "" {
t.Skip("set PETE_DEV_CASINO=:port to run the casino for a browser")
}
s := newCasino(t)
fund(t, 5000)
payload, _ := json.Marshal(SessionUser{
Sub: "sub-1", Username: "reala", Name: "Reala",
Exp: time.Now().Add(24 * time.Hour).Unix(),
})
cookie := s.auth.sign(payload)
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
mux.HandleFunc("GET /games", s.handleLobby)
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /api/games/table", s.handleTable)
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
ln, err := net.Listen("tcp", addr)
if err != nil {
t.Fatal(err)
}
// Written to a file, not printed: `go test` buffers stdout, and the browser
// driver needs the cookie while the server is still running.
if out := os.Getenv("PETE_DEV_COOKIE_FILE"); out != "" {
if err := os.WriteFile(out, []byte(cookie), 0o600); err != nil {
t.Fatal(err)
}
}
fmt.Printf("\nCASINO http://localhost%s/games/blackjack\nCOOKIE %s=%s\n\n", addr, sessionCookie, cookie)
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
t.Cleanup(func() { _ = srv.Close() })
_ = srv.Serve(ln)
}

151
internal/web/games.go Normal file
View File

@@ -0,0 +1,151 @@
package web
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"pete/internal/storage"
)
// The euro/chip wire.
//
// gogobee owns the euros and has no inbound API, so it is the only initiator:
// it polls this endpoint for border crossings, moves the money on its side, and
// pushes the verdict back through the durable queue it already uses for
// adventure facts. Pete never calls gogobee. That direction of travel is a
// standing rule of the seam, not an implementation detail — see roster.go.
//
// All three endpoints are bearer-authed with the same ingest token as the
// adventure seam, and all three are idempotent, because the thing on the other
// end of them is a retrying queue and the thing they move is money.
//
// The storage layer under this (internal/storage/games.go) is where the actual
// invariant lives: chips exist only once gogobee confirms it took the euros.
// These handlers are transport, and deliberately nothing more.
// escrowGUID is the body of the two POSTs that name a row.
type escrowGUID struct {
GUID string `json:"guid"`
}
// escrowVerdict is gogobee's answer: did the money move, and what is the
// player's euro balance now.
type escrowVerdict struct {
GUID string `json:"guid"`
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
BalanceAfter float64 `json:"balance_after"`
}
// handleEscrowPending is gogobee's poll: every crossing waiting to be moved.
//
// It includes rows gogobee claimed but never reported on — see
// storage.PendingEscrow. Re-offering those is the whole reason the guid is an
// idempotency key: if gogobee already moved the euros, the retry is a no-op
// that reports the same answer, and if it died before moving them, the money
// gets moved now instead of being stranded.
func (s *Server) handleEscrowPending(w http.ResponseWriter, r *http.Request) {
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
rows, err := storage.PendingEscrow(escrowPollLimit)
if err != nil {
slog.Error("games: pending escrow", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if rows == nil {
rows = []storage.Escrow{} // an empty poll is [], never null
}
writeJSON(w, rows)
}
// escrowPollLimit caps one poll. gogobee polls every few seconds, so a backlog
// drains in a handful of ticks rather than arriving as one enormous body.
const escrowPollLimit = 50
// handleEscrowClaim marks a row as taken. It is not a lock — a row already
// claimed can be claimed again, which is how a stale re-offer works — but a row
// that has already reached a verdict cannot be, which is what stops a settled
// cash-out being paid twice.
//
// The claimed row goes back in the response, so gogobee moves the money against
// the amount and the user *Pete* holds rather than the ones it read a poll ago.
func (s *Server) handleEscrowClaim(w http.ResponseWriter, r *http.Request) {
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req escrowGUID
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&req); err != nil || req.GUID == "" {
http.Error(w, "guid is required", http.StatusBadRequest)
return
}
e, err := storage.ClaimEscrow(req.GUID)
if errors.Is(err, storage.ErrNoSuchEscrow) {
http.Error(w, "no such escrow", http.StatusNotFound)
return
}
if err != nil {
slog.Error("games: claim escrow", "guid", req.GUID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, e)
}
// handleEscrowSettled applies gogobee's verdict. This is the only way chips are
// ever created, and it runs exactly once per guid no matter how many times the
// push is redelivered.
//
// An unknown guid is a 400 rather than a shrug: gogobee has, by this point,
// already moved real euros for a row Pete has no record of. Under the contract
// the adventure seam established, a 400 makes gogobee's sender park the row
// instead of retrying it forever — which is right, because no amount of retrying
// invents the missing row. It leaves the payload sitting in gogobee's queue,
// where a human can find it.
func (s *Server) handleEscrowSettled(w http.ResponseWriter, r *http.Request) {
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var v escrowVerdict
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if v.GUID == "" {
http.Error(w, "guid is required", http.StatusBadRequest)
return
}
e, err := storage.SettleEscrow(v.GUID, v.OK, v.Reason, v.BalanceAfter)
if errors.Is(err, storage.ErrNoSuchEscrow) {
slog.Error("games: verdict for an escrow row we have never heard of — "+
"gogobee has moved euros against it and Pete cannot honour them",
"guid", v.GUID, "ok", v.OK)
http.Error(w, "no such escrow", http.StatusBadRequest)
return
}
if err != nil {
slog.Error("games: settle escrow", "guid", v.GUID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("games: escrow settled", "guid", e.GUID, "user", e.MatrixUser,
"kind", e.Kind, "amount", e.Amount, "state", e.State, "reason", e.Reason)
writeJSON(w, e)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("games: write response", "err", err)
}
}

114
internal/web/games_pages.go Normal file
View File

@@ -0,0 +1,114 @@
package web
import (
"net/http"
"time"
"pete/internal/games/blackjack"
"pete/internal/storage"
)
// The casino's two pages. Both require a signed-in visitor — there is money in
// here, and a player has to be somebody gogobee's ledger can name.
//
// Neither page renders any game state server-side. The felt is drawn by the
// browser from /api/games/table, because a hand is a thing that *happens*: cards
// are dealt one at a time and the table plays that back. A server-rendered hand
// would arrive fully formed, which is the one thing a card table must never do.
// gameTeaser is a table that isn't open yet. They're on the lobby because an
// empty casino with one game reads as broken, and this reads as early.
type gameTeaser struct {
Name string
Emoji string
Blurb string
}
var comingSoon = []gameTeaser{
{Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
{Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."},
{Name: "Hangman", Emoji: "🪢", Blurb: "Guess the phrase before the gallows finish."},
}
// betDenominations are the chips you build a bet out of.
var betDenominations = []int64{5, 25, 100, 500}
// The casino is not called Pete — the news app is Pete's, and this is somewhere
// you go. It has two names, and which one is over the door depends on the hour:
// the lights come on at six and the place turns into Casino Night Zone until
// dawn. Same tables, different room.
type room struct {
Slug string // drives the palette: html[data-room="…"]
Name string // what's on the sign
}
var (
roomDay = room{Slug: "casinopolis", Name: "Casinopolis"}
roomNight = room{Slug: "casino-night", Name: "Casino Night Zone"}
)
// roomAt picks the room for an hour of the day. Daylight is 6am to 6pm; the rest
// belongs to the neon. The browser re-runs this same rule against its own clock
// (games_layout.html), so a player in another timezone sees their own evening —
// this server-side pick only exists so the first paint isn't the wrong room.
func roomAt(hour int) room {
if hour >= 6 && hour < 18 {
return roomDay
}
return roomNight
}
// gamesPage is deliberately *not* pageData. The casino shares Pete's design
// language and nothing else — no channels, no weather, no sources, no push.
// Giving it its own page struct is what stops the news app's furniture drifting
// back in one convenient field at a time.
type gamesPage struct {
Room room
User *SessionUser
Cap int64
RakePct int
Soon []gameTeaser
Denominations []int64
}
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
// Anyone who is signed in but carries a session from before the casino existed
// has no username in it, so they get sent through sign-in too — which mints one.
func (s *Server) requirePlayer(w http.ResponseWriter, r *http.Request) bool {
if !s.gamesReady() {
http.NotFound(w, r)
return false
}
u := s.auth.userFromRequest(r)
if u != nil && u.MatrixUser(s.cfg.Games.MatrixServer) != "" {
return true
}
http.Redirect(w, r, "/auth/login?next="+r.URL.Path, http.StatusFound)
return false
}
func (s *Server) gamesPage(r *http.Request) gamesPage {
return gamesPage{
Room: roomAt(time.Now().Hour()),
User: s.auth.userFromRequest(r), // requirePlayer ran first, so this is non-nil
Cap: storage.MaxChipsOnTable,
RakePct: int(blackjack.DefaultRules().RakePct * 100),
Soon: comingSoon,
Denominations: betDenominations,
}
}
func (s *Server) handleLobby(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "games", s.gamesPage(r))
}
func (s *Server) handleBlackjack(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "blackjack", s.gamesPage(r))
}

530
internal/web/games_play.go Normal file
View File

@@ -0,0 +1,530 @@
package web
import (
"encoding/json"
"errors"
"io"
"log/slog"
"math/rand/v2"
"net/http"
"time"
"pete/internal/games/blackjack"
"pete/internal/games/cards"
"pete/internal/storage"
)
// The table, as a browser sees it.
//
// Everything here is server-authoritative. The browser sends intents — deal me
// in, hit, stand — and gets back a *view*: the cards it is entitled to see and
// nothing else. The shoe stays in game_live_hands, on this side of the wire.
// That is not belt-and-braces, it is the whole reason the engines are Go: a game
// with money on it cannot trust a client-reported result, and a client that
// holds the deck can read the next card.
//
// The stake leaves the player's stack *before* the hand is dealt, and comes back
// only through the engine's own payout. So a hand that crashes halfway costs the
// player their bet and nothing more, and a hand that Pete restarts through is
// still sitting there when they come back.
// gamesReady reports whether the casino can actually run: it needs sign-in (a
// player has to be someone) and a Matrix server name (that someone has to exist
// in gogobee's ledger).
func (s *Server) gamesReady() bool {
return s.cfg.Games.Enabled && s.auth != nil && s.cfg.Games.MatrixServer != ""
}
// player resolves the signed-in visitor to their Matrix id, or writes the
// failure. An empty id means a session from before games existed, which carries
// no username: sending them back through sign-in mints one.
func (s *Server) player(w http.ResponseWriter, r *http.Request) (string, bool) {
if !s.gamesReady() {
http.NotFound(w, r)
return "", false
}
u := s.auth.userFromRequest(r)
if u == nil {
writeJSONStatus(w, http.StatusUnauthorized, map[string]string{"error": "sign in to play"})
return "", false
}
mx := u.MatrixUser(s.cfg.Games.MatrixServer)
if mx == "" {
writeJSONStatus(w, http.StatusForbidden, map[string]string{
"error": "your session predates the casino — sign out and back in",
})
return "", false
}
return mx, true
}
// ---- what the browser is allowed to see -----------------------------------
// cardView is one card, pre-rendered. The browser draws faces, not logic: it
// gets the glyph and the colour rather than a rank it has to map itself.
type cardView struct {
Label string `json:"label"` // "A♠"
Rank string `json:"rank"` // "A"
Suit string `json:"suit"` // "♠"
Red bool `json:"red"`
}
func viewCard(c cards.Card) cardView {
label := c.String()
// String() renders rank then a three-byte suit glyph, except for a card that
// isn't one ("??"), which has no glyph to split off.
if len(label) <= len("♠") {
return cardView{Label: label, Rank: label}
}
cut := len(label) - len("♠")
return cardView{Label: label, Rank: label[:cut], Suit: label[cut:], Red: c.Red()}
}
// handView is a blackjack hand as its player may see it. While they are still
// acting, the dealer's hole card is *absent* — not sent and flagged hidden, but
// genuinely not in the payload. A field the browser is told to ignore is a field
// somebody reads in devtools.
type handView struct {
Phase string `json:"phase"`
Bet int64 `json:"bet"`
Player []cardView `json:"player"`
Dealer []cardView `json:"dealer"`
Hole bool `json:"hole"` // true: the dealer has a face-down card
Total int `json:"total"`
Soft bool `json:"soft"`
DTotal int `json:"dealer_total"` // what the *shown* dealer cards add to
Outcome string `json:"outcome,omitempty"`
Payout int64 `json:"payout,omitempty"`
Rake int64 `json:"rake,omitempty"`
Net int64 `json:"net"`
Double bool `json:"can_double"`
}
func viewHand(st blackjack.State) handView {
v := handView{
Phase: string(st.Phase),
Bet: st.Bet,
Outcome: string(st.Outcome),
Payout: st.Payout,
Rake: st.Rake,
Net: st.Net(),
Double: st.CanDouble(),
}
for _, c := range st.Player {
v.Player = append(v.Player, viewCard(c))
}
v.Total, v.Soft = blackjack.HandValue(st.Player)
dealer := st.Dealer
if st.Phase == blackjack.PhasePlayer && len(dealer) > 1 {
dealer = dealer[:1] // the hole card is the dealer's business until it isn't
v.Hole = true
}
for _, c := range dealer {
v.Dealer = append(v.Dealer, viewCard(c))
}
v.DTotal, _ = blackjack.HandValue(dealer)
return v
}
// eventView is the dealing script. The engine emits one event per card off the
// shoe, in order, and the table animates them one at a time — which is why the
// events go over the wire at all rather than the browser diffing two states.
type eventView struct {
Kind string `json:"kind"`
Card *cardView `json:"card,omitempty"`
Text string `json:"text,omitempty"`
}
// viewEvents renders the engine's events for the browser, dropping the cards it
// is not yet allowed to see: the dealer's second card is dealt face-down, and
// only the "reveal" event turns it over.
func viewEvents(evs []blackjack.Event, phase blackjack.Phase) []eventView {
out := make([]eventView, 0, len(evs))
dealerCards := 0
for _, e := range evs {
v := eventView{Kind: e.Kind, Text: e.Text}
if e.Card != nil {
c := viewCard(*e.Card)
v.Card = &c
}
if e.Kind == "dealer_card" {
dealerCards++
// The hole card, while the hand is still the player's to play: send
// the event so the table deals a face-down card, but not the face.
if dealerCards == 2 && phase == blackjack.PhasePlayer {
v.Card = nil
v.Kind = "dealer_hole"
}
}
out = append(out, v)
}
return out
}
// tableView is the whole page state: the money, and the hand if there is one.
type tableView struct {
Chips int64 `json:"chips"`
Pending int64 `json:"pending"` // buy-ins gogobee hasn't answered yet
Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale
Cap int64 `json:"cap"`
Hand *handView `json:"hand,omitempty"`
Events []eventView `json:"events,omitempty"` // only on a move, for the animation
Rake float64 `json:"rake_pct"`
}
// table reads the player's money and any hand in progress.
func (s *Server) table(user string) (tableView, error) {
st, err := storage.Chips(user)
if err != nil {
return tableView{}, err
}
v := tableView{
Chips: st.Chips,
Pending: st.Pending,
Euros: st.EuroBalance,
Cap: storage.MaxChipsOnTable,
Rake: blackjack.DefaultRules().RakePct,
}
live, err := storage.LoadLiveHand(user)
if errors.Is(err, storage.ErrNoLiveHand) {
return v, nil
}
if err != nil {
return tableView{}, err
}
var hand blackjack.State
if err := json.Unmarshal(live.State, &hand); err != nil {
// A hand we can't read is a hand nobody can play. Rather than wedge the
// player out of the casino forever, drop it and tell them.
slog.Error("games: unreadable live hand, discarding", "user", user, "err", err)
_ = storage.ClearLiveHand(user)
return v, nil
}
hv := viewHand(hand)
v.Hand = &hv
return v, nil
}
// ---- handlers -------------------------------------------------------------
// handleTable is the page's poll: chips, euros, and whatever hand is on the felt.
func (s *Server) handleTable(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
v, err := s.table(user)
if err != nil {
slog.Error("games: table", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, v)
}
// amountBody is {amount: n} — chips, which are euros.
type amountBody struct {
Amount int64 `json:"amount"`
}
func decodeJSON(r *http.Request, v any) error {
return json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(v)
}
// handleBuyIn opens a buy-in. It creates no chips: it writes an escrow row, and
// gogobee decides — up to three seconds later — whether the player could afford
// it. The browser watches `pending` fall to zero to know how it went.
func (s *Server) handleBuyIn(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req amountBody
if err := decodeJSON(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
e, err := storage.RequestBuyIn(user, req.Amount)
switch {
case errors.Is(err, storage.ErrBadAmount):
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "buy in for something more than nothing"})
return
case errors.Is(err, storage.ErrOverTableCap):
writeJSONStatus(w, http.StatusBadRequest, map[string]string{
"error": "that would put more than the table cap in front of you",
})
return
case err != nil:
slog.Error("games: buy-in", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
storage.Touch(user)
slog.Info("games: buy-in requested", "user", user, "amount", e.Amount, "guid", e.GUID)
v, err := s.table(user)
if err != nil {
slog.Error("games: table after buy-in", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, v)
}
// handleCashOut sends chips back across the border. The chips are destroyed
// here and now — see storage.RequestCashOut for why that has to happen before
// gogobee has said anything — so the response already shows an empty stack. An
// amount of zero or less means "all of it", which is what the button does.
func (s *Server) handleCashOut(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req amountBody
if err := decodeJSON(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// You cannot walk away from a hand you have chips riding on. The stake is
// already off the stack, so this isn't about the money — it's that a hand
// left half-played would settle into a session that no longer exists.
if _, err := storage.LoadLiveHand(user); err == nil {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand first"})
return
} else if !errors.Is(err, storage.ErrNoLiveHand) {
slog.Error("games: cash-out live-hand check", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
amount := req.Amount
if amount <= 0 {
st, err := storage.Chips(user)
if err != nil {
slog.Error("games: cash-out", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
amount = st.Chips
}
e, err := storage.RequestCashOut(user, amount)
switch {
case errors.Is(err, storage.ErrBadAmount), errors.Is(err, storage.ErrInsufficientChips):
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you don't have those chips"})
return
case err != nil:
slog.Error("games: cash-out", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("games: cash-out requested", "user", user, "amount", e.Amount, "guid", e.GUID)
v, err := s.table(user)
if err != nil {
slog.Error("games: table after cash-out", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, v)
}
// ---- blackjack ------------------------------------------------------------
// handleDeal takes the bet and deals. The order matters: chips are staked first,
// in the same statement that checks they exist, so two deals fired at once
// cannot bet the same chip. Only then is a hand dealt.
func (s *Server) handleDeal(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req struct {
Bet int64 `json:"bet"`
}
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
return
}
if err := storage.Stake(user, req.Bet); err != nil {
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
return
}
slog.Error("games: stake", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
seed1, seed2 := newSeeds()
rng := rand.New(rand.NewPCG(seed1, seed2))
st, evs, err := blackjack.New(req.Bet, blackjack.DefaultRules(), rng)
if err != nil {
// The hand never happened, so the stake never should have left. Give it back.
_ = storage.Award(user, req.Bet)
slog.Error("games: deal", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.persist(w, user, st, evs, seed1, seed2, true)
}
// handleMove plays one move of the hand in progress.
func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req struct {
Move string `json:"move"`
}
if err := decodeJSON(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
live, err := storage.LoadLiveHand(user)
if errors.Is(err, storage.ErrNoLiveHand) {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no hand in progress"})
return
}
if err != nil {
slog.Error("games: load hand", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var st blackjack.State
if err := json.Unmarshal(live.State, &st); err != nil {
slog.Error("games: unreadable live hand", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
move := blackjack.Move(req.Move)
// A double doubles the stake, so the extra chips have to be taken before the
// move is applied — and if they aren't there, the move simply isn't legal.
// Take them first: if the engine then refuses the move, they go straight back.
doubled := false
if move == blackjack.Double {
if !st.CanDouble() {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only double on the first two cards"})
return
}
if err := storage.Stake(user, st.Bet); err != nil {
if errors.Is(err, storage.ErrInsufficientChips) {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to double"})
return
}
slog.Error("games: stake double", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
doubled = true
}
next, evs, err := blackjack.ApplyMove(st, move)
if err != nil {
if doubled {
_ = storage.Award(user, st.Bet) // the move didn't happen; neither did the raise
}
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that move isn't legal here"})
return
}
s.persist(w, user, next, evs, live.Seed1, live.Seed2, false)
}
// persist writes the hand back and answers the browser. A finished hand pays
// out, goes in the audit log, and leaves the felt; an unfinished one is saved
// as it stands, so a redeploy mid-hand is survivable.
//
// fresh marks a hand that has just been dealt, which is the one case where the
// write may be refused: the primary key, not an earlier read, is what enforces
// one hand at a time. A Deal that loses that race gets its stake back.
func (s *Server) persist(w http.ResponseWriter, user string, st blackjack.State, evs []blackjack.Event, seed1, seed2 uint64, fresh bool) {
blob, err := json.Marshal(st)
if err != nil {
slog.Error("games: marshal hand", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Seat the hand before doing anything else with it — even one that is already
// over, because a natural settles the instant it's dealt. The insert is what
// enforces one hand at a time, and it has to happen for *every* new hand: a
// natural dealt on top of a hand already in progress would otherwise settle,
// clear the felt, and take the other hand's stake down with it.
hand := storage.LiveHand{Game: "blackjack", State: blob, Seed1: seed1, Seed2: seed2}
save := storage.SaveLiveHand
if fresh {
save = storage.StartLiveHand
}
if err := save(user, hand); err != nil {
if errors.Is(err, storage.ErrHandInProgress) {
// Somebody was already sitting here. This hand was never seated, so the
// chips it staked go back: the player is in one hand, not two.
_ = storage.Award(user, st.Bet)
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a hand"})
return
}
slog.Error("games: save hand", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if st.Phase == blackjack.PhaseDone {
// Pay first, then clear. If Pete dies between the two, the player has been
// paid and the worst case is a settled hand still showing on the felt —
// which reads as done and can be cleared. The other order loses them a win.
if err := storage.Award(user, st.Payout); err != nil {
slog.Error("games: award", "user", user, "payout", st.Payout, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := storage.RecordHand(storage.Hand{
MatrixUser: user, Game: "blackjack",
Bet: st.Bet, Payout: st.Payout, Rake: st.Rake,
Outcome: string(st.Outcome), Seed1: seed1, Seed2: seed2,
}); err != nil {
slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's hand
}
if err := storage.ClearLiveHand(user); err != nil {
slog.Error("games: clear hand", "user", user, "err", err)
}
}
storage.Touch(user)
v, err := s.table(user)
if err != nil {
slog.Error("games: table", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// A settled hand is gone from storage, so the table view has no hand to show —
// but the browser still needs the final cards to animate the reveal onto.
if st.Phase == blackjack.PhaseDone {
hv := viewHand(st)
v.Hand = &hv
}
v.Events = viewEvents(evs, st.Phase)
writeJSON(w, v)
}
// newSeeds mints the shoe's seed. It goes in the audit log, so a hand somebody
// disputes can be dealt again exactly as it fell.
func newSeeds() (uint64, uint64) {
return rand.Uint64(), uint64(time.Now().UnixNano())
}
func writeJSONStatus(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("games: write response", "err", err)
}
}

View File

@@ -0,0 +1,275 @@
package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"pete/internal/config"
"pete/internal/storage"
)
const testPlayer = "@reala:parodia.dev"
// newCasino is a server with the tables open and one signed-in player. The
// Authenticator is built by hand rather than through OIDC discovery: sign-in is
// a network call, and none of what's under test is about the handshake.
func newCasino(t *testing.T) *Server {
t.Helper()
s, _ := newAdvServer(t, "tok")
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
s.cfg.Games = config.GamesConfig{Enabled: true, MatrixServer: "parodia.dev"}
return s
}
// as returns a request carrying the signed session of the given username. An
// empty username is the pre-casino session: signed in, but nobody the economy
// can name.
func as(t *testing.T, s *Server, username, method, path string, body any) *http.Request {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatal(err)
}
}
r := httptest.NewRequest(method, path, &buf)
payload, _ := json.Marshal(SessionUser{
Sub: "sub-1", Username: username, Exp: time.Now().Add(time.Hour).Unix(),
})
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
return r
}
// call runs one request against a handler and decodes the table view.
func call(t *testing.T, s *Server, h http.HandlerFunc, r *http.Request) (tableView, int) {
t.Helper()
w := httptest.NewRecorder()
h(w, r)
var v tableView
if w.Code == 200 {
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
t.Fatalf("decode table: %v (body %q)", err, w.Body.String())
}
}
return v, w.Code
}
// fund puts chips in front of the player the way the border really does it.
func fund(t *testing.T, chips int64) {
t.Helper()
e, err := storage.RequestBuyIn(testPlayer, chips)
if err != nil {
t.Fatal(err)
}
if _, err := storage.ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
if _, err := storage.SettleEscrow(e.GUID, true, "", 0); err != nil {
t.Fatal(err)
}
}
func chipsNow(t *testing.T) int64 {
t.Helper()
st, err := storage.Chips(testPlayer)
if err != nil {
t.Fatal(err)
}
return st.Chips
}
// TestDealTakesTheStakeAndHidesTheHoleCard is the one thing the table cannot get
// wrong: the bet leaves the stack, and the dealer's second card does not leave
// the server.
func TestDealTakesTheStakeAndHidesTheHoleCard(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
v, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal", map[string]int64{"bet": 100}))
if code != 200 {
t.Fatalf("deal = %d, want 200", code)
}
if v.Chips != 900 {
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
}
if v.Hand == nil {
t.Fatal("deal returned no hand")
}
if len(v.Hand.Player) != 2 {
t.Fatalf("player was dealt %d cards, want 2", len(v.Hand.Player))
}
// A natural settles on the spot and legitimately shows both dealer cards.
if v.Hand.Phase == "done" {
return
}
if !v.Hand.Hole || len(v.Hand.Dealer) != 1 {
t.Fatalf("dealer shows %d cards (hole=%v), want 1 with the hole card held back",
len(v.Hand.Dealer), v.Hand.Hole)
}
// And it isn't smuggled out in the dealing script either.
for _, e := range v.Events {
if e.Kind == "dealer_hole" && e.Card != nil {
t.Fatal("the hole card's face went to the browser in the events")
}
}
}
// TestHandSettlesIntoTheChipStack plays a hand to the end and checks the chips
// moved by exactly what the engine said they did — and that the hand left the
// felt and landed in the audit log.
func TestHandSettlesIntoTheChipStack(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
for v.Hand != nil && v.Hand.Phase == "player" {
v, _ = call(t, s, s.handleMove, as(t, s, "reala", "POST", "/move", map[string]string{"move": "stand"}))
}
if v.Hand == nil || v.Hand.Phase != "done" {
t.Fatalf("hand didn't finish: %+v", v.Hand)
}
// 1000, minus the stake, plus whatever came back.
want := int64(1000) - 100 + v.Hand.Payout
if got := chipsNow(t); got != want {
t.Fatalf("chips = %d, want %d (payout %d, outcome %q)", got, want, v.Hand.Payout, v.Hand.Outcome)
}
if _, err := storage.LoadLiveHand(testPlayer); err == nil {
t.Fatal("a settled hand is still sitting on the felt")
}
if v.Hand.Outcome == "" {
t.Fatal("a finished hand with no outcome")
}
// The rake only ever comes out of winnings.
if v.Hand.Outcome == "push" && v.Hand.Payout != 100 {
t.Fatalf("a push paid %d, want the 100 back untouched", v.Hand.Payout)
}
if v.Hand.Net < 0 && v.Hand.Rake != 0 {
t.Fatalf("a losing hand was raked %d", v.Hand.Rake)
}
}
// TestOneHandAtATime is the double-click: a second Deal must not overwrite the
// hand the first one is paying for, and must not keep the chips it staked.
func TestOneHandAtATime(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
first, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
if first.Hand != nil && first.Hand.Phase == "done" {
t.Skip("dealt a natural; there is no live hand to protect")
}
before := chipsNow(t)
w := httptest.NewRecorder()
s.handleDeal(w, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
if w.Code != http.StatusConflict {
t.Fatalf("second deal = %d, want 409", w.Code)
}
if got := chipsNow(t); got != before {
t.Fatalf("the refused deal cost the player %d chips", before-got)
}
// The original hand is untouched.
live, err := storage.LoadLiveHand(testPlayer)
if err != nil {
t.Fatalf("the live hand went missing: %v", err)
}
var st struct {
Player []struct{} `json:"player"`
}
if err := json.Unmarshal(live.State, &st); err != nil {
t.Fatal(err)
}
if len(st.Player) != len(first.Hand.Player) {
t.Fatal("the refused deal replaced the hand in progress")
}
}
// TestCannotCashOutMidHand — the stake is on the table, so the session it would
// settle into cannot be closed underneath it.
func TestCannotCashOutMidHand(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
if v.Hand != nil && v.Hand.Phase == "done" {
t.Skip("dealt a natural; nothing is in progress")
}
w := httptest.NewRecorder()
s.handleCashOut(w, as(t, s, "reala", "POST", "/cashout", map[string]int64{"amount": 0}))
if w.Code != http.StatusConflict {
t.Fatalf("cash-out mid-hand = %d, want 409", w.Code)
}
if got := chipsNow(t); got != 900 {
t.Fatalf("the refused cash-out moved chips: %d, want 900", got)
}
}
// TestDoubleWithoutTheChipsChangesNothing: a double the player can't cover is
// refused, and refusing it must not quietly pocket the raise.
func TestDoubleWithoutTheChipsChangesNothing(t *testing.T) {
s := newCasino(t)
fund(t, 100)
v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
if v.Hand == nil || v.Hand.Phase != "player" {
t.Skip("no live hand to double on")
}
if chipsNow(t) != 0 {
t.Fatal("test wants a player with nothing left to raise with")
}
w := httptest.NewRecorder()
s.handleMove(w, as(t, s, "reala", "POST", "/move", map[string]string{"move": "double"}))
if w.Code != http.StatusBadRequest {
t.Fatalf("broke double = %d, want 400", w.Code)
}
if got := chipsNow(t); got != 0 {
t.Fatalf("chips = %d after a refused double, want 0", got)
}
// And the hand is still there, still doubleable once they can afford it.
after, code := call(t, s, s.handleTable, as(t, s, "reala", "GET", "/table", nil))
if code != 200 || after.Hand == nil || !after.Hand.Double {
t.Fatalf("the hand should be intact and still doubleable: %d %+v", code, after.Hand)
}
}
// TestTableNeedsAPlayerTheEconomyCanName. Anonymous is a 401. A session minted
// before the casino existed carries no username, so it can't be mapped to a
// Matrix id — that's a 403, and the fix is to sign in again.
func TestTableNeedsAPlayerTheEconomyCanName(t *testing.T) {
s := newCasino(t)
w := httptest.NewRecorder()
s.handleTable(w, httptest.NewRequest("GET", "/api/games/table", nil))
if w.Code != http.StatusUnauthorized {
t.Fatalf("anonymous = %d, want 401", w.Code)
}
w = httptest.NewRecorder()
s.handleTable(w, as(t, s, "", "GET", "/api/games/table", nil))
if w.Code != http.StatusForbidden {
t.Fatalf("session with no username = %d, want 403", w.Code)
}
}
// TestCasinoIsShutWithoutAServerName. No Matrix server name means no player can
// be named to gogobee, so the tables 404 rather than dealing hands whose money
// has nowhere to go.
func TestCasinoIsShutWithoutAServerName(t *testing.T) {
s := newCasino(t)
s.cfg.Games.MatrixServer = ""
w := httptest.NewRecorder()
s.handleTable(w, as(t, s, "reala", "GET", "/api/games/table", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("table with no server name = %d, want 404", w.Code)
}
}

248
internal/web/games_test.go Normal file
View File

@@ -0,0 +1,248 @@
package web
import (
"bytes"
"encoding/json"
"net/http/httptest"
"testing"
"pete/internal/storage"
)
// pollEscrow runs one gogobee poll.
func pollEscrow(t *testing.T, s *Server, token string) ([]storage.Escrow, int) {
t.Helper()
req := httptest.NewRequest("GET", "/api/games/escrow/pending", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.handleEscrowPending(w, req)
if w.Code != 200 {
return nil, w.Code
}
var out []storage.Escrow
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("decode pending: %v (body %q)", err, w.Body.String())
}
return out, w.Code
}
func claimEscrow(t *testing.T, s *Server, token, guid string) (storage.Escrow, int) {
t.Helper()
body, _ := json.Marshal(escrowGUID{GUID: guid})
req := httptest.NewRequest("POST", "/api/games/escrow/claim", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.handleEscrowClaim(w, req)
var e storage.Escrow
if w.Code == 200 {
_ = json.Unmarshal(w.Body.Bytes(), &e)
}
return e, w.Code
}
func settleEscrow(t *testing.T, s *Server, token string, v escrowVerdict) (storage.Escrow, int) {
t.Helper()
body, _ := json.Marshal(v)
req := httptest.NewRequest("POST", "/api/games/escrow/settled", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.handleEscrowSettled(w, req)
var e storage.Escrow
if w.Code == 200 {
_ = json.Unmarshal(w.Body.Bytes(), &e)
}
return e, w.Code
}
// TestEscrowBuyInRoundTrip walks the happy path a player actually takes: ask for
// chips, watch gogobee pick the row up, and have the chips appear only once the
// euros are confirmed gone.
func TestEscrowBuyInRoundTrip(t *testing.T) {
s, _ := newAdvServer(t, "tok")
const user = "@reala:parodia.dev"
req, err := storage.RequestBuyIn(user, 500)
if err != nil {
t.Fatal(err)
}
// No chips yet. The euros are still gogobee's.
if st, _ := storage.Chips(user); st.Chips != 0 || st.Pending != 500 {
t.Fatalf("before settle: chips=%d pending=%d, want 0/500", st.Chips, st.Pending)
}
pending, code := pollEscrow(t, s, "tok")
if code != 200 || len(pending) != 1 || pending[0].GUID != req.GUID {
t.Fatalf("poll = %d %+v, want the one buy-in", code, pending)
}
if pending[0].MatrixUser != user || pending[0].Kind != storage.KindBuyIn || pending[0].Amount != 500 {
t.Fatalf("poll row = %+v, want the row we opened", pending[0])
}
claimed, code := claimEscrow(t, s, "tok", req.GUID)
if code != 200 || claimed.State != storage.EscrowClaimed {
t.Fatalf("claim = %d state=%q, want 200/claimed", code, claimed.State)
}
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: req.GUID, OK: true, BalanceAfter: 1500})
if code != 200 || e.State != storage.EscrowFunded {
t.Fatalf("settle = %d state=%q, want 200/funded", code, e.State)
}
st, _ := storage.Chips(user)
if st.Chips != 500 || st.Pending != 0 {
t.Fatalf("after settle: chips=%d pending=%d, want 500/0", st.Chips, st.Pending)
}
if st.EuroBalance != 1500 {
t.Fatalf("euro balance = %v, want the 1500 gogobee reported", st.EuroBalance)
}
// Settled rows are done. A later poll must not offer it again, or gogobee
// would debit the player a second time.
if pending, _ := pollEscrow(t, s, "tok"); len(pending) != 0 {
t.Fatalf("settled row still pending: %+v", pending)
}
}
// TestEscrowVerdictReplayedCreatesChipsOnce is the property the whole guid
// scheme exists for. gogobee's push queue retries: the same verdict lands twice,
// and only the first one may create chips.
func TestEscrowVerdictReplayedCreatesChipsOnce(t *testing.T) {
s, _ := newAdvServer(t, "tok")
const user = "@replay:parodia.dev"
req, err := storage.RequestBuyIn(user, 200)
if err != nil {
t.Fatal(err)
}
if _, code := claimEscrow(t, s, "tok", req.GUID); code != 200 {
t.Fatalf("claim = %d", code)
}
for i := 0; i < 3; i++ {
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: req.GUID, OK: true, BalanceAfter: 800})
if code != 200 || e.State != storage.EscrowFunded {
t.Fatalf("settle %d = %d state=%q", i, code, e.State)
}
}
if st, _ := storage.Chips(user); st.Chips != 200 {
t.Fatalf("chips = %d after three deliveries of one verdict, want 200", st.Chips)
}
}
// TestEscrowRejectedBuyInMovesNothing — the player couldn't cover it. gogobee
// took no euros, so Pete must create no chips, and the pending amount has to
// clear or it would eat into the table cap forever.
func TestEscrowRejectedBuyInMovesNothing(t *testing.T) {
s, _ := newAdvServer(t, "tok")
const user = "@broke:parodia.dev"
req, err := storage.RequestBuyIn(user, 9000)
if err != nil {
t.Fatal(err)
}
if _, code := claimEscrow(t, s, "tok", req.GUID); code != 200 {
t.Fatalf("claim = %d", code)
}
e, code := settleEscrow(t, s, "tok",
escrowVerdict{GUID: req.GUID, OK: false, Reason: "insufficient_funds", BalanceAfter: 12})
if code != 200 || e.State != storage.EscrowRejected {
t.Fatalf("settle = %d state=%q, want 200/rejected", code, e.State)
}
if e.Reason != "insufficient_funds" {
t.Fatalf("reason = %q, want it carried back for the player to read", e.Reason)
}
st, _ := storage.Chips(user)
if st.Chips != 0 || st.Pending != 0 {
t.Fatalf("after rejection: chips=%d pending=%d, want 0/0", st.Chips, st.Pending)
}
}
// TestEscrowCashOutFailureReturnsChips — gogobee couldn't pay. The chips were
// destroyed the moment the cash-out opened, so if we simply mark it done the
// player's money is gone from both sides. It has to come back.
func TestEscrowCashOutFailureReturnsChips(t *testing.T) {
s, _ := newAdvServer(t, "tok")
const user = "@unlucky:parodia.dev"
buy, err := storage.RequestBuyIn(user, 300)
if err != nil {
t.Fatal(err)
}
if _, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: buy.GUID, OK: true}); code != 200 {
t.Fatalf("fund = %d", code)
}
out, err := storage.RequestCashOut(user, 300)
if err != nil {
t.Fatal(err)
}
if st, _ := storage.Chips(user); st.Chips != 0 {
t.Fatalf("chips during cash-out = %d, want 0 — they must not be bettable in flight", st.Chips)
}
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: out.GUID, OK: false, Reason: "ledger_error"})
if code != 200 {
t.Fatalf("settle = %d", code)
}
if e.State != storage.EscrowFunded {
t.Fatalf("state = %q, want funded — a failed cash-out gives the chips back", e.State)
}
if st, _ := storage.Chips(user); st.Chips != 300 {
t.Fatalf("chips = %d after a failed cash-out, want the 300 back", st.Chips)
}
}
// TestEscrowUnknownGUIDIsA400 — a verdict for a row Pete has never heard of.
// gogobee has already moved real euros for it, and no amount of retrying will
// conjure the row, so the answer is the one that parks the row in gogobee's
// queue for a human rather than the one that retries forever.
func TestEscrowUnknownGUIDIsA400(t *testing.T) {
s, _ := newAdvServer(t, "tok")
if _, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: "ghost", OK: true}); code != 400 {
t.Fatalf("settle unknown guid = %d, want 400", code)
}
if _, code := claimEscrow(t, s, "tok", "ghost"); code != 404 {
t.Fatalf("claim unknown guid = %d, want 404", code)
}
}
// TestEscrowNeedsTheBearerToken. These endpoints move money and are reachable on
// the same mux as the public news site.
func TestEscrowNeedsTheBearerToken(t *testing.T) {
s, _ := newAdvServer(t, "tok")
req, err := storage.RequestBuyIn("@x:parodia.dev", 10)
if err != nil {
t.Fatal(err)
}
if _, code := pollEscrow(t, s, "wrong"); code != 401 {
t.Fatalf("poll with a bad token = %d, want 401", code)
}
if _, code := claimEscrow(t, s, "", req.GUID); code != 401 {
t.Fatalf("claim with no token = %d, want 401", code)
}
if _, code := settleEscrow(t, s, "wrong", escrowVerdict{GUID: req.GUID, OK: true}); code != 401 {
t.Fatalf("settle with a bad token = %d, want 401", code)
}
if st, _ := storage.Chips("@x:parodia.dev"); st.Chips != 0 {
t.Fatal("an unauthenticated settle created chips")
}
}
// TestEscrowEmptyPollIsAnArrayNotNull. gogobee decodes into []Escrow; a bare
// `null` body decodes fine in Go but is a trap for anything else that ever reads
// this, and an empty poll is the overwhelmingly common case.
func TestEscrowEmptyPollIsAnArrayNotNull(t *testing.T) {
s, _ := newAdvServer(t, "tok")
req := httptest.NewRequest("GET", "/api/games/escrow/pending", nil)
req.Header.Set("Authorization", "Bearer tok")
w := httptest.NewRecorder()
s.handleEscrowPending(w, req)
if got := w.Body.String(); got != "[]\n" {
t.Fatalf("empty poll body = %q, want %q", got, "[]\n")
}
}

View File

@@ -119,6 +119,13 @@ type channelPage struct {
PrevURL string
NextURL string
Total int
// Roster is the live adventurer board, populated on the adventure section
// only and only on page 1 — it is present tense, and page 2 of an archive is
// not where anyone looks for what's happening right now.
Roster []RosterView
RosterStale bool
ShowRoster bool
}
type indexPage struct {
@@ -343,6 +350,10 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
NextURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page+1),
Total: total,
}
if ch.Slug == "adventure" && page == 1 {
data.Roster, data.RosterStale, _ = s.roster()
data.ShowRoster = true
}
s.render(w, "channel", data)
}

183
internal/web/roster.go Normal file
View File

@@ -0,0 +1,183 @@
package web
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"pete/internal/storage"
)
// The live adventurer board.
//
// Everything else Pete publishes about the realm is an *accomplishment* — a
// death, a clear, a milestone. Those are clippings: they read as archive the
// instant they land, however fast we deliver them, and no refresh interval fixes
// that. The board is the opposite kind of thing. It is state that is currently
// true, so it is worth looking at *now*, and it goes stale on its own if we stop
// hearing from gogobee.
//
// Direction of travel is gogobee → Pete, like every other adventure payload:
// Pete has no route back into the game box's network and we are not opening one.
// gogobee pushes the whole board; we replace ours with it.
const (
// rosterStaleAfter — how old a snapshot can get before the board stops
// claiming to be live. gogobee pushes every couple of minutes, so this is
// several missed pushes, not one unlucky one.
//
// This matters more than it looks: if gogobee dies mid-expedition, the last
// snapshot says "Josie is in holymachina" and would say so forever. A board
// that lies confidently is worse than one that admits it lost the wire —
// especially once players can act on it (see the target-list note below).
rosterStaleAfter = 12 * time.Minute
// rosterMaxEntries — hard cap on a snapshot. A bounded realm; this only
// exists so a malformed or hostile push can't spool unbounded rows.
rosterMaxEntries = 500
)
// rosterPush is the payload gogobee POSTs to /api/ingest/roster.
type rosterPush struct {
SnapshotAt int64 `json:"snapshot_at"`
Adventurers []storage.RosterEntry `json:"adventurers"`
}
// RosterView is one row as the page renders it.
type RosterView struct {
Name string
Level int
ClassRace string
OnRun bool
Zone string
Region string
Where string // "holymachina, day 3" | "in town"
Idle string // "quiet for 2 days" — only when idle
}
// handleRosterIngest replaces the board with gogobee's latest snapshot.
func (s *Server) handleRosterIngest(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var push rosterPush
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&push); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if len(push.Adventurers) > rosterMaxEntries {
http.Error(w, "roster too large", http.StatusBadRequest)
return
}
// A snapshot with no timestamp can't be aged, so it could never go stale —
// it would sit on the page claiming to be live forever. Treat it as now.
if push.SnapshotAt <= 0 {
push.SnapshotAt = time.Now().Unix()
}
// Never trust the channel with a name. Same rule as factGuard: gogobee
// pre-sanitizes to character names, and Pete checks anyway, because this is
// the last thing standing between the payload and a public page.
for i, e := range push.Adventurers {
if e.Token == "" || e.Name == "" {
http.Error(w, "each adventurer needs token and name", http.StatusBadRequest)
return
}
if e.Status != "expedition" && e.Status != "idle" {
http.Error(w, fmt.Sprintf("adventurer %d: unknown status %q", i, e.Status), http.StatusBadRequest)
return
}
}
if err := storage.ReplaceRoster(push.Adventurers, push.SnapshotAt); err != nil {
slog.Error("roster ingest: replace failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers))
w.WriteHeader(http.StatusOK)
}
// handleRosterAPI serves the board as JSON for the page's own re-poll, so an
// open tab goes live without a reload. Public — same exposure as the rendered
// page, no more.
func (s *Server) handleRosterAPI(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
views, stale, _ := s.roster()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"stale": stale,
"adventurers": views,
})
}
// roster loads the board and decides whether it is still live. A stale board is
// still returned — the page shows it dimmed and says so, rather than blanking,
// because "here is who was out when we lost contact" beats an empty page.
func (s *Server) roster() (views []RosterView, stale bool, snapshotAt int64) {
entries, snapshotAt, err := storage.LoadRoster()
if err != nil {
slog.Error("roster: load failed", "err", err)
return nil, true, 0
}
stale = snapshotAt == 0 || time.Since(time.Unix(snapshotAt, 0)) > rosterStaleAfter
for _, e := range entries {
views = append(views, toRosterView(e))
}
return views, stale, snapshotAt
}
func toRosterView(e storage.RosterEntry) RosterView {
v := RosterView{
Name: e.Name,
Level: e.Level,
ClassRace: e.ClassRace,
OnRun: e.Status == "expedition",
Zone: e.Zone,
Region: e.Region,
}
if v.OnRun {
where := e.Zone
if e.Region != "" {
where = fmt.Sprintf("%s, %s", e.Zone, e.Region)
}
if e.Day > 0 {
where = fmt.Sprintf("%s — day %d", where, e.Day)
}
v.Where = where
return v
}
v.Where = "in town"
v.Idle = humanIdle(e.IdleHours)
return v
}
// humanIdle renders the idle clock the way a person would say it. Deliberately
// coarse: this is colour on a board, not the boredom ticker's actual threshold.
func humanIdle(hours int) string {
switch {
case hours <= 0:
return ""
case hours < 2:
return "just got back"
case hours < 24:
return fmt.Sprintf("quiet for %dh", hours)
case hours < 48:
return "quiet for a day"
default:
return fmt.Sprintf("quiet for %d days", hours/24)
}
}

193
internal/web/roster_test.go Normal file
View File

@@ -0,0 +1,193 @@
package web
import (
"bytes"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
"pete/internal/storage"
)
func postRoster(t *testing.T, s *Server, token string, push rosterPush) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(push)
req := httptest.NewRequest("POST", "/api/ingest/roster", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.handleRosterIngest(w, req)
return w
}
func entry(token, name, status, zone string) storage.RosterEntry {
return storage.RosterEntry{Token: token, Name: name, Level: 5, ClassRace: "elf ranger", Status: status, Zone: zone}
}
// TestRosterReplacesNeverMerges is the whole contract of the board. gogobee
// sends the *complete* set of adventurers it is willing to show; anyone missing
// from a later snapshot has left the board — they deleted their character, or
// (the case that actually matters) they just ran `!news optout` and must
// disappear from a public page. An upsert would leave them standing there
// forever, which is precisely the exposure the opt-out exists to prevent.
func TestRosterReplacesNeverMerges(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
entry("t1", "Josie", "expedition", "holymachina"),
entry("t2", "Quack", "idle", ""),
}}); w.Code != 200 {
t.Fatalf("first push = %d, want 200", w.Code)
}
// Quack opts out; gogobee stops including her.
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now + 60, Adventurers: []storage.RosterEntry{
entry("t1", "Josie", "expedition", "holymachina"),
}}); w.Code != 200 {
t.Fatalf("second push = %d, want 200", w.Code)
}
views, stale, _ := s.roster()
if stale {
t.Error("fresh snapshot read as stale")
}
if len(views) != 1 {
t.Fatalf("board has %d rows, want 1 — a dropped adventurer survived the swap", len(views))
}
if views[0].Name != "Josie" {
t.Errorf("board kept %q, want Josie", views[0].Name)
}
}
// TestRosterGoesStale: if gogobee dies mid-expedition, the last snapshot says
// "Josie is in holymachina" and would say so forever. The board has to admit it
// lost the wire rather than keep asserting a stale fact as live.
func TestRosterGoesStale(t *testing.T) {
s, _ := newAdvServer(t, "tok")
old := time.Now().Add(-2 * rosterStaleAfter).Unix()
postRoster(t, s, "tok", rosterPush{SnapshotAt: old, Adventurers: []storage.RosterEntry{
entry("t1", "Josie", "expedition", "holymachina"),
}})
views, stale, _ := s.roster()
if !stale {
t.Error("snapshot older than rosterStaleAfter still reported live")
}
// Stale, but still shown: "who was out when we lost contact" beats a blank page.
if len(views) != 1 {
t.Errorf("stale board dropped its rows (%d), want them kept and dimmed", len(views))
}
}
// TestRosterEmptySnapshotIsNotStale: a realm where nobody is playing is a
// legitimate state and must not be confused with gogobee having gone away. This
// is why the snapshot time lives in its own row instead of MAX() over entries.
func TestRosterEmptySnapshotIsNotStale(t *testing.T) {
s, _ := newAdvServer(t, "tok")
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: time.Now().Unix()}); w.Code != 200 {
t.Fatalf("empty push = %d, want 200", w.Code)
}
views, stale, _ := s.roster()
if len(views) != 0 {
t.Errorf("empty snapshot produced %d rows", len(views))
}
if stale {
t.Error("a quiet realm read as a dead wire — the two must not collapse")
}
}
func TestRosterIngestRejects(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
if w := postRoster(t, s, "wrong", rosterPush{SnapshotAt: now}); w.Code != 401 {
t.Errorf("bad bearer = %d, want 401", w.Code)
}
// An unknown status would render as neither "out there" nor "in town".
bad := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
{Token: "t1", Name: "Josie", Status: "vibing"},
}}
if w := postRoster(t, s, "tok", bad); w.Code != 400 {
t.Errorf("unknown status = %d, want 400", w.Code)
}
// A nameless row would render an empty slot on a public page.
nameless := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
{Token: "t1", Status: "idle"},
}}
if w := postRoster(t, s, "tok", nameless); w.Code != 400 {
t.Errorf("nameless adventurer = %d, want 400", w.Code)
}
}
// TestDepartureRenders guards the cross-repo contract: gogobee emits this
// event_type for a bored adventurer, and an event_type Pete doesn't know is a
// 400 that retries and parks the bulletin forever.
func TestDepartureRenders(t *testing.T) {
headline, lede, ok := renderAdventure(AdvFact{
EventType: "departure",
Subject: "Camcast",
Zone: "crypt_valdris",
Level: 1,
Actors: []string{"Camcast"},
})
if !ok {
t.Fatal("departure event_type unknown to Pete — gogobee's bulletin would 400 and park")
}
if headline == "" || lede == "" {
t.Error("departure rendered empty")
}
}
func TestRosterViewWhere(t *testing.T) {
out := toRosterView(storage.RosterEntry{
Name: "Josie", Status: "expedition", Zone: "holymachina", Region: "the deep", Day: 3,
})
if !out.OnRun || out.Where != "holymachina, the deep — day 3" {
t.Errorf("expedition row = %+v", out)
}
in := toRosterView(storage.RosterEntry{Name: "Quack", Status: "idle", IdleHours: 50})
if in.OnRun || in.Where != "in town" || in.Idle != "quiet for 2 days" {
t.Errorf("idle row = %+v", in)
}
}
// TestAdventurePageRendersBoard drives the real page through the real template.
// The handler tests above prove the data is right; this proves it reaches a
// reader. A template slip (bad field, unclosed block) doesn't fail a build — it
// 500s the whole adventure section at request time, board and clippings alike.
func TestAdventurePageRendersBoard(t *testing.T) {
s, _ := newAdvServer(t, "tok")
postRoster(t, s, "tok", rosterPush{
SnapshotAt: time.Now().Unix(),
Adventurers: []storage.RosterEntry{
{Token: "t1", Name: "Josie", Level: 14, ClassRace: "human cleric",
Status: "expedition", Zone: "holymachina", Day: 3},
{Token: "t2", Name: "Quack", Level: 4, ClassRace: "elf ranger",
Status: "idle", IdleHours: 50},
},
})
req := httptest.NewRequest("GET", "/adventure", nil)
w := httptest.NewRecorder()
s.handleChannel(w, req, Channel{Slug: "adventure", Title: "Adventure", Theme: "adventure"})
if w.Code != 200 {
t.Fatalf("GET /adventure = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{
"Out there right now", // the board's headline
"Josie", "holymachina", "day 3", // live zone, shown while she's still in it
"Quack", "in town", "quiet for 2 days",
"/api/roster", // the client re-poll, so an open tab stays true
} {
if !strings.Contains(body, want) {
t.Errorf("adventure page missing %q", want)
}
}
}

View File

@@ -9,6 +9,7 @@ import (
"io/fs"
"log/slog"
"net/http"
"strings"
"sync"
"time"
@@ -77,22 +78,39 @@ type Server struct {
salt [16]byte
}
// New builds the server. Templates are parsed once at startup. Each page
// gets its own template set sharing layout.html + _card.html, which avoids
// `main` define collisions between pages.
// New builds the server. Templates are parsed once at startup. Each page gets
// its own template set sharing a layout, which avoids `main` define collisions
// between pages.
//
// There are two layouts, and they are not the same site. The news pages hang off
// layout.html: Pete's face, the channel nav, search, the reader, the weather
// canvas. The casino hangs off games_layout.html, which shares the *design* —
// the palette vars, the fonts, the fat rounded cards — and none of the
// furniture. games.parodia.dev is its own place, not a news page with a felt on
// it, so it doesn't inherit a single control it has no use for.
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) {
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}
tpls := make(map[string]*template.Template, len(pages))
for _, p := range pages {
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
"templates/layout.html",
"templates/_card.html",
"templates/"+p+".html",
)
if err != nil {
return nil, err
sets := []struct {
layout string
shared []string
pages []string
}{
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack"}},
}
tpls := make(map[string]*template.Template)
for _, set := range sets {
for _, p := range set.pages {
files := []string{"templates/" + set.layout + ".html"}
for _, s := range set.shared {
files = append(files, "templates/"+s+".html")
}
files = append(files, "templates/"+p+".html")
t, err := template.New("").Funcs(funcs).ParseFS(templateFS, files...)
if err != nil {
return nil, err
}
tpls[p] = t
}
tpls[p] = t
}
infos := make([]SourceInfo, 0, len(sources))
for _, sc := range sources {
@@ -188,6 +206,11 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
// outside the sign-in block. Self-gates on adv.Enabled (404 when off).
mux.HandleFunc("POST /api/ingest/adventure", s.handleAdventureIngest)
// The live board. Ingest is bearer-authed like the fact seam; the read side
// is public because it renders on a public page anyway.
mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest)
mux.HandleFunc("GET /api/roster", s.handleRosterAPI)
// Per-dispatch permalink (the article_url every ingested story points at).
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
// channel listing, registered in the channels loop above).
@@ -197,6 +220,27 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
// /adventure/{guid}, so the two patterns never overlap.
mux.HandleFunc("GET /adventure/art/{type}", s.handleAdventureArt)
// The euro/chip border. gogobee polls pending, claims a row, moves the euros,
// and pushes the verdict back. Bearer-authed on the same ingest token as the
// adventure seam, so like it, these sit outside the sign-in block — the caller
// is a machine on the tailnet, not a browser.
mux.HandleFunc("GET /api/games/escrow/pending", s.handleEscrowPending)
mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim)
mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled)
// The casino. Signed-in only — there is money in it — so these hang off the
// auth block, and gamesReady() also insists on a Matrix server name: without
// one, no player can be named to gogobee's ledger and the tables stay shut.
if s.gamesReady() {
mux.HandleFunc("GET /games", s.handleLobby)
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /api/games/table", s.handleTable)
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
}
if s.auth != nil {
mux.HandleFunc("GET /auth/login", s.auth.handleLogin)
mux.HandleFunc("GET /auth/callback", s.auth.handleCallback)
@@ -219,12 +263,56 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
s.srv = &http.Server{
Addr: cfg.ListenAddr,
Handler: mux,
Handler: s.hostRouter(mux),
ReadHeaderTimeout: 5 * time.Second,
}
return s, nil
}
// hostRouter puts the casino at the root of its own hostname without giving it
// its own mux. games.parodia.dev and news.parodia.dev are the same process on
// the same port — Caddy sends both here — so a request that arrives on the games
// host has /games prefixed onto its path, and "/" lands on the lobby.
//
// Shared plumbing (the API, sign-in, static files, health) is left alone: it is
// the same plumbing whichever door you came in by, and the login round-trip in
// particular has to keep working on both hosts.
func (s *Server) hostRouter(mux http.Handler) http.Handler {
host := strings.ToLower(strings.TrimSpace(s.cfg.Games.Host))
if host == "" || !s.gamesReady() {
return mux
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.EqualFold(hostOnly(r.Host), host) || isSharedPath(r.URL.Path) {
mux.ServeHTTP(w, r)
return
}
// Rewrite a copy: the request's URL is shared with anything that logged it.
r2 := r.Clone(r.Context())
r2.URL.Path = "/games" + strings.TrimSuffix(r.URL.Path, "/")
mux.ServeHTTP(w, r2)
})
}
// isSharedPath marks the paths that mean the same thing on every host and must
// not be shuffled under /games.
func isSharedPath(p string) bool {
for _, prefix := range []string{"/api/", "/auth/", "/static/", "/img/", "/games"} {
if strings.HasPrefix(p, prefix) {
return true
}
}
return p == "/healthz" || p == "/sw.js" || p == "/manifest.webmanifest"
}
// hostOnly strips the port from a Host header.
func hostOnly(host string) string {
if i := strings.IndexByte(host, ':'); i >= 0 {
return host[:i]
}
return host
}
// Start runs the HTTP server and blocks until ctx is canceled.
func (s *Server) Start(ctx context.Context) {
go func() {

View File

@@ -527,4 +527,471 @@ html[data-phase="night"] {
font-size: 0.8rem;
font-weight: 700;
}
/* ---- the casino ---------------------------------------------------------
Cards are dealt, not swapped in: each one starts at the shoe in the corner,
flies to its place, and turns over. The whole point of the table is that you
watch it happen, so this is written as one animation per card with a delay,
and the JS just says which cards exist and in what order. */
.pete-felt {
background:
radial-gradient(120% 90% at 50% -10%, rgba(255,255,255,0.10), transparent 60%),
linear-gradient(160deg, #2f7d5b 0%, #24614a 55%, #1c4d3c 100%);
}
/* The shoe: where every card comes from. */
.pete-shoe {
position: absolute;
top: 1.25rem;
right: 1.25rem;
height: 4.2rem;
width: 3rem;
border-radius: 0.55rem;
background: linear-gradient(150deg, #b4553f, #8d3f2f);
border: 2px solid rgba(0,0,0,0.18);
box-shadow: 0 4px 0 rgba(0,0,0,0.18), inset 0 0 0 3px rgba(255,255,255,0.12);
}
.pete-shoe::after {
content: "";
position: absolute;
inset: 0.45rem 0.4rem auto 0.4rem;
height: 0.5rem;
border-radius: 0.2rem;
background: rgba(0,0,0,0.22);
}
.pete-hand {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
min-height: 8.6rem;
}
/* One card. The wrapper does the flight, the inner face does the flip, so the
two never fight over the same transform. */
.pete-card {
perspective: 700px;
height: 8.4rem;
width: 6rem;
animation: pete-deal 0.42s cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
.pete-card-inner {
position: relative;
height: 100%;
width: 100%;
transform-style: preserve-3d;
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Face-down is the resting state of a card that hasn't been turned over: the
hole card sits like this until the dealer plays. */
.pete-card[data-face="down"] .pete-card-inner { transform: rotateY(180deg); }
.pete-card-front,
.pete-card-back {
position: absolute;
inset: 0;
display: grid;
border-radius: 0.55rem;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
box-shadow: 0 3px 0 rgba(0,0,0,0.18), 0 6px 14px rgba(0,0,0,0.22);
}
/* The face is a single SVG on a 100×140 field (see blackjack.js) — suits as
vector shapes, pips at printed-deck coordinates. The card element only has
to hold it and colour it: `fill: currentColor` means the red/black switch
below repaints every pip, index and court letter at once. */
.pete-card-front {
place-items: stretch;
background: #fdfaf2;
border: 2px solid rgba(30,20,10,0.12);
color: #2b2118;
font-family: "Fredoka", "Nunito", system-ui, sans-serif;
line-height: 1;
overflow: hidden;
}
.pete-card-front[data-red="1"] { color: #cc3d4a; }
/* The back. Turned away from you until the card is: this is what the dealer's
hole card shows while you're still acting. */
.pete-card-back {
transform: rotateY(180deg);
background:
repeating-linear-gradient(45deg, rgba(255,255,255,0.10) 0 6px, transparent 6px 12px),
linear-gradient(150deg, #b4553f, #8d3f2f);
border: 2px solid rgba(0,0,0,0.15);
}
.pete-card-svg {
width: 100%;
height: 100%;
display: block;
fill: currentColor;
}
/* The corner index. Tabular-ish and tight, so a 10 doesn't shove the suit. */
.pete-card-idx {
font-size: 26px;
font-weight: 700;
text-anchor: middle;
fill: currentColor;
}
/* The court panel: a frame, not a portrait. */
.pete-card-panel {
fill: currentColor;
fill-opacity: 0.06;
stroke: currentColor;
stroke-opacity: 0.35;
stroke-width: 1.5;
}
.pete-card-court {
font-size: 34px;
font-weight: 700;
text-anchor: middle;
fill: currentColor;
}
/* The flight itself: out of the shoe (up and to the right), scaled down and
spinning slightly, into place — and then a beat of settling, because a card
that stops dead on the felt looks like it was pasted there. It overshoots a
little and rocks back onto --tilt, the small resting angle the JS gives each
card so a hand looks handled rather than typeset. */
@keyframes pete-deal {
0% {
opacity: 0;
transform: translate(var(--deal-x, 14rem), var(--deal-y, -7rem)) scale(0.72) rotate(9deg);
}
62% {
opacity: 1;
transform: translate(0, 0.35rem) scale(1.05) rotate(calc(var(--tilt, 0deg) - 2deg));
}
100% {
opacity: 1;
transform: translate(0, 0) scale(1) rotate(var(--tilt, 0deg));
}
}
/* And the weight: the felt takes the hit. The shadow under a landing card
spreads and darkens for the instant it arrives. */
.pete-card::after {
content: "";
position: absolute;
inset: auto 6% -0.35rem 6%;
height: 0.6rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.35);
filter: blur(5px);
animation: pete-thud 0.42s cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes pete-thud {
0%, 55% { opacity: 0; transform: scale(0.4); }
72% { opacity: 0.9; transform: scale(1.15); }
100% { opacity: 0.45; transform: scale(1); }
}
/* A settled hand: the winning side gets a little lift, so a win reads at a
glance rather than only in the text. */
.pete-hand[data-won="1"] .pete-card { animation: pete-deal 0.42s cubic-bezier(0.22,1,0.36,1) backwards, pete-win 0.6s ease 0.1s; }
@keyframes pete-win {
0%, 100% { transform: rotate(var(--tilt, 0deg)) translateY(0); }
40% { transform: rotate(var(--tilt, 0deg)) translateY(-0.6rem); }
}
/* A losing hand doesn't just sit there being wrong: it greys off. */
.pete-hand[data-won="-1"] .pete-card {
filter: saturate(0.45) brightness(0.78);
transition: filter 0.5s ease 0.2s;
}
/* ---- chips ---------------------------------------------------------------
One disc, three jobs: the buttons you bet with, the stack sitting in the bet
spot, and the chip in mid-air between them. Same face on all three, because
they are meant to read as the *same chip* being moved around. */
.pete-disc {
position: relative;
border-radius: 999px;
background:
radial-gradient(circle at 50% 30%, rgba(255,255,255,0.30), transparent 62%),
var(--chip, #e07a5f);
border: 2px solid rgba(0,0,0,0.22);
box-shadow: 0 3px 0 rgba(0,0,0,0.28), 0 6px 14px rgba(0,0,0,0.20);
}
/* The edge spots every casino chip has, cut as a dashed ring inside the rim. */
.pete-disc::before {
content: "";
position: absolute;
inset: 11%;
border-radius: 999px;
border: 2px dashed rgba(255,255,255,0.55);
}
.pete-disc[data-chip="5"] { --chip: #5aa9e6; }
.pete-disc[data-chip="25"] { --chip: #4caf7d; }
.pete-disc[data-chip="100"] { --chip: #2b2118; }
.pete-disc[data-chip="500"] { --chip: #b079d6; }
.pete-chip { /* the buttons */
transition: transform 0.12s ease;
}
.pete-chip > span {
position: relative; /* over the dashed ring */
}
.pete-chip:active { transform: translateY(1px) scale(0.94); }
/* The bet spot: where your stake actually sits while the hand is played. */
.pete-spot {
position: relative;
display: grid;
place-items: center;
height: 7rem;
width: 7rem;
flex: none;
border-radius: 999px;
border: 2px dashed rgba(255,255,255,0.35);
background: radial-gradient(circle at 50% 40%, rgba(255,255,255,0.10), transparent 70%);
box-shadow: inset 0 0 0 6px rgba(0,0,0,0.08);
transition: box-shadow 0.3s ease, border-color 0.3s ease;
}
.pete-spot[data-live="1"] {
border-color: rgba(var(--glow, 242,181,61), 0.85);
box-shadow: inset 0 0 0 6px rgba(0,0,0,0.08), 0 0 22px rgba(var(--glow, 242,181,61), 0.35);
}
.pete-spot-label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255,255,255,0.35);
}
.pete-spot[data-live="1"] .pete-spot-label { opacity: 0; }
/* The stack in the spot. Each chip is offset up the pile by its index, so ten
chips look like ten chips rather than one chip with a number on it. */
.pete-stack {
position: absolute;
inset: 0;
pointer-events: none;
}
.pete-stack .pete-disc {
position: absolute;
left: 50%;
top: 50%;
height: 2.6rem;
width: 2.6rem;
margin: -1.3rem 0 0 -1.3rem;
/* Each chip sits proud of the one below it by more than its own rim, so a
stack of three reads as three. Less than this and they hide inside each
other and a 150 bet looks the same as a 25 one. */
transform: translateY(calc(-0.5rem * var(--i, 0))) rotate(var(--spin, 0deg));
box-shadow: 0 2px 0 rgba(0,0,0,0.35), 0 4px 8px rgba(0,0,0,0.25);
animation: pete-chip-land 0.28s cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
}
@keyframes pete-chip-land {
from { transform: translateY(calc(-0.5rem * var(--i, 0) - 1.1rem)) rotate(var(--spin, 0deg)) scale(1.18); }
}
.pete-spot-total {
position: absolute;
bottom: -0.6rem;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
border-radius: 999px;
background: rgba(0,0,0,0.45);
padding: 0.1rem 0.6rem;
font-size: 0.72rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #fff;
}
/* A chip in mid-air. Lives in a fixed overlay so it can cross from the betting
buttons, over the page, and onto the felt without any container clipping it. */
.pete-fly-layer {
position: fixed;
inset: 0;
z-index: 60;
pointer-events: none;
overflow: hidden;
}
.pete-fly {
position: absolute;
height: 2.6rem;
width: 2.6rem;
margin: -1.3rem 0 0 -1.3rem;
will-change: transform;
}
/* The house's own chips, racked on the felt. This is where a payout comes
from, and where a losing stake goes — so the money has somewhere to be.
It sits alongside the shoe rather than over on the left, because the left of
the felt is where the dealer's cards land. */
.pete-rack {
position: absolute;
top: 1.25rem;
right: 5.75rem;
display: flex;
align-items: flex-end;
gap: 0.4rem;
padding: 0.5rem 0.6rem 0.45rem;
border-radius: 0.75rem;
background: rgba(0,0,0,0.20);
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.06);
}
/* A column in the rack is a *stack*, so it's striped: one band per chip, with
the shadow of the chip above it cut into each. A plain coloured lozenge here
read as a blob, which is the one thing a pile of money shouldn't. */
.pete-rack span {
display: block;
width: 1.7rem;
border-radius: 0.35rem;
background:
linear-gradient(90deg, rgba(0,0,0,0.28), transparent 35%, transparent 65%, rgba(0,0,0,0.28)),
repeating-linear-gradient(180deg,
rgba(255,255,255,0.18) 0 0.06rem,
var(--chip, #e07a5f) 0.06rem 0.3rem,
rgba(0,0,0,0.35) 0.3rem 0.36rem);
border: 1px solid rgba(0,0,0,0.35);
height: calc(0.36rem * var(--stack, 3));
}
.pete-rack span[data-chip="5"] { --chip: #5aa9e6; }
.pete-rack span[data-chip="25"] { --chip: #4caf7d; }
.pete-rack span[data-chip="100"] { --chip: #2b2118; }
.pete-rack span[data-chip="500"] { --chip: #b079d6; }
/* The burst. A natural gets confetti; nothing else in the room does, which is
what keeps it worth something. */
.pete-spark {
position: absolute;
height: 0.75rem;
width: 0.45rem;
border-radius: 2px;
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
will-change: transform, opacity;
}
/* The dealer's beat before they draw out. */
.pete-dealer-think {
animation: pete-think 0.9s ease-in-out infinite;
}
@keyframes pete-think {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; transform: translateY(-1px); }
}
@media (prefers-reduced-motion: reduce) {
.pete-card,
.pete-card::after,
.pete-stack .pete-disc,
.pete-dealer-think { animation: none; }
.pete-card-inner { transition: none; }
.pete-hand[data-won="1"] .pete-card { animation: none; }
}
}
/* ----------------------------------------------------------------------------
The rooms.
The casino borrows the news app's design language wholesale — the same four
palette vars, the same Fredoka/Nunito, the same fat rounded cards and dropped
shadows — and none of its furniture.
It borrows the clock too, but tells a different joke with it. News shifts
dawn→day→dusk→night. The casino has two rooms and swaps between them on the
hour, name and all:
casinopolis by day — honey and green felt, lamps low
casino-night by dark — neon blue, pinball purple, lit like a machine
Both are dark rooms of the same shape; only the light and the sign change.
Adding a third is a palette block, a felt, and a name — nothing else.
Written outside @layer on purpose: these must beat the base .shadow-pete.
---------------------------------------------------------------------------- */
html[data-room="casinopolis"] {
--bg: #16211c; /* the room, lights low */
--card: #23342b; /* a table in it */
--ink: #f6ecd8; /* warm cream, not white */
--accent: #f2b53d; /* honey */
--felt-a: #2f7d5b;
--felt-b: #24614a;
--felt-c: #1c4d3c;
--glow: 242,181,61; /* what the lamps throw, as rgb parts */
}
html[data-room="casino-night"] {
--bg: #140f2e; /* the machine's cabinet */
--card: #241a4d;
--ink: #f2ecff;
--accent: #ffcc2f; /* the jackpot bulb */
--felt-a: #4a2fa8; /* the table is a pinball table now */
--felt-b: #351f80;
--felt-c: #241659;
--glow: 126,86,255;
}
/* A lamp over the table and a darker floor at the edges, so the page has a
middle to sit in rather than being flat dark. */
html[data-room] .cs-room {
background:
radial-gradient(80% 55% at 50% -8%, rgba(var(--glow), 0.18), transparent 62%),
radial-gradient(120% 90% at 50% 120%, rgba(0,0,0,0.45), transparent 55%);
}
/* Casino Night is lit by bulbs, so it gets a few of them: a slow chase across
the top of the room. Cheap (one gradient, one transform) and it makes the
place feel plugged in rather than painted. */
html[data-room="casino-night"] .cs-room::before {
content: "";
position: absolute;
inset: 0 -50% auto -50%;
height: 0.5rem;
background: repeating-linear-gradient(90deg,
rgba(255,204,47,0.55) 0 0.5rem, transparent 0.5rem 2.25rem);
filter: blur(1px);
animation: cs-bulbs 2.4s linear infinite;
}
@keyframes cs-bulbs {
from { transform: translateX(0); }
to { transform: translateX(2.75rem); }
}
/* The house shadow, restruck for a dark room: the news app's soft brown lift is
invisible down here, so the same shape is cut in black instead. */
html[data-room] .shadow-pete { box-shadow: 0 4px 0 rgba(0,0,0,0.30), 0 10px 26px rgba(0,0,0,0.30); }
html[data-room] .shadow-pete-lg { box-shadow: 0 6px 0 rgba(0,0,0,0.34), 0 20px 40px rgba(0,0,0,0.38); }
html[data-room] .cs-comb svg { filter: drop-shadow(0 3px 0 rgba(0,0,0,0.35)); }
/* The felt takes its colours from the room, so switching rooms reupholsters the
table too. */
html[data-room] .pete-felt {
background:
radial-gradient(120% 90% at 50% -10%, rgba(255,255,255,0.10), transparent 60%),
linear-gradient(160deg, var(--felt-a) 0%, var(--felt-b) 55%, var(--felt-c) 100%);
}
@media (prefers-reduced-motion: reduce) {
html[data-room="casino-night"] .cs-room::before { animation: none; }
}
/* Three stacks of chips on the lobby's welcome card, in the same denomination
colours you actually bet with. Height comes from --stack, so the row reads as
a real pile someone left on the table rather than an icon of one. */
.cs-stack {
display: block;
width: 2.75rem;
height: calc(0.45rem * var(--stack, 1) + 0.55rem);
border-radius: 999px;
background:
radial-gradient(circle at 50% 30%, rgba(255,255,255,0.30), transparent 60%),
var(--chip, #e07a5f);
border: 2px solid rgba(0,0,0,0.25);
box-shadow:
0 3px 0 rgba(0,0,0,0.30),
inset 0 -0.4rem 0 rgba(0,0,0,0.16),
inset 0 0.35rem 0 rgba(255,255,255,0.14);
}
.cs-stack[data-chip="5"] { --chip: #5aa9e6; }
.cs-stack[data-chip="25"] { --chip: #4caf7d; }
.cs-stack[data-chip="100"] { --chip: #2b2118; }
.cs-stack[data-chip="500"] { --chip: #b079d6; }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,591 @@
// The blackjack table.
//
// The browser holds no game. It sends intents — deal, hit, stand, double — and
// the server answers with the cards you're allowed to see plus the *script* of
// how they got there: one event per card off the shoe, in the order the shoe
// gave them up. This file's job is to play that script back at a human speed
// rather than snapping the finished hand into place.
//
// Which is also why the hole card works the way it does: the server sends a
// "dealer_hole" event with no card attached, because while you are still acting
// it hasn't told anyone what that card is. It arrives with the reveal.
//
// The money is animated on the same principle. Every number the server sends is
// also a movement: a stake is chips leaving your pile and landing on the spot, a
// payout is chips coming out of the house's rack, a loss is your stack being
// taken away. Nothing about the money changes on this table without something
// crossing the felt to make it change — including the count in the chip bar,
// which is deliberately not updated until the chips that justify it have landed.
(function () {
"use strict";
var root = document.querySelector("[data-blackjack]");
if (!root) return;
var FX = window.PeteFX;
var dealerEl = root.querySelector("[data-dealer]");
var playerEl = root.querySelector("[data-player]");
var dTotalEl = root.querySelector("[data-dealer-total]");
var pTotalEl = root.querySelector("[data-player-total]");
var dLabelEl = root.querySelector("[data-dealer-label]");
var verdictEl = root.querySelector("[data-verdict]");
var betting = root.querySelector("[data-betting]");
var actions = root.querySelector("[data-actions]");
var betAmount = root.querySelector("[data-bet-amount]");
var dealBtn = root.querySelector("[data-deal]");
var msgEl = root.querySelector("[data-table-msg]");
// The three places a chip can be: your pile (in the bar above), the spot in
// front of you, and the house's rack on the felt.
var purseEl = document.querySelector("[data-chips]");
var spotEl = root.querySelector("[data-spot]");
var stackEl = root.querySelector("[data-stack]");
var spotTotalEl = root.querySelector("[data-spot-total]");
var houseEl = root.querySelector("[data-house]");
// Nothing is bet until a chip is on the felt. The number in the panel is a
// readout of the pile, so it starts where the pile does — at nothing — rather
// than at a default stake nobody put down.
var bet = 0; // what you're building between hands
var staked = 0; // what is actually sitting on the spot right now
var busy = false; // a request is in flight, or cards are still landing
var hand = null; // the hand as the server last described it
var DEAL_MS = 380; // one card's flight, and the gap before the next
var FLIP_MS = 450;
var BEAT_MS = 600; // the dealer thinking before they draw out
var reduced = FX.reduced;
function pace(ms) { return reduced ? 0 : ms; }
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
function say(text, tone) {
if (!text) { msgEl.classList.add("hidden"); return; }
msgEl.textContent = text;
msgEl.classList.remove("hidden");
msgEl.style.color = tone === "bad" ? "#cc3d4a" : "";
}
// ---- drawing --------------------------------------------------------------
var dealt = 0; // how many cards this table has put down, ever — the tilt seed
// cardEl builds one card. face === null means face-down: the card is dealt,
// but this browser has not been told what it is.
function cardEl(face) {
var wrap = document.createElement("div");
wrap.className = "pete-card";
wrap.dataset.face = face ? "up" : "down";
// Every card flies out of the shoe, which sits in the top-right of the felt.
// The offset is per-card, so a card landing further left flies further.
wrap.style.setProperty("--deal-x", "14rem");
wrap.style.setProperty("--deal-y", "-6rem");
// Where it comes to rest. A degree or two either way is the whole difference
// between cards that were dealt onto a table and cards that were laid out in
// a grid, and it costs one custom property.
wrap.style.setProperty("--tilt", FX.jitter(dealt++, 2.4).toFixed(2) + "deg");
var inner = document.createElement("div");
inner.className = "pete-card-inner";
var front = document.createElement("div");
front.className = "pete-card-front";
var back = document.createElement("div");
back.className = "pete-card-back";
inner.appendChild(front);
inner.appendChild(back);
wrap.appendChild(inner);
if (face) paintFace(front, face);
return wrap;
}
// ---- the face ------------------------------------------------------------
//
// A card is drawn, not typed. The first attempt set the pips as text — "♠" in a
// span — and at the size a card actually is, a suit character renders as a
// speck: the shape is whatever font happened to answer, it doesn't scale, and
// it can't be positioned to the half-row a real pip layout needs.
//
// So each face is one SVG on a 100×140 field (the proportions of a real card),
// with the suits as vector shapes. Everything below is coordinates on that
// field, which is why the pips land where a printed deck puts them instead of
// where a flexbox felt like putting them.
var SUIT_ART = {
"♠": '<path d="M50 6C50 6 16 34 16 58a17 17 0 0 0 28 13c-1 12-5 19-12 25h36c-7-6-11-13-12-25a17 17 0 0 0 28-13C84 34 50 6 50 6Z"/>',
"♥": '<path d="M50 96C50 96 8 66 8 38A22 22 0 0 1 50 24 22 22 0 0 1 92 38c0 28-42 58-42 58Z"/>',
"♦": '<path d="M50 4 88 50 50 96 12 50Z"/>',
"♣": '<g><circle cx="50" cy="28" r="19"/><circle cx="24" cy="62" r="19"/><circle cx="76" cy="62" r="19"/>' +
'<path d="M44 60h12l7 36H37Z"/></g>',
};
// Pip layouts, the way a real deck lays them out — which is not "N suits in a
// row". [x, y] on the 100×140 field. The seven canonical rows sit at y = 27,
// 41, 56, 70, 84, 99, 113; sevens, eights and tens carry a pip *between* two of
// them, which is the whole reason this is a table of coordinates and not a
// grid. Anything below the middle is printed upside down, so it is.
var R = [0, 27, 41.4, 55.7, 70, 84.3, 98.6, 113]; // 1-indexed, R[4] is the middle
var L = 30, C = 50, Rr = 70; // the three columns
var PIPS = {
"A": [[C, 70, 2.1]],
"2": [[C, R[1]], [C, R[7]]],
"3": [[C, R[1]], [C, R[4]], [C, R[7]]],
"4": [[L, R[1]], [Rr, R[1]], [L, R[7]], [Rr, R[7]]],
"5": [[L, R[1]], [Rr, R[1]], [C, R[4]], [L, R[7]], [Rr, R[7]]],
"6": [[L, R[1]], [Rr, R[1]], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
"7": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
"8": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
"9": [[L, R[1]], [Rr, R[1]], [L, R[3]], [Rr, R[3]], [C, R[4]], [L, R[5]], [Rr, R[5]], [L, R[7]], [Rr, R[7]]],
"10": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[3]], [Rr, R[3]], [L, R[5]], [Rr, R[5]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
};
var COURT = { "J": "Jack", "Q": "Queen", "K": "King" };
// One pip: the suit art, scaled and dropped at [x, y], turned over if it sits
// below the middle of the card.
function pipAt(suit, x, y, scale) {
var s = (scale || 1) * 0.17;
var turn = y > 70 ? " rotate(180 50 50)" : "";
return '<g transform="translate(' + x + ' ' + y + ') scale(' + s + ') translate(-50 -50)' + turn + '">' +
SUIT_ART[suit] + "</g>";
}
// The corner index: rank over suit. Printed in both corners, the second one
// upside down, which is what lets you read a card from a fanned hand.
function index(face) {
var g =
'<g>' +
'<text x="12" y="24" class="pete-card-idx">' + face.rank + "</text>" +
'<g transform="translate(12 36) scale(0.13) translate(-50 -50)">' + SUIT_ART[face.suit] + "</g>" +
"</g>";
return g + '<g transform="rotate(180 50 70)">' + g + "</g>";
}
// paintFace draws the card. The dealer's cards and yours use the same face,
// because they came out of the same shoe.
function paintFace(front, face) {
front.dataset.red = face.red ? "1" : "0";
var body = "";
if (COURT[face.rank]) {
// Court cards: a framed panel, the suit above the letter and again below it
// the other way up. A real court mirrors a *figure*; mirroring a letter just
// stacks two of them into a blob, which is exactly what the first attempt
// did. No portrait either — a drawn king would fight the room, and this
// reads instantly at the size a card actually is.
body =
'<rect x="20" y="22" width="60" height="96" rx="6" class="pete-card-panel"/>' +
pipAt(face.suit, 50, 38, 0.95) +
'<text x="50" y="82" class="pete-card-court">' + face.rank + "</text>" +
pipAt(face.suit, 50, 102, 0.95);
} else {
var spots = PIPS[face.rank] || [];
for (var i = 0; i < spots.length; i++) {
body += pipAt(face.suit, spots[i][0], spots[i][1], spots[i][2]);
}
}
front.innerHTML =
'<svg class="pete-card-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg" ' +
'role="img" aria-label="' + ariaFor(face) + '">' + index(face) + body + "</svg>";
}
// "A♠" is not something a screen reader should be asked to pronounce.
function ariaFor(face) {
var SUITS = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" };
var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank);
var suit = SUITS[face.suit];
return suit ? name + " of " + suit : face.label;
}
// turnOver flips a face-down card up, now that we've been told what it is.
function turnOver(wrap, face) {
if (!wrap) return;
paintFace(wrap.querySelector(".pete-card-front"), face);
wrap.dataset.face = "up";
}
// ---- the money on the felt -------------------------------------------------
//
// `staked` is what the spot is holding. Every path that changes it also moves
// chips to say so, so the two can't come apart: renderStack draws the pile,
// and the fly* calls are what put it there.
function renderStack(amount) {
staked = amount || 0;
stackEl.innerHTML = "";
spotEl.dataset.live = staked > 0 ? "1" : "0";
if (!staked) {
spotTotalEl.classList.add("hidden");
return;
}
FX.chipsFor(staked).forEach(function (d, i) {
var c = FX.disc(d);
c.style.setProperty("--i", i);
c.style.setProperty("--spin", FX.jitter(i, 12).toFixed(1) + "deg");
c.style.animationDelay = pace(i * 40) + "ms";
stackEl.appendChild(c);
});
spotTotalEl.textContent = staked.toLocaleString();
spotTotalEl.classList.remove("hidden");
}
// pour throws a run of chips from one place to another and grows the pile on
// the spot as each one lands — by the value of the chip that landed, so the
// total under the pile counts up the way the chips do. The last chip carries
// the remainder, because chipsFor caps how many chips it will make you watch
// and the pile still has to end on the real number.
function pour(from, to, amount, opts) {
if (amount <= 0) return Promise.resolve();
var base = staked;
var chips = FX.chipsFor(amount, 8);
var run = 0;
return FX.flyMany(from, to, chips, Object.assign({
onLand: function (d, i) {
run += d;
renderStack(base + (i === chips.length - 1 ? amount : run));
},
}, opts || {}));
}
// stake moves chips from your pile onto the spot: the bet you build before a
// deal, and the second bet a double puts down beside it.
function stake(amount, from) {
return pour(from || purseEl, spotEl, amount);
}
// settleChips is what the felt does about the outcome, after the cards have
// finished telling you what it is. It reads the same two numbers the ledger
// moved: `bet` (already off your pile since the deal) and `payout` (what comes
// back — stake plus winnings less rake, or nothing at all).
function settleChips(final) {
var payout = final.payout || 0;
var back = payout - final.bet; // what the house is adding, if anything
if (payout <= 0) {
// The house takes it. The stack goes to the rack and doesn't come back.
var lost = FX.chipsFor(final.bet, 8);
var chain = FX.flyMany(spotEl, houseEl, lost, { gap: 45, lift: 0.6, fade: true });
renderStack(0);
return chain;
}
// The house pays first, into the spot beside your stake, so you watch the
// winnings arrive on top of the bet that earned them.
var pay = pour(houseEl, spotEl, back, { gap: 60 });
// Paid, then swept up: the whole lot comes back to your pile, and only then
// does the number in the bar move.
return pay
.then(function () { return wait(back > 0 ? 380 : 200); })
.then(function () {
var home = FX.flyMany(spotEl, purseEl, FX.chipsFor(payout, 8), { gap: 40, lift: 0.8 });
renderStack(0);
return home;
});
}
function totals(v) {
if (v.total) {
pTotalEl.textContent = v.total + (v.soft ? " (soft)" : "");
pTotalEl.classList.remove("hidden");
} else {
pTotalEl.classList.add("hidden");
}
// While the hole card is down, the dealer's total is only what's showing —
// so say so, rather than printing a number that quietly means something else.
if (v.dealer && v.dealer.length) {
dTotalEl.textContent = v.hole ? v.dealer_total + " showing" : String(v.dealer_total);
dTotalEl.classList.remove("hidden");
} else {
dTotalEl.classList.add("hidden");
}
}
// paint puts a hand on the felt with no animation. This is the resume path:
// you reloaded, or Pete restarted, and your cards are simply there — including
// the stake, which is still on the spot because the server still has it.
function paint(v) {
dealerEl.innerHTML = "";
playerEl.innerHTML = "";
if (!v) { setPhase(null); renderStack(0); return; }
v.player.forEach(function (c) { playerEl.appendChild(cardEl(c)); });
v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); });
if (v.hole) dealerEl.appendChild(cardEl(null));
renderStack(v.phase === "done" ? 0 : v.bet);
totals(v);
setPhase(v);
}
var VERDICTS = {
blackjack: "Blackjack! 🎉",
win: "You win!",
dealer_bust: "Dealer busts. You win!",
lose: "Dealer takes it.",
bust: "Bust.",
push: "Push — your bet comes back.",
};
function verdict(v) {
var text = VERDICTS[v.outcome] || "";
if (!text) { verdictEl.classList.add("hidden"); return; }
if (v.net > 0) text += " +" + v.net.toLocaleString();
else if (v.net < 0) text += " " + v.net.toLocaleString();
verdictEl.textContent = text;
verdictEl.classList.remove("hidden");
playerEl.dataset.won = v.net > 0 ? "1" : v.net < 0 ? "-1" : "0";
// The one thing in this room that gets confetti. A natural is rare, it pays
// 3:2, and if everything celebrated then nothing would.
if (v.outcome === "blackjack") FX.burst(verdictEl, { count: 34 });
}
// setPhase swaps the controls: bet between hands, act during one.
function setPhase(v) {
hand = v;
var live = !!v && v.phase === "player";
betting.classList.toggle("hidden", live);
actions.classList.toggle("hidden", !live);
if (live) {
var dbl = actions.querySelector('[data-move="double"]');
if (dbl) dbl.disabled = !v.can_double;
}
if (!v || v.phase !== "player") verdictEl.classList.toggle("hidden", !(v && v.outcome));
}
// ---- the script -----------------------------------------------------------
// play walks the server's events, one card at a time. It is deliberately the
// only thing that renders during a hand: the final state is applied at the end,
// so what you watch and what the server says can't disagree halfway through.
//
// `money` is the one exception, and it's a deliberate one. On a hand that is
// still running, the chip bar is right immediately — your stake left your pile
// when you pressed Deal, and it's sitting on the spot where you can see it. On
// a hand that *settles*, the bar is left alone until the chips have physically
// come home, because a counter that pays you before the dealer has turned over
// is a counter that has told you the ending.
function play(view, money) {
var events = view.events || [];
var final = view.hand;
var settles = !!final && final.phase === "done";
var hole = null; // the face-down card element, once one has been dealt
var chain = Promise.resolve();
var drew = false; // has the dealer drawn since the reveal?
if (!settles) money();
// Whatever the server says the stake is, that's what has to be on the spot.
// Two things get here: a double, which puts a second bet down beside the
// first, and a deal whose bet was typed rather than stacked (you kept last
// hand's number and just pressed Deal). Either way the chips go down before
// the card they're buying does.
if (final && final.bet > staked) {
var extra = final.bet - staked;
chain = chain.then(function () { return stake(extra); });
}
events.forEach(function (e) {
chain = chain.then(function () {
switch (e.kind) {
case "deal":
dealerEl.innerHTML = "";
playerEl.innerHTML = "";
playerEl.dataset.won = "0";
verdictEl.classList.add("hidden");
return;
case "player_card":
playerEl.appendChild(cardEl(e.card));
return wait(DEAL_MS);
case "dealer_card":
// The dealer takes a moment before the first card they draw out.
// Card, card, card with no breath in between is a machine dealing;
// the pause is the only thing on this table that plays as suspense.
var beat = drew ? Promise.resolve() : think();
drew = true;
return beat.then(function () {
dealerEl.appendChild(cardEl(e.card));
return wait(DEAL_MS);
});
case "dealer_hole":
hole = cardEl(null);
dealerEl.appendChild(hole);
return wait(DEAL_MS);
case "reveal":
// The hole card turns over. Its face is in the final hand — this is
// the first moment the server has been willing to say what it was.
if (!hole) hole = dealerEl.querySelector('.pete-card[data-face="down"]');
if (hole && final && final.dealer && final.dealer[1]) {
turnOver(hole, final.dealer[1]);
}
return wait(FLIP_MS);
case "settle":
return;
}
});
});
return chain.then(function () {
if (!final) { paint(null); money(); return; }
totals(final);
if (!settles) { setPhase(final); return; }
// The hand is over: nothing is on offer while the money is moving. Hit and
// Stand go now, and Deal comes back at the far end.
actions.classList.add("hidden");
verdict(final);
// The chips move, and the bar catches up with them when they arrive. The
// betting controls come back last, once the felt is clear: offering Deal
// over a table that is still being paid out invites a click the table then
// has to refuse.
return settleChips(final)
.then(money)
.then(function () { return standing(final.bet); })
.then(function () { setPhase(final); });
});
}
// standing leaves your bet up for the next hand, the way you would at a table:
// the stake that just settled goes straight back on the spot. It costs nothing
// — chips on the spot are a proposal until you press Deal — and it's what keeps
// the number in the panel honest, because otherwise a settled hand leaves
// "your bet: 300" printed over an empty spot.
function standing(amount) {
var money = window.PeteGames.view();
if (!amount || !money || money.chips < amount) {
bet = 0;
showBet();
return;
}
bet = amount;
showBet();
return stake(amount);
}
// think is the dealer's beat: a pause with something to look at, so it reads as
// deliberation rather than as the page having hung.
function think() {
if (reduced || !dLabelEl) return wait(0);
dLabelEl.classList.add("pete-dealer-think");
return wait(BEAT_MS).then(function () {
dLabelEl.classList.remove("pete-dealer-think");
});
}
// ---- talking to the table -------------------------------------------------
function send(path, body) {
if (busy) return;
busy = true;
say("");
return window.PeteGames.post(path, body)
.then(function (view) {
// play() decides *when* the money lands; see the note on it.
return play(view, function () { window.PeteGames.apply(view); });
})
.catch(function (err) {
say(err.message, "bad");
// Whatever we thought was on the felt, the server is the authority on it.
return window.PeteGames.refresh().then(function (v) {
if (v && !v.hand) renderStack(0);
});
})
.then(function () { busy = false; });
}
// ---- betting --------------------------------------------------------------
//
// A bet is built by putting chips on the spot, one at a time, and it is those
// chips the deal then rides on — the number under the pile is a readout of the
// pile, not the other way round.
function showBet() {
betAmount.textContent = bet.toLocaleString();
var money = window.PeteGames.view();
if (dealBtn) dealBtn.disabled = bet <= 0 || !money || money.chips < bet;
}
root.querySelectorAll("[data-chip]").forEach(function (btn) {
btn.addEventListener("click", function () {
if (busy) return;
var d = parseInt(btn.dataset.chip, 10);
var money = window.PeteGames.view();
if (money && bet + d > money.chips) {
say("You haven't got that many chips.", "bad");
return;
}
bet += d;
showBet();
// The chip you clicked is the chip that flies: same colour, same size, off
// the button and onto the felt. The pile only grows once it gets there —
// but `staked` moves now, so a Deal pressed mid-flight still knows the chip
// is on its way and doesn't put a second one down.
var target = bet;
staked = bet;
FX.fly(btn, spotEl, { denom: d }).then(function () {
if (bet >= target) renderStack(target); // unless Clear got there first
});
});
});
var clearBtn = root.querySelector("[data-bet-clear]");
if (clearBtn) {
clearBtn.addEventListener("click", function () {
if (busy || !staked) { bet = 0; showBet(); return; }
FX.flyMany(spotEl, purseEl, FX.chipsFor(staked, 8), { gap: 40, lift: 0.7 });
bet = 0;
renderStack(0);
showBet();
});
}
if (dealBtn) {
dealBtn.addEventListener("click", function () {
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
send("/api/games/blackjack/deal", { bet: bet });
});
}
root.querySelectorAll("[data-move]").forEach(function (btn) {
btn.addEventListener("click", function () {
send("/api/games/blackjack/move", { move: btn.dataset.move });
});
});
document.addEventListener("keydown", function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (/^(input|textarea|select)$/i.test((e.target.tagName || ""))) return;
if (!hand || hand.phase !== "player" || busy) return;
var move = { h: "hit", s: "stand", d: "double" }[e.key.toLowerCase()];
if (!move) return;
if (move === "double" && !hand.can_double) return;
e.preventDefault();
send("/api/games/blackjack/move", { move: move });
});
// The money bar owns the first fetch; the table picks up whatever it found,
// including a hand left sitting on the felt by a reload or a redeploy.
var resumed = false;
window.PeteGames.onUpdate(function (v) {
if (!resumed) {
resumed = true;
if (v.hand) paint(v.hand);
if (v.hand && v.hand.phase === "done") verdict(v.hand);
}
showBet();
});
})();

View File

@@ -0,0 +1,249 @@
// The moving parts of the room.
//
// A casino is mostly one gesture repeated: something of value is picked up here
// and put down there, in front of you, slowly enough that you can object. So the
// only real idea in this file is fly() — take an element's place on screen, take
// another's, and move a chip between them on an arc that a hand would make.
//
// Everything flies in a fixed overlay pinned to the viewport, not inside the
// felt, because a chip's journey starts on a button *outside* the felt and any
// container in between would clip it halfway. Coordinates come from
// getBoundingClientRect, so the overlay needs no knowledge of the layout at all
// and nothing has to be positioned relative to anything else.
//
// Exposed as window.PeteFX. Nothing in here knows what blackjack is.
(function () {
"use strict";
var reduced =
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var layer = null;
function stage() {
if (!layer) {
layer = document.createElement("div");
layer.className = "pete-fly-layer";
layer.setAttribute("aria-hidden", "true");
document.body.appendChild(layer);
}
return layer;
}
// Where a thing is, in viewport coordinates: the middle of it.
function centre(target) {
if (!target) return { x: 0, y: 0 };
if (typeof target.x === "number") return target;
var r = target.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
// DENOMS, biggest first — the order you break an amount down in.
var DENOMS = [500, 100, 25, 5];
// chipsFor turns an amount into the chips a dealer would actually push across:
// as few as possible, biggest first. Capped, because a €10,000 bet is 20 purple
// chips and nobody wants to watch 20 of anything fly. Past the cap the stack
// just gets shorter than the number under it, which is what a real tall stack
// looks like from across a table anyway.
function chipsFor(amount, cap) {
var out = [];
var left = Math.max(0, Math.round(amount || 0));
for (var i = 0; i < DENOMS.length && left > 0; i++) {
while (left >= DENOMS[i]) {
out.push(DENOMS[i]);
left -= DENOMS[i];
}
}
if (left > 0) out.push(5); // a remainder below the smallest chip still shows up
var max = cap || 12;
return out.length > max ? out.slice(0, max) : out;
}
function disc(denom) {
var el = document.createElement("div");
el.className = "pete-disc";
el.dataset.chip = String(denom);
return el;
}
// A deterministic wobble per index, so a stack looks hand-placed but doesn't
// reshuffle itself every time the page repaints.
function jitter(i, spread) {
var n = Math.sin((i + 1) * 12.9898) * 43758.5453;
return ((n - Math.floor(n)) * 2 - 1) * (spread || 1);
}
// fly moves one chip from one place to another and resolves when it lands.
//
// The arc is the point. A straight line between two rects reads as a UI
// transition; a chip that rises, travels and drops reads as a throw. WAAPI does
// this in one keyframe list because it can interpolate a midpoint — a CSS
// transition can't, which is why this isn't a class toggle.
function fly(from, to, opts) {
opts = opts || {};
var a = centre(from);
var b = centre(to);
var el = disc(opts.denom || 25);
el.className += " pete-fly";
stage().appendChild(el);
var dur = opts.duration || 420;
if (reduced) dur = 1;
// How high it goes: further throws arc higher, and a lob can be forced.
var dist = Math.hypot(b.x - a.x, b.y - a.y);
var lift = Math.min(120, 28 + dist * 0.18) * (opts.lift == null ? 1 : opts.lift);
var midX = (a.x + b.x) / 2;
var midY = (a.y + b.y) / 2 - lift;
var spin = opts.spin == null ? jitter(opts.index || 0, 90) : opts.spin;
var anim = el.animate(
[
{ transform: t(a.x, a.y, 0.85, 0), opacity: 1, offset: 0 },
{ transform: t(midX, midY, 1.12, spin * 0.6), opacity: 1, offset: 0.5 },
{ transform: t(b.x, b.y, 1, spin), opacity: opts.fade ? 0 : 1, offset: 1 },
],
{ duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: reduced ? 0 : opts.delay || 0, fill: "both" }
);
return anim.finished
.catch(function () {})
.then(function () {
el.remove();
});
}
function t(x, y, scale, rot) {
return "translate(" + x + "px," + y + "px) scale(" + scale + ") rotate(" + rot + "deg)";
}
// flyMany throws a whole bet across, one chip after another rather than all at
// once, because a pile of chips arriving as a single object is a progress bar.
// Resolves when the last one lands.
function flyMany(from, to, denoms, opts) {
opts = opts || {};
var gap = reduced ? 0 : opts.gap == null ? 70 : opts.gap;
var each = denoms.map(function (d, i) {
return fly(from, to, {
denom: d,
index: i,
delay: (opts.delay || 0) + i * gap,
duration: opts.duration,
lift: opts.lift,
fade: opts.fade,
spin: opts.spin,
});
});
if (opts.onLand && !reduced) {
denoms.forEach(function (d, i) {
setTimeout(opts.onLand, (opts.delay || 0) + i * gap + (opts.duration || 420), d, i);
});
} else if (opts.onLand) {
denoms.forEach(function (d, i) { opts.onLand(d, i); });
}
return Promise.all(each);
}
// burst: confetti out of a point. Saved for the things worth celebrating.
function burst(target, opts) {
if (reduced) return Promise.resolve();
opts = opts || {};
var c = centre(target);
var n = opts.count || 26;
var colours = opts.colours || ["#f2b53d", "#4caf7d", "#5aa9e6", "#b079d6", "#ffffff", "#cc3d4a"];
var done = [];
for (var i = 0; i < n; i++) {
var bit = document.createElement("div");
bit.className = "pete-spark";
bit.style.background = colours[i % colours.length];
stage().appendChild(bit);
// Fired into a cone that mostly goes up, then let gravity have it.
var angle = (-Math.PI / 2) + jitter(i, 1) * (opts.spread || 1.15);
var speed = 120 + Math.abs(jitter(i + 7, 1)) * 190;
var vx = Math.cos(angle) * speed;
var vy = Math.sin(angle) * speed;
var dropTo = c.y + 220 + jitter(i + 3, 60);
done.push(
bit
.animate(
[
{ transform: t(c.x, c.y, 0.6, 0), opacity: 1, offset: 0 },
{
transform: t(c.x + vx * 0.45, c.y + vy * 0.45, 1, jitter(i, 180)),
opacity: 1,
offset: 0.4,
},
// Held at full colour most of the way down: a piece of confetti that
// starts fading the moment it's thrown reads as smoke.
{
transform: t(c.x + vx * 0.75, c.y + vy * 0.3 + 110, 0.95, jitter(i, 380)),
opacity: 1,
offset: 0.75,
},
{
transform: t(c.x + vx * 0.9, dropTo, 0.9, jitter(i, 540)),
opacity: 0,
offset: 1,
},
],
{
duration: 900 + Math.abs(jitter(i + 11, 1)) * 500,
easing: "cubic-bezier(0.18, 0.7, 0.4, 1)",
fill: "both",
}
)
.finished.catch(function () {})
.then(
(function (b) {
return function () { b.remove(); };
})(bit)
)
);
}
return Promise.all(done);
}
// count rolls a number to a new value instead of swapping it. A chip count that
// jumps is a variable; one that climbs is a payout.
function count(el, to, opts) {
if (!el) return;
opts = opts || {};
var from = parseInt(String(el.textContent).replace(/[^0-9-]/g, ""), 10);
if (isNaN(from) || reduced || from === to) {
el.textContent = (to || 0).toLocaleString();
return;
}
var dur = opts.duration || 520;
var t0 = null;
if (el._petePop) el._petePop.cancel();
el._petePop = el.animate(
[{ transform: "scale(1)" }, { transform: "scale(1.12)" }, { transform: "scale(1)" }],
{ duration: dur, easing: "ease-out" }
);
function step(ts) {
if (t0 === null) t0 = ts;
var p = Math.min(1, (ts - t0) / dur);
var eased = 1 - Math.pow(1 - p, 3);
el.textContent = Math.round(from + (to - from) * eased).toLocaleString();
if (p < 1) requestAnimationFrame(step);
else el.textContent = (to || 0).toLocaleString();
}
requestAnimationFrame(step);
}
window.PeteFX = {
reduced: reduced,
chipsFor: chipsFor,
disc: disc,
jitter: jitter,
fly: fly,
flyMany: flyMany,
burst: burst,
count: count,
centre: centre,
};
})();

View File

@@ -0,0 +1,168 @@
// The money bar: chips, wallet, buying in, cashing out.
//
// Buying chips is not instant and cannot be. gogobee owns the euros and has no
// inbound API, so it polls Pete for the crossing, moves the money on its side,
// and pushes the verdict back — a few seconds, end to end. So the button does
// not lie about it: the chips show as "buying" until they are real, and this
// file polls until they are. Nothing spendable appears until gogobee has said
// it took the euros.
//
// Exposed as window.PeteGames so the table (blackjack.js) shares one view of
// the money rather than keeping a second copy that drifts from this one.
(function () {
"use strict";
var bar = document.querySelector("[data-chipbar]");
if (!bar) return;
var chipsEl = bar.querySelector("[data-chips]");
var pendingEl = bar.querySelector("[data-pending]");
var eurosEl = bar.querySelector("[data-euros]");
var amountEl = bar.querySelector("[data-buyin-amount]");
var buyBtn = bar.querySelector("[data-buyin]");
var cashBtn = bar.querySelector("[data-cashout]");
var msgEl = bar.querySelector("[data-chipbar-msg]");
var listeners = [];
var view = null;
var pollTimer = null;
var pollUntil = 0;
function money(n) {
return (n || 0).toLocaleString();
}
function say(text, tone) {
if (!msgEl) return;
if (!text) { msgEl.classList.add("hidden"); return; }
msgEl.textContent = text;
msgEl.classList.remove("hidden");
msgEl.style.color = tone === "bad" ? "#cc3d4a" : "";
}
function paint(v) {
var first = view === null;
view = v;
// The chip count rolls to its new value rather than jumping to it — the table
// times this to land with the chips that caused it, so a payout reads as the
// number catching up with the felt. On the first paint there's nothing to
// catch up with, so it just sets.
if (chipsEl) {
if (first || !window.PeteFX) chipsEl.textContent = money(v.chips);
else window.PeteFX.count(chipsEl, v.chips);
}
if (eurosEl) eurosEl.textContent = (v.euros || 0).toFixed(2);
if (pendingEl) {
if (v.pending > 0) {
pendingEl.textContent = "+" + money(v.pending) + " buying…";
pendingEl.classList.remove("hidden");
} else {
pendingEl.classList.add("hidden");
}
}
// You cannot cash out mid-hand: the stake is already on the table.
if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.hand;
if (buyBtn) buyBtn.disabled = v.chips + v.pending >= v.cap;
listeners.forEach(function (fn) { fn(v); });
}
// pollPending keeps asking while a buy-in is in flight, and stops the moment
// it lands — or is refused, which looks the same from here (pending drops to
// zero) and is told apart by whether the chips arrived.
function pollPending() {
clearTimeout(pollTimer);
if (Date.now() > pollUntil) {
say("gogobee hasn't answered yet. Your euros are safe — give it a moment and reload.");
return;
}
pollTimer = setTimeout(function () {
get().then(function (v) {
if (!v) return;
if (v.pending > 0) { pollPending(); return; }
if (v.chips > (pollPending.was || 0)) {
say("Chips are yours. Good luck!");
} else {
say("gogobee wouldn't cover that — not enough euros in your wallet.", "bad");
}
});
}, 1200);
}
function request(path, body) {
return fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
}).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) {
if (!res.ok) throw new Error(data.error || "that didn't work");
return data;
});
});
}
function get() {
return fetch("/api/games/table", { headers: { "Accept": "application/json" } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (v) { if (v) paint(v); return v; })
.catch(function () { return null; });
}
if (buyBtn) {
buyBtn.addEventListener("click", function () {
var amount = parseInt(amountEl && amountEl.value, 10);
if (!(amount > 0)) { say("How many chips?", "bad"); return; }
buyBtn.disabled = true;
say("Asking gogobee for " + money(amount) + " euros…");
pollPending.was = view ? view.chips : 0;
request("/api/games/buyin", { amount: amount })
.then(function (v) {
paint(v);
pollUntil = Date.now() + 60000;
pollPending();
})
.catch(function (err) {
say(err.message, "bad");
buyBtn.disabled = false;
});
});
}
if (cashBtn) {
cashBtn.addEventListener("click", function () {
cashBtn.disabled = true;
say("Cashing you out…");
request("/api/games/cashout", { amount: 0 })
.then(function (v) {
paint(v);
say("Chips are on their way back to euros. They'll show in your wallet shortly.");
// The euro balance Pete shows is whatever gogobee last told it, so it
// only moves once the credit has actually gone through over there.
setTimeout(get, 4000);
})
.catch(function (err) {
say(err.message, "bad");
cashBtn.disabled = false;
});
});
}
window.PeteGames = {
// onUpdate registers a listener called on every fresh view of the money.
onUpdate: function (fn) { listeners.push(fn); if (view) fn(view); },
// apply pushes a view the table already fetched (a deal answers with one),
// so playing a hand doesn't need a second round-trip to refresh the chips.
apply: paint,
refresh: get,
post: request,
say: say,
view: function () { return view; },
};
get();
})();

View File

@@ -0,0 +1,45 @@
{{define "_chipbar"}}
<section data-chipbar
class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-center gap-x-6 gap-y-4">
<div class="min-w-0">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Chips in front of you</div>
<div class="flex items-baseline gap-2">
<span data-chips class="font-display text-3xl font-bold tabular-nums"></span>
<span data-pending class="hidden text-sm font-semibold text-[color:var(--ink)]/60 animate-pulse"></span>
</div>
</div>
<div class="min-w-0">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your wallet</div>
<div class="flex items-baseline gap-1.5">
<span data-euros class="font-display text-2xl font-bold tabular-nums text-[color:var(--ink)]/70"></span>
<span class="text-sm text-[color:var(--ink)]/40" title="Pete reads this off the game box every couple of minutes, so it lags a little.">€, give or take</span>
</div>
</div>
<div class="ml-auto flex flex-wrap items-center gap-2">
<div class="flex items-center gap-1.5 rounded-full bg-[color:var(--ink)]/5 p-1.5">
<label for="buyin-amount" class="sr-only">How many chips?</label>
<input id="buyin-amount" data-buyin-amount type="number" min="1" step="1" value="100"
inputmode="numeric" enterkeyhint="go"
class="w-24 rounded-full bg-[color:var(--card)] px-3 py-1.5 text-sm font-semibold tabular-nums
border-2 border-[color:var(--ink)]/10 focus:outline-none focus:border-[color:var(--accent)]">
<button type="button" data-buyin
class="rounded-full bg-[color:var(--accent)] px-4 py-1.5 text-sm font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Buy chips
</button>
</div>
<button type="button" data-cashout
class="rounded-full bg-[color:var(--card)] px-4 py-2 text-sm font-bold shadow-pete border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Cash out
</button>
</div>
</div>
<p data-chipbar-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
{{end}}

View File

@@ -0,0 +1,132 @@
{{define "title"}}Blackjack · {{.Room.Name}}{{end}}
{{define "main"}}
<div class="space-y-6" data-blackjack>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
</svg>
<span class="sr-only">Back to the casino</span>
</a>
<h1 class="font-display text-3xl font-bold">Blackjack</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">Six decks · pays 3:2 · dealer hits soft 17</p>
</div>
{{template "_chipbar" .}}
<!-- The felt. The two things on it that aren't cards are the shoe the cards
come out of and the rack the money comes out of — a chip on this table is
always travelling between one of those and the spot in front of you. -->
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<div class="pete-rack" data-house aria-hidden="true">
<span data-chip="500" style="--stack: 5"></span>
<span data-chip="100" style="--stack: 7"></span>
<span data-chip="25" style="--stack: 4"></span>
<span data-chip="5" style="--stack: 6"></span>
</div>
<!-- The shoe every card flies out of. -->
<div class="pete-shoe" aria-hidden="true"></div>
<div class="relative space-y-8">
<!-- Dealer -->
<div>
<div class="mb-2 flex items-center gap-2">
<span data-dealer-label class="text-xs font-bold uppercase tracking-wider text-white/60">Dealer</span>
<span data-dealer-total class="hidden rounded-full bg-black/25 px-2.5 py-0.5 text-xs font-bold tabular-nums text-white"></span>
</div>
<div data-dealer class="pete-hand" aria-live="polite"></div>
</div>
<!-- What just happened. -->
<div class="flex min-h-[2.75rem] items-center justify-center">
<!-- The pill is white, so its text is ink — not var(--ink), which is the
room's cream and would be white on white. -->
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
</div>
<!-- Player: the bet sits in front of the cards it's riding on. -->
<div>
<div class="mb-2 flex items-center gap-2">
<span class="text-xs font-bold uppercase tracking-wider text-white/60">You</span>
<span data-player-total class="hidden rounded-full bg-black/25 px-2.5 py-0.5 text-xs font-bold tabular-nums text-white"></span>
</div>
<div class="flex items-center gap-5">
<div class="pete-spot" data-spot>
<span class="pete-spot-label">Bet</span>
<div class="pete-stack" data-stack></div>
<span data-spot-total class="pete-spot-total hidden"></span>
</div>
<div data-player class="pete-hand flex-1" aria-live="polite"></div>
</div>
</div>
</div>
</section>
<!-- Betting: shown between hands. -->
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-center gap-x-6 gap-y-4">
<div>
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
</div>
<div class="flex flex-wrap items-center gap-2">
{{range .Denominations}}
<button type="button" data-chip="{{.}}" aria-label="Bet {{.}} more"
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
<span>{{.}}</span>
</button>
{{end}}
<button type="button" data-bet-clear
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
</div>
<button type="button" data-deal
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Deal
</button>
</div>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- Playing: shown while a hand is live. -->
<section data-actions class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-center justify-center gap-3">
<button type="button" data-move="hit"
class="rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Hit
</button>
<button type="button" data-move="stand"
class="rounded-full bg-[color:var(--card)] px-8 py-3 font-display text-lg font-bold shadow-pete border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Stand
</button>
<button type="button" data-move="double"
class="rounded-full bg-[color:var(--card)] px-8 py-3 font-display text-lg font-bold shadow-pete border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Double
</button>
</div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
<kbd>h</kbd> hit · <kbd>s</kbd> stand · <kbd>d</kbd> double
</p>
</section>
</div>
{{end}}
{{define "scripts"}}
<script src="/static/js/casino-fx.js" defer></script>
<script src="/static/js/games.js" defer></script>
<script src="/static/js/blackjack.js" defer></script>
{{end}}

View File

@@ -13,6 +13,81 @@
</div>
</section>
{{if .ShowRoster}}
<section class="mb-10" id="roster" data-stale="{{.RosterStale}}">
<div class="flex items-baseline justify-between mb-3">
<h2 class="font-display text-2xl font-bold">Out there right now</h2>
<p class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50" id="roster-status">
{{if .RosterStale}}last known — the wire's gone quiet{{else}}live{{end}}
</p>
</div>
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
{{range .Roster}}
<li class="flex items-center gap-4 px-5 py-3">
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
<span class="font-semibold">{{.Name}}</span>
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
</span>
</li>
{{else}}
<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody's out at the moment. Quiet week in the realm.</li>
{{end}}
</ul>
</div>
</section>
<script>
// The board is state, not a story: an open tab should keep telling the truth
// without a reload. Cheap poll — a handful of rows, no auth, no cache.
(function () {
var list = document.getElementById('roster-list');
var status = document.getElementById('roster-status');
var card = document.getElementById('roster-card');
if (!list) return;
function esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function row(a) {
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
return '<li class="flex items-center gap-4 px-5 py-3">' +
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
'<span class="font-semibold">' + esc(a.Name) + '</span>' +
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
esc(a.Where) + idle + '</span></li>';
}
function refresh() {
fetch('/api/roster', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data) return;
var rows = data.adventurers || [];
list.innerHTML = rows.length
? rows.map(row).join('')
: '<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody\'s out at the moment. Quiet week in the realm.</li>';
status.textContent = data.stale ? "last known — the wire's gone quiet" : 'live';
card.classList.toggle('opacity-60', !!data.stale);
})
.catch(function () { /* transient — the next tick will do */ });
}
setInterval(refresh, 60000);
document.addEventListener('visibilitychange', function () {
if (!document.hidden) refresh();
});
})();
</script>
{{end}}
{{if .Stories}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{{range .Stories}}

View File

@@ -0,0 +1,75 @@
{{define "title"}}{{.Room.Name}}{{end}}
{{define "main"}}
<div class="space-y-8">
<section class="rounded-3xl bg-[color:var(--card)] p-6 sm:p-8 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<h1 class="font-display text-3xl sm:text-4xl font-bold">Welcome in 🎲</h1>
<p class="mt-2 max-w-xl text-[color:var(--ink)]/70">
Real euros, from the same wallet as everything else. Chips are one euro each,
and whatever you don't spend goes straight back when you cash out.
</p>
</div>
<div class="hidden shrink-0 sm:flex items-end gap-1.5" aria-hidden="true">
<span class="cs-stack" data-chip="5"></span>
<span class="cs-stack" data-chip="100" style="--stack:3"></span>
<span class="cs-stack" data-chip="25" style="--stack:2"></span>
</div>
</div>
</section>
{{template "_chipbar" .}}
<section>
<h2 class="font-display text-2xl font-bold mb-4">The tables</h2>
<div class="grid gap-4 sm:grid-cols-2">
<a href="/games/blackjack"
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">🃏</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold">Blackjack</h3>
<p class="text-sm text-[color:var(--ink)]/60">Six decks, blackjack pays 3:2.</p>
</div>
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
</div>
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
The dealer stands on 17 and hits a soft one. House takes {{.RakePct}}% of what you win,
and nothing at all when you lose or push.
</p>
</a>
{{range .Soon}}
<div class="rounded-3xl bg-[color:var(--card)]/60 p-6 shadow-pete border-2 border-dashed border-[color:var(--ink)]/15">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--ink)]/5 text-2xl grayscale">{{.Emoji}}</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold text-[color:var(--ink)]/50">{{.Name}}</h3>
<p class="text-sm text-[color:var(--ink)]/40">{{.Blurb}}</p>
</div>
<span class="ml-auto shrink-0 rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 text-xs font-bold uppercase tracking-wider text-[color:var(--ink)]/40">Soon</span>
</div>
</div>
{{end}}
</div>
</section>
<section class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<h2 class="font-display text-xl font-bold mb-2">House rules</h2>
<ul class="space-y-1.5 text-sm text-[color:var(--ink)]/70">
<li>· A chip is a euro. Nothing is worth more here than it is out there.</li>
<li>· You can have {{.Cap}} chips in front of you at most. That's the limit for one sitting.</li>
<li>· The house takes {{.RakePct}}% of your winnings. Never your stake: a push hands your bet straight back.</li>
<li>· Walk away for half an hour and the house cashes you out for you, so your euros never sit in limbo.</li>
<li>· Every hand is logged with the seed it was dealt from, so any hand can be dealt again exactly as it fell.</li>
</ul>
</section>
</div>
{{end}}
{{define "scripts"}}<script src="/static/js/games.js" defer></script>{{end}}

View File

@@ -0,0 +1,91 @@
{{define "layout"}}<!doctype html>
<html lang="en" data-room="{{.Room.Slug}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}{{.Room.Name}}{{end}}</title>
<meta name="robots" content="noindex">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/output.css">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,3 27,9.5 27,22.5 16,29 5,22.5 5,9.5' fill='%23f2b53d' stroke='%232b2118' stroke-width='2.5' stroke-linejoin='round'/%3E%3C/svg%3E" type="image/svg+xml">
<meta name="theme-color" content="#17231d">
<script>
// Which room you're standing in, from your clock rather than the server's:
// Casinopolis in daylight, Casino Night Zone once the lights come on at six.
// Same rule as roomAt() in games_pages.go — keep the two in step.
//
// The server already painted its best guess into the markup, so this only
// corrects a player whose evening isn't the server's.
(function () {
var ROOMS = {
"casinopolis": "Casinopolis",
"casino-night": "Casino Night Zone",
};
function roomAt(h) { return (h >= 6 && h < 18) ? "casinopolis" : "casino-night"; }
function apply() {
var slug = roomAt(new Date().getHours());
// The palette can be set from <head> — the sign can't: this script runs
// before the header exists. So set the attribute now (no flash of the
// wrong room) and always re-stamp the name, which is a no-op until the
// header is there to stamp. Bailing out early when the slug already
// matches would leave the server's name under the browser's palette.
document.documentElement.dataset.room = slug;
document.querySelectorAll("[data-room-name]").forEach(function (el) {
el.textContent = ROOMS[slug];
});
}
apply();
document.addEventListener("DOMContentLoaded", apply);
setInterval(apply, 60000);
})();
</script>
</head>
<body class="min-h-screen bg-[color:var(--bg)] text-[color:var(--ink)]">
<div class="pointer-events-none fixed inset-0 -z-10 cs-room" aria-hidden="true"></div>
<header class="mx-auto max-w-5xl px-4 pt-6 pb-4 sm:pt-10">
<div class="flex items-center justify-between gap-3">
<a href="/games" class="group flex min-w-0 items-center gap-3">
{{template "_comb" .}}
<span class="min-w-0">
<span data-room-name class="block font-display text-2xl sm:text-3xl font-bold leading-none tracking-tight">{{.Room.Name}}</span>
<span class="mt-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[color:var(--ink)]/45">Chips are euros</span>
</span>
</a>
{{if .User}}
<a href="/auth/logout" title="Signed in as {{.User.Display}} — sign out"
class="inline-flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-2.5 py-1.5 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
<span class="grid h-6 w-6 place-items-center rounded-full bg-[color:var(--accent)] text-xs font-bold text-[#20180c]">{{.User.Initial}}</span>
<span class="hidden sm:inline max-w-[7rem] truncate">{{.User.Display}}</span>
</a>
{{end}}
</div>
</header>
<main class="mx-auto max-w-5xl px-4 pb-20">
{{block "main" .}}{{end}}
</main>
<footer class="mx-auto max-w-5xl px-4 pb-10 text-center text-xs text-[color:var(--ink)]/40">
Play for what you can lose. Cash out whenever you like.
</footer>
{{block "scripts" .}}{{end}}
</body>
</html>{{end}}
{{/* The house mark: a honeycomb cell struck like a chip. Stands in for a logo,
and deliberately isn't a face — nobody is watching you play. */}}
{{define "_comb"}}
<span class="cs-comb grid h-11 w-11 shrink-0 place-items-center transition-transform group-hover:-rotate-6" aria-hidden="true">
<svg viewBox="0 0 32 32" class="h-11 w-11">
<polygon points="16,2.5 27.5,9.25 27.5,22.75 16,29.5 4.5,22.75 4.5,9.25"
fill="var(--accent)" stroke="var(--ink)" stroke-width="2.5" stroke-linejoin="round"/>
<polygon points="16,9 21.5,12.25 21.5,18.75 16,22 10.5,18.75 10.5,12.25"
fill="none" stroke="var(--ink)" stroke-width="1.75" stroke-linejoin="round" opacity="0.5"/>
</svg>
</span>
{{end}}

View File

@@ -322,5 +322,6 @@
<script src="/static/js/settings.js" defer></script>
<script src="/static/js/reader.js" defer></script>
<script src="/static/js/pwa.js" defer></script>
{{block "scripts" .}}{{end}}
</body>
</html>{{end}}

View File

@@ -0,0 +1,80 @@
package web
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"pete/internal/config"
"pete/internal/storage"
)
// TestDrive is not a test. It is a hand crank: it stands the casino up on a real
// port with one funded player so a browser can be pointed at it. Skipped unless
// PETE_DRIVE is set. Delete it if it ever stops earning its keep.
func TestDrive(t *testing.T) {
if os.Getenv("PETE_DRIVE") == "" {
t.Skip("set PETE_DRIVE=1 to hand-drive the casino")
}
// Built through New() with the tables already open, because the /games routes
// are registered at construction — newCasino() switches them on afterwards,
// which is fine for handler tests and useless for a browser.
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "drive.db")); err != nil {
t.Fatal(err)
}
defer storage.Close()
s, err := New(config.WebConfig{
SiteTitle: "Pete",
ListenAddr: ":0",
BaseURL: "http://127.0.0.1",
Games: config.GamesConfig{Enabled: true, MatrixServer: "parodia.dev"},
}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}
// New() only mounts the casino when auth is live, and auth means an OIDC
// discovery call. Sign the cookie by hand and mount the routes here instead —
// the handshake is not what a browser is here to look at.
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
fund(t, 5000)
static, err := fs.Sub(staticFS, "static")
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
mux.HandleFunc("GET /games", s.handleLobby)
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /api/games/table", s.handleTable)
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
srv := httptest.NewServer(mux)
defer srv.Close()
payload, _ := json.Marshal(SessionUser{
Sub: "sub-1", Username: "reala", Exp: time.Now().Add(time.Hour).Unix(),
})
fmt.Printf("DRIVE_URL=%s\nDRIVE_COOKIE=%s=%s\n", srv.URL, sessionCookie, s.auth.sign(payload))
// Long enough for a browser to buy in, deal a few hands, and be screenshotted.
deadline := time.Now().Add(3 * time.Minute)
for time.Now().Before(deadline) {
time.Sleep(500 * time.Millisecond)
if _, err := http.Get(srv.URL + "/healthz"); err != nil {
return
}
}
}

13
main.go
View File

@@ -267,11 +267,16 @@ func main() {
// Adapter: the web ingest handler posts priority adventure beats live to
// Matrix through the same queue everything else uses (bypasses pacing).
// PostNow doesn't consult posting.enabled, so gate here: with posting off,
// adventure stays website-only like every other channel (nil poster also
// keeps the digest loop from starting).
//
// Adventure carries its own enable switch and is push-based: facts arrive
// from gogobee at ingest, they are not polled, classified or paced, and they
// never enter the round-robin rotation. So it is gated on [adventure] alone,
// NOT on posting.enabled — that flag governs the RSS pipeline's chatter, and
// an operator running "news on the web only, adventure live in the games
// room" is a legitimate configuration. A nil poster (adventure off, or no
// channel) still keeps both the live beats and the digest loop shut.
var advPost web.PriorityPoster
if postingEnabled {
if cfg.Adventure.Enabled && cfg.Adventure.Channel != "" {
advPost = func(p web.AdvPost) {
queue.PostNow(poster.QueueItem{
GUID: p.GUID,

520
pete_games_plan.md Normal file
View File

@@ -0,0 +1,520 @@
# Pete Games — games.parodia.dev
A web casino/arcade on Pete, authenticated by Authentik, playing for gogobee euros.
Blackjack, Texas Hold'em, UNO (normal + no-mercy), Hangman, Trivia.
Companion to `gogobee_mischief_plan.md`, which already established the web↔game seam.
This plan reuses that seam wholesale and does not invent a second one.
---
## 0. Progress — last updated 2026-07-14
A multi-session build. This section is the handover; read it before anything else.
### Decisions taken (these close §9's open questions)
- **Chips are 1:1 with euros.** No second denomination.
- **Session buy-in cap: €10,000**, enforced against chips held *plus* buy-ins still
in flight, so it can't be cleared by firing several requests at once.
- **A house rake**, 5% in blackjack's `DefaultRules`, taken from *winnings only*
never the stake. A push returns the bet untouched; a loss is never charged a fee.
- **The site shares Pete's design, not Pete's shell.** *(Revised 2026-07-13 — this
replaces the earlier "the site must look like Pete", which meant `layout.html`
itself.)* The casino is its own place. It takes the design language — Fredoka/
Nunito, the four palette vars, `rounded-3xl`, `shadow-pete`, the bubbly weight of
everything — and takes none of the furniture: no Pete avatar, no channel nav, no
search, no reader, no settings, no weather canvas, no PWA. It has its own layout
(`games_layout.html`), its own header, its own footer, its own scripts. Still not
an SPA; still server-rendered `html/template`.
- **It has two names, on a clock.** Casinopolis by day, Casino Night Zone from six
in the evening — palette, felt and the sign over the door all change together.
This is the news app's phase system pointed at a joke: one `data-room` attribute,
two palette blocks, and a rule shared between `roomAt()` in Go (first paint) and
the same rule in JS (the player's own clock, so a player abroad gets their own
evening).
- **Dealing is animated.** Cards visibly dealt and flipped, chips that move. This is
a requirement, not polish to drop when the clock runs out.
### Done
- **Phase 0 — euro idempotency (gogobee).** `euro_transactions.external_id` + a
partial unique index, and `CreditIdem`/`DebitIdem` in `internal/plugin/euro.go`:
balance mutation and transaction log in one tx, keyed by the escrow GUID. A replay
reports success without moving money again; a rejection writes nothing, so the same
GUID stays retryable once the player is good for it. Six tests, including eight
goroutines racing one GUID. *(gogobee `ab2bcf0`)*
- **`pete/internal/games/cards`** — the shared deck gogobee never had. RNG is
threaded, never the package global, so a hand is reproducible from its seed.
*(pete `8310b30`)*
- **`pete/internal/games/blackjack`** — pure reducer,
`ApplyMove(state, move) (state, []Event, error)`, where an error means the move was
illegal and nothing else. State is a plain value: it serializes, so a hand survives
a redeploy, and it replays. Six decks, 3:2, dealer hits soft 17, plus the rake.
*(pete `8310b30`)*
- **`pete/internal/storage/games.go`** — the euro/chip border. `game_chips`,
`game_escrow`, `game_hands`. Chips appear only once gogobee confirms it took the
euros; chips are destroyed the moment a cash-out opens (so they can't be bet while
their euros are in flight) and come back if the credit fails. Table cap, 30-minute
reaper, per-hand audit log with seeds. 17 tests. *(pete `f9a98f7`)*
- **The wire protocol.** Pete serves `GET /api/games/escrow/pending`, `POST …/claim`,
`POST …/settled` (`internal/web/games.go`), bearer-authed on the adventure ingest
token. gogobee polls every 3s (`internal/plugin/pete_games.go`), claims a row,
calls `DebitIdem`/`CreditIdem` against the escrow GUID, and pushes the verdict
back through `pete_emit_queue` — which grew a `path` column so escrow verdicts
ride the same durable queue as adventure facts rather than getting a second one.
`peteclient.Flush` sends the verdict immediately instead of waiting out the 15s
sender tick, because a player is watching a spinner. A row re-offered after a
gogobee crash replays as a no-op: 13 tests across both repos, including a fake
Pete that offers the same row three times and a player who is charged once.
- **Identity.** `preferred_username` now rides in the signed session, and
`SessionUser.MatrixUser(server)` maps it to `@user:parodia.dev`. The session cookie
takes an opt-in `web.auth.cookie_domain`, so a sign-in on news is a sign-in on games;
the OAuth round-trip cookie deliberately stays host-only, and the redirect_uri is
derived per-request so a login that starts on games comes back to games. A Host we
don't own is never echoed into a redirect. *(pete `cb84e1d`)*
- **Blackjack, playable end to end.** `game_live_hands` (the hand in progress,
engine state and all, so a redeploy mid-hand is survivable), the session-authed play
surface (`internal/web/games_play.go`), the lobby and table pages, and the dealing
animation. Driven in a real browser: chips staked before the deal, hole card withheld
from the payload until the reveal, payout settled back into the stack.
- **The casino moved out.** Its own layout (`games_layout.html`), parsed as its own
template set alongside the news one; `gamesPage` no longer embeds the news
`pageData`, which is what stops the old furniture drifting back one convenient
field at a time. Two rooms on a clock (above), the felt reupholstered from the
room's vars, and a house mark that is a honeycomb chip rather than a face.
- **The cards are cards.** Corner indices in both corners (the bottom one upside
down, as printed), pips laid out on the three-by-seven grid a real deck uses,
bottom-half pips inverted, courts as a letter with the suit over each shoulder,
and a screen-reader label that says "Queen of hearts" instead of "Q♥".
- **The money moves.** The felt grew the two things it was missing: a **bet spot**
in front of you and the **house's rack** beside the shoe, so every chip on the
table is always travelling between one of those and the other. A bet is *built*
by throwing chips onto the spot (the chip you clicked is the chip that flies);
the stake sits there through the hand; the house pays out of its rack into the
spot; the whole pile is then swept back to your pile. A loss goes to the rack and
doesn't come back. `casino-fx.js` is the shared engine — `fly`/`flyMany` (WAAPI,
on an arc, out of a fixed overlay so nothing clips them), `chipsFor` (an amount
broken into the fewest chips, capped at what's worth watching), `burst`, `count`.
Two rules hold it together, and both are load-bearing:
1. **The number under the pile is a readout of the pile**, never the other way
round. So the bet starts at zero rather than at a default nobody put down, and
a settled hand puts your stake *back on the spot* as a standing bet — otherwise
the panel prints "your bet: 300" over an empty circle.
2. **The chip bar does not move until the chips that justify it have landed.** On
a live hand the money applies immediately (your stake left your pile and is
visibly on the spot); on a settling hand `play()` holds the apply until the
payout has swept home. A counter that pays you before the dealer turns over is
a counter that has told you the ending.
Also: cards land with weight (overshoot, a shadow that takes the hit, a degree or
two of resting tilt each), the dealer takes a beat before drawing out, and a
natural gets confetti — the only thing in the room that does.
- **A way to actually look at it.** `internal/web/devcasino_test.go` is the casino on
a port with one signed-in, funded player: `PETE_DEV_CASINO=:7788 go test
./internal/web -run TestDevCasino -timeout 0`. Skipped without the env var. It
wires its own routes because `New()` decides whether the casino exists at the
moment it builds the mux, and the test rig signs the player in afterwards. **Drive
the table in a real browser before believing anything about it** — this pass found
a white-on-white verdict pill, a rack that collided with the dealer, and Hit still
being offered over a table that was being paid out, none of which a Go test can see.
### Next, in order
1. **Deploy.** Add the `games.parodia.dev` redirect URI to the `pete` app in Authentik,
point Caddy at the same port, set `[web.games]` + `web.auth.cookie_domain` on the
server. Nothing else is host-specific.
2. Then Phase 2 (trivia, hangman), 3 (UNO), 4 (hold'em) as below.
Still open on the table itself, none of it blocking: **split** isn't implemented (the
engine has no move for it), the felt is roomy at desktop widths with only one seat on
it, and the chip animations are tuned for one player — a second seat would need the
spot to be per-seat rather than the singleton it is now.
### How the browser half fits together
- `GET /games` (lobby), `GET /games/blackjack` (table) — signed-in only. On the games
host, the mux prefixes `/games` onto the path, so the lobby is that host's `/`. Shared
paths (`/api/`, `/auth/`, `/static/`) mean the same thing on every host and are left
alone.
- `GET /api/games/table`, `POST /api/games/{buyin,cashout}`,
`POST /api/games/blackjack/{deal,move}` — session-authed, JSON, all returning the same
`tableView` so the money and the felt can never disagree.
- **The browser never sees the shoe.** The dealer's hole card is *absent* from the
payload — not flagged hidden — until the reveal, and the deck lives only in
`game_live_hands`. The response carries the engine's events (one per card off the
shoe), which is what the table plays back as an animation.
- Money order-of-operations: stake leaves the stack *before* the hand is dealt, in the
same statement that checks it's there; the hand is *seated* (a plain INSERT on the
primary key) before it can settle, which is what makes a double-clicked Deal a 409 with
the stake refunded rather than a silently overwritten hand.
### Notes for whoever picks this up
- SQLite runs at `MaxOpenConns(1)` in *both* repos. Any `db.Get().Exec` inside an
open transaction deadlocks against itself. Do the pre-work before `Begin`.
- **A buy-in can currently take a player into debt.** `DebitIdem` inherits
`BLACKJACK_DEBT_LIMIT` (default 1000), so someone with an empty wallet can buy
€1,000 of chips, win, and cash out while still €1,000 down. That is exactly what
gogobee's Matrix blackjack already allows, so it is consistent rather than a bug —
but a web casino runs far more hands, and this is the knob to turn if the economy
starts leaking. A buy-in-specific floor of 0 is a two-line change.
- gogobee's blackjack taxes 5% of the *gross* payout into a community pot
(`communityTax`). Pete's rake takes 5% of the *profit*. Deliberately different, and
gentler; don't "fix" one to match the other without deciding which is right.
---
## 1. The three constraints everything else follows from
**gogobee owns the euros.** The ledger is `euro_balances` / `euro_transactions`
(gogobee `internal/db/db.go:1316,1324`), tied to the wider economy — adventure, shop,
lottery, mischief. Pete does not get a second wallet. Pete never writes a balance.
**gogobee has no inbound API and isn't getting one.** The only listening socket in the
whole repo is the Matrix appservice transaction endpoint (`internal/bot/appservice.go:255`).
Pete's own source says it plainly (`internal/web/roster.go:23`):
> Direction of travel is gogobee → Pete ... Pete has no route back into the game box's
> network and we are not opening one.
So gogobee stays the only initiator. It **polls** Pete for work and **pushes** results
back through the existing durable queue. Same as mischief (`gogobee_mischief_plan.md:191-197`).
**One binary.** Games live in the Pete process. gogobee already runs ~50 plugins and six
games in one process with in-memory table state and it's fine. Caddy points
`games.parodia.dev` at the same port; the mux branches on Host.
---
## 2. Identity — free, no link codes
MAS imports the Authentik `preferred_username` as the Matrix localpart
(`gogobee_mischief_plan.md:176-186`). So an Authentik session on Pete maps to a Matrix
user deterministically:
```
OIDC preferred_username -> strings.ToLower(u) -> @<u>:parodia.dev
```
Pete's `SessionUser` (`internal/web/auth.go`) carries `Sub`/`Name`/`Email` today. Add
`PreferredUsername` to the claims struct and the signed cookie payload. That is the whole
identity story.
Note the existing precedent: `email_nag.go:52` already asserts "Authentik usernames ==
Matrix localparts".
---
## 3. Money — session escrow, not per-hand settlement
### Why not settle each hand
Mischief is fire-and-forget: place an order, gogobee claims it within 30s, nobody cares.
A blackjack hand cannot work that way. If every bet round-trips through a poll loop you
wait half a minute to be dealt, and again for the payout.
### The model
Borrow the semantics hold'em already uses — `euro.Debit(..., "holdem_buyin")`
(`holdem.go:319`), `euro.Credit(..., "holdem_cashout")` (`holdem.go:371`) — and apply it
to the whole web casino:
1. **Buy in.** You convert euros to *chips* for a games session. One debit. Tolerates poll latency.
2. **Play.** Blackjack, UNO, hold'em, all at full speed against chips held in Pete's SQLite.
Zero economy calls in the hot path.
3. **Cash out.** Chips convert back to euros. One credit. Tolerates the same latency.
Two economy touches per *session* instead of two per *hand*. Poll latency stops mattering.
### The invariant
> A euro is either in gogobee's `euro_balances` or in Pete's chip escrow. Never both.
> It moves between them only via a GUID-idempotent claim.
Pete's balance display is advisory only, sourced from the roster push and up to 2 minutes
stale. The authoritative check is `euro.Debit` at claim time. This preserves
`gogobee_mischief_plan.md:198-202`*"Pete never writes a balance, so no double-spend surface."*
### The prerequisite: euro idempotency (BLOCKING)
`euro_transactions` (`db.go:1324`) has **no external id and no unique constraint**. `Debit`
is an atomic conditional UPDATE, but calling it twice debits twice. That is safe today only
because every caller is a Matrix message that arrives once. A *retrying poll loop* breaks
that: a claim that succeeds but whose ack is lost on the wire gets retried, and the player
pays twice.
**Before any of this ships:**
```sql
ALTER TABLE euro_transactions ADD COLUMN external_id TEXT;
CREATE UNIQUE INDEX idx_euro_tx_external ON euro_transactions(external_id)
WHERE external_id IS NOT NULL;
```
plus `CreditIdem(userID, amount, reason, externalID)` / `DebitIdem(...)` in `euro.go` that
do the balance mutation and the transaction insert **in one tx**, and treat a unique-violation
on `external_id` as success-already-applied. Everything web-initiated goes through these.
---
## 4. The wire protocol
All new Pete endpoints are bearer-authed with the existing ingest token
(`internal/web/adventure.go:307` `bearerOK`). gogobee grows its first GET path in
`internal/peteclient/client.go` — the poll loop the mischief plan already calls for.
### Pete serves (gogobee polls, ~3s interval)
```
GET /api/games/escrow/pending -> [{guid, matrix_user, kind: buyin|cashout, amount}]
POST /api/games/escrow/claim <- {guid} idempotent, marks claimed
```
### gogobee pushes (existing peteclient queue, guid-idempotent)
```
POST /api/games/escrow/settled -> {guid, ok: bool, reason?: "insufficient_funds", balance_after}
```
Reuse `pete_emit_queue` (`client.go:121-125`, `INSERT OR IGNORE` on `guid` PK) — it already
does durability, backoff and parking. Don't build a second queue.
### State machine (Pete side, table `game_escrow`)
```
requested -> claimed -> funded (buyin ok; chips become spendable)
-> rejected (insufficient funds; nothing spendable)
requested -> claimed -> settled (cashout ok; chips destroyed, euros credited)
```
Poll interval 3s, not 30s: a player waiting to be dealt is watching a spinner. 3s of
"buying chips…" is acceptable; 30s is not.
### The reaper
Chips left in an abandoned session are euros in limbo. Auto-cash-out any session idle for
30 minutes. A crashed Pete must reconcile on boot: any `claimed` escrow with no `settled`
push gets re-polled by GUID.
---
## 5. Code reuse — copy, don't share
Separate modules, both mine, and the shells diverge (Matrix vs HTTP). Copy the pure cores
into `pete/internal/games/`, let them drift, no shared module.
### Verdict per game
| Game | Copy | Rewrite | Notes |
|---|---|---|---|
| **Hold'em** | ~2,700 LOC | the shell | The crown jewel. Take it all. |
| **UNO** | ~1,400 LOC | the turn engine | Great primitives, unshippable engine. |
| **Hangman** | ~250 LOC | loading/persistence | Clean rune-safe state machine. |
| **Blackjack** | ~95 LOC | everything else | 95 lines is the entire core. |
| **Trivia** | ~80 LOC | everything else | **No question bank exists.** |
### Hold'em — take almost all of it
Already mautrix-free, verified by import check:
- `holdem_cfr.go` (1,285) — full CFR trainer + NPC policy runtime, info-set packing into a
`uint64`, regret pruning, board-texture/SPR/equity bucketing. Plus the trained
`data/policy.gob` (3.4 MB) and `cmd/holdem-train`, `cmd/holdem-seed`. **This is the single
highest-value asset in either repo.**
- `holdem_equity.go` + `holdem_equity_range.go` (548) — Monte-Carlo equity, equity-vs-range,
draw/out detection. 100% pure, well tested.
- `holdem_betting.go` (383) — side pots, min-raise, all-in, street completion. The fiddly
poker rules you do not want to rewrite. **Untested in gogobee — write tests as you port.**
- `holdem_game.go`, `holdem_eval.go`, `holdem_render.go`.
Entanglements to break (mechanical):
1. `id.UserID``PlayerID string` (`holdem_betting.go:283,314`; `holdem_eval.go` winnings maps).
2. Delete `RoomID`/`DMRoomID` from `HoldemGame` — table identity belongs to the shell.
3. Hoist the four `*time.Timer` fields out of `HoldemGame` (`holdem_game.go:92-95`).
4. `LoadPolicy(path)` does `os.Open``LoadPolicyFrom(io.Reader)`, so the policy can be `embed.FS`'d.
Hand evaluation is **not** homegrown — `holdem_eval.go:12` wraps `poker.Evaluate`. Just take
the `github.com/chehsunliu/poker` dependency.
### UNO — lift the primitives, rewrite the engine
Copy verbatim (already unit-tested in `uno_test.go`):
- `unoCard`/`unoColor`/`unoValue`, `canPlayOn`, `newUnoDeck`, draw/reshuffle (`uno.go:21-364`)
- The bot AI as free functions: `botPickCard`, `botPickNormal`, `botPickAggressive`,
`botPickColor` (`uno.go:1465-1585`)
- `uno_nomercy.go` is ~90% pure: scoring, stacking rules, no-mercy deck, second bot.
**Rewrite the turn engine.** In gogobee the engine *is* the message sender —
`executeMultiTurn`, `applyAndAnnounce`, `handlePlayerPlay` mutate state and call
`p.SendReply(...)` mid-turn, and their `error` returns mean "send failed", not "illegal move".
There is no `ApplyMove(game, move) (Result, error)` seam anywhere. Disentangling that costs
more than rewriting it against the (good) primitives. One near-seam worth keeping:
`applyCardEffects` (`uno_multi.go:1459`) already returns a struct instead of sending.
### Hangman — take the struct
`hangmanGame` + `guessLetter`/`guessSolution`/`displayPhrase` + the `gallows [7]string` ASCII
art (`hangman.go:26-274`). Strip three fields (`participants`, `solvedBy`, `threadID`). Copy
`hangman_phrases.txt` (237 lines) and `embed` it instead of `os.Getenv("HANGMAN_PHRASE_FILE")`.
Drop the dreamclient translation path for v1.
### Blackjack — retype it
`handValue` (correct soft-ace demotion), `isBlackjack`, and their tests. That's it — 95 lines.
The rest is `bjTable` keyed by `id.RoomID` with timers embedded, and raw `db.Exec` SQL at
`blackjack.go:867`.
### Trivia — the question bank does not exist
`trivia.go:288` fetches from OpenTDB live, one question per round:
```go
apiURL := "https://opentdb.com/api.php?amount=1"
```
Reuse the category map (`trivia.go:24-53`) and `calculateScore` (time-decay, `:536`). For the
web version, **pre-fetch and cache a bank locally** — a per-question HTTP call in a web game
loop is a latency and rate-limit problem gogobee never had to care about at Matrix pace. Route
outbound fetches through Pete's `internal/safehttp` (SSRF guard).
Trivia has **no euro coupling today** (points only). Keep it that way in v1 — it's the one
game that can ship with zero escrow risk.
### Two things that apply to every copied engine
**Thread the RNG.** Every card game uses the `math/rand/v2` package global —
`blackjack.go:60`, `uno.go:186,277`, `holdem_game.go:102`, and throughout the CFR/Monte-Carlo
code. Nothing is seedable, which is why `TestBotPickCard_*` can only assert weak properties.
The adventure half of gogobee already does this right (`dnd_zone_combat.go:361` threads an
explicit `*rand.Rand` via `rand.NewPCG`). The card games never adopted it. **Threading
`rng *rand.Rand` through the deck constructors is mandatory, not optional** — ~20 call sites,
and it's the difference between a testable engine and one you can only smoke-test. It also
gives you a reproducible shuffle for dispute resolution.
**Hoist the timers.** `bjTable.joinTimer/turnTimer/reminderTimers`, `unoGame.idleTimer/warningTimer`,
`HoldemGame.actionTimer/warningTimer/idleTimer/idleWarningTimer` — all live inside the game
structs today. Timers are a shell concern. Game state must be a plain value you can serialize,
which is also what makes restart-mid-hand survivable.
### Build a `cards` package while you're at it
There is **no shared cards package in gogobee** — blackjack has its own deck
(`blackjack.go:32-75`), UNO has its own (`uno.go:130-189`), hold'em uses the third-party lib.
Consolidate into `pete/internal/games/cards` during the port rather than importing the
duplication.
---
## 6. Architecture in Pete
```
internal/games/
cards/ shared deck primitives (new; consolidates gogobee's duplicates)
blackjack/ pure engine — ApplyMove(state, move) (state, events, error)
holdem/ pure engine + cfr/ (copied) + policy.gob (embedded)
uno/ pure engine (rewritten) over copied primitives + bots
hangman/ pure engine (copied) + phrases.txt (embedded)
trivia/ pure engine (new) + cached question bank
escrow/ chip ledger, the gogobee poll/push seam
table/ session, seating, turn clocks, reconnect — the shell
```
**Server-authoritative, always.** The browser sends intents and never sees the deck. Any
game with money attached cannot trust a client-reported result. This is why the engines have
to be Go on Pete's side rather than ported to JS.
**Every engine is a pure reducer**: `ApplyMove(state, move) (newState, []Event, error)`.
Timers, sockets and persistence all live in `table/`. That's the seam gogobee never had, and
it's what buys testability, replay, and surviving a redeploy.
### Transport
- **Blackjack, Hangman, Trivia, UNO-solo** — request/response over `fetch`. No sockets.
- **Hold'em, UNO-multi** — WebSocket. Lobby, seating, presence, turn clocks, reconnect-mid-hand,
spectators. This is the bulk of the total effort, and it is the only genuinely new
infrastructure in the project.
### Frontend
Pete has **no SPA and no bundler** today — server-rendered `html/template` + `embed.FS`, plain
`<script defer>` tags, npm present only to run the Tailwind CLI. games.parodia.dev is the first
real client-side app in the repo.
Precedent says this is survivable: `weather-gl.js` is 1,028 lines of hand-written WebGL2 with
no build step. Do the same here — vanilla JS per game, no framework, no bundler, Tailwind for
layout. Revisit only if it actually hurts.
### Auth
Session cookie is host-only today — `auth.go:151` sets `Path` but no `Domain`, so a
`news.parodia.dev` session will not travel to `games.parodia.dev`. Set `Domain: ".parodia.dev"`.
Note this widens the cookie to every parodia.dev host including the landing site — a deliberate
loosening, fine here, but not a freebie. Add the `games.parodia.dev` redirect URI to the `pete`
app in Authentik.
Games require login. No anonymous play — there's money in it.
---
## 7. Ship order
**Phase 0 — euro idempotency (gogobee).** `external_id` column + unique index +
`CreditIdem`/`DebitIdem`. Blocking; nothing else is safe without it.
**Phase 1 — escrow + Blackjack.** The full money loop against the simplest possible game
(95 lines of logic). Buy in, play, cash out. This proves cross-subdomain auth, the identity
mapping, the poll loop, the escrow state machine, the reaper, and the frontend shape — all
against a game where the *game* cannot be what's broken.
**Phase 2 — Trivia + Hangman.** No escrow (trivia has no euro coupling; keep hangman's
collaborative credit out of v1). Pure frontend and content work. Cheap wins, and they make the
site feel like a place rather than a demo.
**Phase 3 — UNO.** Solo first (single-player vs bot, no sockets). Then multi, which is where
the WebSocket infrastructure gets built. Forgiving latency, simple turn model — the right place
to learn multiplayer.
**Phase 4 — Hold'em.** Last. It's the hardest engine (side pots, all-ins, split pots), the
biggest port, and the one where collusion is a real threat rather than a theoretical one. Do it
when the multiplayer plumbing has already survived contact with real players.
---
## 8. Risks
**Economy inflation.** A web casino runs orders of magnitude more hands per hour than
Matrix-paced games ever did. Whatever the house edge is, it now compounds far faster in both
directions. Before Phase 1 ships, decide: session buy-in caps, a daily net-win/loss ceiling, or
a rake. This is the risk most likely to be discovered too late.
**Restart mid-hand.** Game state is in memory, so a Pete redeploy kills live tables — the same
property gogobee has today, and it redeploys far less often than Pete does. Mitigate with
serializable state (which the pure-reducer design gives for free) plus a drain-before-restart,
not a second process.
**Collusion in hold'em.** Two browsers, one person, one table. Not solvable in v1; at minimum
log seat/IP/session overlap so it's *detectable* after the fact.
**The gogobee contract is cross-repo.** `roster_test.go` already guards it: an unknown
`event_type` is a 400 that makes gogobee's sender park the row. Add the same guard on the new
escrow endpoints, and keep the payload structs in step across both repos by hand — that's the
cost of copying instead of sharing, and it's the right trade here.
---
## 9. Open questions
- **Chips 1:1 with euros, or a separate denomination?** 1:1 is simpler and honest. A separate
denomination gives you a knob for the inflation problem.
- **Do web results feed the Matrix room?** Pete already has a priority poster
(`adventure.go:151`). "Reala just took a 12k pot" is a good bulletin, and this is nearly free.
- **NPC opponents at the web tables?** The CFR bot is right there and it's good. It also means
a table never sits empty, which matters a lot for a small community.