games: the door you can see from outside, and the picture on it

We never had Open Graph on the casino, and adding meta tags would not have
fixed it. Every route was behind requirePlayer, so a link pasted into a chat
window got a 302 to sign-in and unfurled as whatever the auth screen said:
"parodia.dev", no image, no description. Tags on a page a stranger cannot
fetch are tags nobody reads. So the casino now has a front door — a real page,
served to anybody, that says what the place is and offers a way in. You still
can't play from it, and every table still bounces you to sign-in.

The share card is drawn in Go rather than checked in as a picture, because the
casino has two names on a clock and the card keeps the joke: paste the link in
daylight and you get Casinopolis on green felt, paste it after six and the neon
is on and it says Casino Night Zone. Same roomAt() rule as everywhere else,
except the clock that decides is the server's — an unfurl bot has no evening of
its own. Both cards are drawn once, at first ask, and kept.

Two things worth keeping from building it. color.RGBA is alpha-premultiplied,
and the lamp over the table wrote raw channels next to a low alpha, which is
not a dim glow but an invalid colour: image/draw ran it past 255 and wrapped
the hue, and the first card came out with a blue dome over a green stripe. If
a colour here ever comes out impossible, look for a missing premultiply. And
og:image has to be an absolute URL that actually resolves, which is two
different addresses depending on how you arrived: /og.png on the games host
(hostRouter puts the /games back on) and /games/og.png anywhere else. The dev
rig advertised the first while serving only the second. The test now reads the
URL off the page and goes and fetches it, on both hosts, because an og:image
that 404s is worth exactly as much as no og:image.

Fredoka is vendored (OFL) — the page can reach for a font over the network and
a server drawing a PNG cannot.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
prosolis
2026-07-14 13:30:52 -07:00
parent 39ed293f4f
commit 7ca1f7a030
8 changed files with 754 additions and 3 deletions

View File

@@ -2,6 +2,7 @@ package web
import (
"net/http"
"strings"
"time"
"pete/internal/games/blackjack"
@@ -67,7 +68,12 @@ func roomAt(hour int) room {
// 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
Room room
// URL and OGImage are here for the share card, and they are absolute because
// Open Graph will not resolve a relative one: the thing reading those tags is
// a chat server, and it has no page to resolve against. See casinoURL.
URL string // this page, at the address a player would type
OGImage string // the share card, at the same
User *SessionUser
Cap int64
RakePct int
@@ -94,6 +100,7 @@ type gamesPage struct {
// silently stops including the newest game.
func (s *Server) casinoRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /games", s.handleLobby)
mux.HandleFunc("GET /games/og.png", s.handleGamesOG) // the share card, and the one games page with no door on it
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /games/hangman", s.handleHangman)
mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
@@ -140,9 +147,35 @@ func (s *Server) requirePlayer(w http.ResponseWriter, r *http.Request) bool {
return false
}
// casinoURL turns a route on the mux ("/games/uno") into the absolute address a
// player would actually type, which is what an unfurl bot has to be handed.
//
// The two halves of it are the same fact seen twice. On the games host the casino
// sits at the root and hostRouter puts the /games prefix back on the way in, so
// the public address of a page is its route with that prefix taken off. Without a
// games host — development, and only development — there is no rewrite and no
// prefix to remove, and the route *is* the address. Getting this backwards points
// og:image at a URL that 404s, which is exactly as visible as no og:image at all.
func (s *Server) casinoURL(r *http.Request, route string) string {
if h := strings.TrimSpace(s.cfg.Games.Host); h != "" {
path := strings.TrimPrefix(route, "/games")
if path == "" {
path = "/"
}
return "https://" + h + path
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return scheme + "://" + r.Host + route
}
func (s *Server) gamesPage(r *http.Request) gamesPage {
return gamesPage{
Room: roomAt(time.Now().Hour()),
URL: s.casinoURL(r, r.URL.Path),
OGImage: s.casinoURL(r, "/games/og.png"),
User: s.auth.userFromRequest(r), // requirePlayer ran first, so this is non-nil
Cap: storage.MaxChipsOnTable,
RakePct: int(blackjack.DefaultRules().RakePct * 100),
@@ -162,8 +195,21 @@ func (s *Server) gamesPage(r *http.Request) gamesPage {
}
}
// handleLobby is the one page in the casino that answers a stranger.
//
// Every table bounces an anonymous visitor straight to sign-in, which is right:
// there is money on them. But the front door cannot do that, because the front
// door is what people paste into a chat window, and a 302 to an auth screen has
// nothing in it to make a preview out of — the casino unfurled as the bare word
// "parodia.dev" for as long as it has existed. So the door is a real page, served
// to anybody, carrying the share card and a way in. You still can't play from it.
func (s *Server) handleLobby(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
if !s.gamesReady() {
http.NotFound(w, r)
return
}
if u := s.auth.userFromRequest(r); u == nil || u.MatrixUser(s.cfg.Games.MatrixServer) == "" {
s.render(w, "games_door", s.gamesPage(r))
return
}
s.render(w, "games", s.gamesPage(r))