Files
Pete/internal/web/games_og.go
prosolis 7ca1f7a030 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
2026-07-14 13:30:52 -07:00

439 lines
14 KiB
Go

package web
import (
"bytes"
_ "embed"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"math"
"net/http"
"sync"
"time"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"golang.org/x/image/vector"
)
// The card that shows up when somebody pastes the casino into a chat window.
//
// It is drawn here, in Go, rather than checked in as a picture, because the
// casino has two names on a clock and the share 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 the sign says Casino Night Zone. Same tables, different room,
// same rule as roomAt() everywhere else.
//
// The clock that decides is the *server's*, because an unfurl bot has no evening
// of its own. That's the one place the room rule can't be the player's own clock.
//go:embed assets/fonts/Fredoka-SemiBold.ttf
var fredokaTTF []byte
// Share cards are 1200x630: the size every unfurler crops to and the one thing
// about Open Graph that everybody agrees on.
const (
ogWidth = 1200
ogHeight = 630
)
// ogPalette is a room's colours, lifted from the CSS block of the same name in
// input.css. Two copies of a palette is a thing that drifts, so if you retune a
// room, retune it here as well — TestTheShareCardKnowsBothRooms will not catch a
// colour, only a missing room.
type ogPalette struct {
bg color.RGBA // the room, behind the table
feltA color.RGBA // the felt, lit
feltC color.RGBA // the felt, in shadow
ink color.RGBA // type
accent color.RGBA // the sign, and the lamp over the table
}
var ogPalettes = map[string]ogPalette{
roomDay.Slug: {
bg: rgb(0x16, 0x21, 0x1c),
feltA: rgb(0x2f, 0x7d, 0x5b),
feltC: rgb(0x1c, 0x4d, 0x3c),
ink: rgb(0xf6, 0xec, 0xd8),
accent: rgb(0xf2, 0xb5, 0x3d),
},
roomNight.Slug: {
bg: rgb(0x14, 0x0f, 0x2e),
feltA: rgb(0x4a, 0x2f, 0xa8),
feltC: rgb(0x24, 0x16, 0x59),
ink: rgb(0xf2, 0xec, 0xff),
accent: rgb(0xff, 0xcc, 0x2f),
},
}
// A card is drawn once per room and then kept. There are two of them, they never
// change, and an unfurl bot is not worth a rasterizer.
var (
ogOnce sync.Once
ogCards map[string][]byte // room slug -> PNG
ogErr error
)
func (s *Server) handleGamesOG(w http.ResponseWriter, r *http.Request) {
if !s.gamesReady() {
http.NotFound(w, r)
return
}
// No requirePlayer here, and that is the whole point: the thing fetching this
// is a chat server's link preview, which has never signed in and never will.
ogOnce.Do(func() { ogCards, ogErr = drawShareCards() })
if ogErr != nil {
http.Error(w, "share card unavailable", http.StatusInternalServerError)
return
}
room := roomAt(time.Now().Hour())
png := ogCards[room.Slug]
w.Header().Set("Content-Type", "image/png")
// Long enough that a busy channel isn't redrawing it, short enough that the
// room actually turns over: the lights come on at six and the card follows
// within the hour.
w.Header().Set("Cache-Control", "public, max-age=900")
w.Header().Set("Content-Length", fmt.Sprint(len(png)))
http.ServeContent(w, r, "og.png", time.Time{}, bytes.NewReader(png))
}
// drawShareCards renders every room's card up front. If the font is broken every
// card is broken, so they fail together or not at all.
func drawShareCards() (map[string][]byte, error) {
f, err := opentype.Parse(fredokaTTF)
if err != nil {
return nil, fmt.Errorf("parse fredoka: %w", err)
}
out := make(map[string][]byte, len(ogPalettes))
for _, rm := range []room{roomDay, roomNight} {
img, err := drawShareCard(f, rm, ogPalettes[rm.Slug])
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("encode %s: %w", rm.Slug, err)
}
out[rm.Slug] = buf.Bytes()
}
return out, nil
}
func drawShareCard(f *opentype.Font, rm room, p ogPalette) (image.Image, error) {
title, err := face(f, 96)
if err != nil {
return nil, err
}
line, err := face(f, 33)
if err != nil {
return nil, err
}
small, err := face(f, 25)
if err != nil {
return nil, err
}
defer title.Close()
defer line.Close()
defer small.Close()
img := image.NewRGBA(image.Rect(0, 0, ogWidth, ogHeight))
draw.Draw(img, img.Bounds(), &image.Uniform{p.bg}, image.Point{}, draw.Src)
// The table: a rounded rect of felt, lit from the top and falling into shadow
// at the rail, which is the same trick the room's CSS plays with a gradient.
table := func(rz *vector.Rasterizer) {
roundRect(rz, 36, 36, ogWidth-72, ogHeight-72, 44, nil)
}
fillPath(img, verticalGradient(p.feltA, p.feltC), table)
// The lamp over it. Nothing else in the picture explains where the light is
// coming from, and a felt with no lamp reads as a green rectangle. It is
// painted through the table's own shape, because light that spills onto the
// floor outside the rail is not a lamp, it's a mistake.
fillPath(img, lamp(ogWidth/2, 40, 520, p.accent, 0.26), table)
// The sign over the door.
centerText(img, title, p.accent, rm.Name, ogHeight/2-42)
centerText(img, line, alpha(p.ink, 0.92), "Blackjack, Hold'em, UNO, Trivia, Hangman, Solitaire", ogHeight/2+26)
centerText(img, small, alpha(p.ink, 0.62), "Played for real gogobee euros", ogHeight/2+78)
// Bottom left: two cards, one face down and one face up, because a casino that
// shows you only the backs is a casino that isn't dealing.
drawCardBack(img, p, 118, 430, -13)
drawCardFace(img, 196, 420, 7)
// Bottom right: what you're playing for.
chips := []color.RGBA{p.accent, rgb(0xd9, 0x4f, 0x4f), rgb(0xf6, 0xf1, 0xe6), p.accent}
for i, c := range chips {
drawChip(img, 1042, float32(536-i*23), 46, c, p)
}
// The address, small, in the corner. A share card is also a signpost.
rightText(img, small, alpha(p.ink, 0.45), "games.parodia.dev", ogWidth-70, 96)
return img, nil
}
// ---- shapes -----------------------------------------------------------------
//
// Everything below draws through a vector.Rasterizer the size of the whole card,
// which is wasteful and completely fine: it happens twice, at boot, forever.
// xform moves a point before it's rasterized. It is how anything here gets to sit
// at an angle — font.Drawer can't rotate, but a path can be rotated on its way in.
type xform func(x, y float32) (float32, float32)
func rotate(cx, cy, deg float32) xform {
rad := float64(deg) * math.Pi / 180
sin, cos := float32(math.Sin(rad)), float32(math.Cos(rad))
return func(x, y float32) (float32, float32) {
dx, dy := x-cx, y-cy
return cx + dx*cos - dy*sin, cy + dx*sin + dy*cos
}
}
func (t xform) at(x, y float32) (float32, float32) {
if t == nil {
return x, y
}
return t(x, y)
}
// fillPath rasterizes a path and paints src through it.
func fillPath(dst *image.RGBA, src image.Image, path func(*vector.Rasterizer)) {
rz := vector.NewRasterizer(ogWidth, ogHeight)
path(rz)
rz.Draw(dst, dst.Bounds(), src, image.Point{})
}
func fill(dst *image.RGBA, c color.Color, path func(*vector.Rasterizer)) {
fillPath(dst, &image.Uniform{c}, path)
}
// roundRect lays a rounded rectangle into the rasterizer, optionally through a
// transform, so the same helper draws a table and a card held at an angle.
func roundRect(rz *vector.Rasterizer, x, y, w, h, r float32, t xform) {
move := func(px, py float32) { rz.MoveTo(t.at(px, py)) }
line := func(px, py float32) { rz.LineTo(t.at(px, py)) }
quad := func(cx, cy, px, py float32) {
qx, qy := t.at(cx, cy)
ex, ey := t.at(px, py)
rz.QuadTo(qx, qy, ex, ey)
}
move(x+r, y)
line(x+w-r, y)
quad(x+w, y, x+w, y+r)
line(x+w, y+h-r)
quad(x+w, y+h, x+w-r, y+h)
line(x+r, y+h)
quad(x, y+h, x, y+h-r)
line(x, y+r)
quad(x, y, x+r, y)
rz.ClosePath()
}
func circle(rz *vector.Rasterizer, cx, cy, r float32) {
// Four cubics, the usual 0.5523 magic number for a circle out of beziers.
const k = 0.5523
rz.MoveTo(cx+r, cy)
rz.CubeTo(cx+r, cy+r*k, cx+r*k, cy+r, cx, cy+r)
rz.CubeTo(cx-r*k, cy+r, cx-r, cy+r*k, cx-r, cy)
rz.CubeTo(cx-r, cy-r*k, cx-r*k, cy-r, cx, cy-r)
rz.CubeTo(cx+r*k, cy-r, cx+r, cy-r*k, cx+r, cy)
rz.ClosePath()
}
// hexagon is Pete's mark — the same six-sided badge as the favicon, which is what
// the back of the house's cards is printed with.
func hexagon(rz *vector.Rasterizer, cx, cy, r float32, t xform) {
for i := 0; i < 6; i++ {
ang := float64(i)*math.Pi/3 - math.Pi/2
x := cx + r*float32(math.Cos(ang))
y := cy + r*float32(math.Sin(ang))
px, py := t.at(x, y)
if i == 0 {
rz.MoveTo(px, py)
} else {
rz.LineTo(px, py)
}
}
rz.ClosePath()
}
const (
cardW = 132
cardH = 186
)
// drawCardBack is the house's card: dark, with the hexagon on it.
func drawCardBack(dst *image.RGBA, p ogPalette, x, y, deg float32) {
t := rotate(x+cardW/2, y+cardH/2, deg)
fill(dst, color.RGBA{0, 0, 0, 70}, func(rz *vector.Rasterizer) {
roundRect(rz, x+5, y+8, cardW, cardH, 14, t)
})
fill(dst, alpha(p.ink, 0.96), func(rz *vector.Rasterizer) {
roundRect(rz, x, y, cardW, cardH, 14, t)
})
fill(dst, darken(p.feltC, 0.55), func(rz *vector.Rasterizer) {
roundRect(rz, x+9, y+9, cardW-18, cardH-18, 9, t)
})
fill(dst, p.accent, func(rz *vector.Rasterizer) {
hexagon(rz, x+cardW/2, y+cardH/2, 34, t)
})
fill(dst, darken(p.feltC, 0.55), func(rz *vector.Rasterizer) {
hexagon(rz, x+cardW/2, y+cardH/2, 20, t)
})
}
// drawCardFace is the one that's been turned over: an ace of hearts, near enough.
func drawCardFace(dst *image.RGBA, x, y, deg float32) {
t := rotate(x+cardW/2, y+cardH/2, deg)
red := rgb(0xd9, 0x3b, 0x3b)
fill(dst, color.RGBA{0, 0, 0, 80}, func(rz *vector.Rasterizer) {
roundRect(rz, x+5, y+9, cardW, cardH, 14, t)
})
fill(dst, rgb(0xfb, 0xf7, 0xef), func(rz *vector.Rasterizer) {
roundRect(rz, x, y, cardW, cardH, 14, t)
})
heart(dst, red, x+cardW/2, y+cardH/2+4, 40, t)
// The pip in the corner, which is how you know it's a card and not a tile.
heart(dst, red, x+24, y+30, 11, t)
}
// heart, as two lobes and a point.
func heart(dst *image.RGBA, c color.RGBA, cx, cy, r float32, t xform) {
fill(dst, c, func(rz *vector.Rasterizer) {
move := func(px, py float32) { rz.MoveTo(t.at(px, py)) }
cube := func(ax, ay, bx, by, px, py float32) {
a1, a2 := t.at(ax, ay)
b1, b2 := t.at(bx, by)
e1, e2 := t.at(px, py)
rz.CubeTo(a1, a2, b1, b2, e1, e2)
}
bottom := cy + r*0.95
move(cx, bottom)
cube(cx-r*1.15, cy+r*0.18, cx-r*1.0, cy-r*0.95, cx, cy-r*0.28)
cube(cx+r*1.0, cy-r*0.95, cx+r*1.15, cy+r*0.18, cx, bottom)
rz.ClosePath()
})
}
// drawChip is a chip seen face on: a disc, a ring of notches around the rim, and a
// pale centre. Stack four and it reads as money.
func drawChip(dst *image.RGBA, cx, cy, r float32, c color.RGBA, p ogPalette) {
fill(dst, color.RGBA{0, 0, 0, 60}, func(rz *vector.Rasterizer) { circle(rz, cx+3, cy+5, r) })
fill(dst, c, func(rz *vector.Rasterizer) { circle(rz, cx, cy, r) })
// Six notches on the rim, in the room's ink, the way a real chip is edge-marked.
for i := 0; i < 6; i++ {
ang := float64(i) * math.Pi / 3
nx := cx + (r-7)*float32(math.Cos(ang))
ny := cy + (r-7)*float32(math.Sin(ang))
fill(dst, alpha(p.ink, 0.85), func(rz *vector.Rasterizer) { circle(rz, nx, ny, 6) })
}
fill(dst, alpha(p.ink, 0.28), func(rz *vector.Rasterizer) { circle(rz, cx, cy, r-15) })
fill(dst, darken(c, 0.82), func(rz *vector.Rasterizer) { circle(rz, cx, cy, r-21) })
}
// lamp is the light over the table: the accent colour, brightest under the bulb
// and gone by the rail. It's an image rather than a paint, so it can be laid down
// through the table's shape and stop at the felt's edge.
//
// Note the alpha() on the way out. color.RGBA is *alpha-premultiplied*, and the
// first version of this wrote the raw channels next to a low alpha — which is not
// a dim honey glow, it's an invalid colour, and image/draw ran it straight past
// 255 and wrapped it. The card came out with a blue dome over a green stripe. If
// something here ever turns an impossible colour, look for a premultiply first.
func lamp(cx, cy, radius float32, c color.RGBA, strength float64) image.Image {
img := image.NewRGBA(image.Rect(0, 0, ogWidth, ogHeight))
for y := 0; y < ogHeight; y++ {
for x := 0; x < ogWidth; x++ {
dx, dy := float64(float32(x)-cx), float64(float32(y)-cy)
d := math.Sqrt(dx*dx+dy*dy) / float64(radius)
if d >= 1 {
continue
}
// Squared falloff: a lamp is bright under itself and dim at the edges.
img.SetRGBA(x, y, alpha(c, strength*(1-d)*(1-d)))
}
}
return img
}
// verticalGradient is the felt: lit at the top, in shadow at the rail.
func verticalGradient(top, bottom color.RGBA) image.Image {
g := image.NewRGBA(image.Rect(0, 0, 1, ogHeight))
for y := 0; y < ogHeight; y++ {
f := float64(y) / float64(ogHeight-1)
g.SetRGBA(0, y, color.RGBA{
R: lerp(top.R, bottom.R, f),
G: lerp(top.G, bottom.G, f),
B: lerp(top.B, bottom.B, f),
A: 255,
})
}
// One column, stretched sideways forever: the felt doesn't change across the table.
return &stretched{g}
}
type stretched struct{ src *image.RGBA }
func (s *stretched) ColorModel() color.Model { return s.src.ColorModel() }
func (s *stretched) Bounds() image.Rectangle { return image.Rect(0, 0, ogWidth, ogHeight) }
func (s *stretched) At(x, y int) color.Color { return s.src.At(0, y) }
// ---- type -------------------------------------------------------------------
func face(f *opentype.Font, size float64) (font.Face, error) {
return opentype.NewFace(f, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
}
func centerText(dst *image.RGBA, f font.Face, c color.Color, s string, baseline int) {
w := font.MeasureString(f, s)
drawText(dst, f, c, s, (ogWidth-w.Round())/2, baseline)
}
func rightText(dst *image.RGBA, f font.Face, c color.Color, s string, right, baseline int) {
w := font.MeasureString(f, s)
drawText(dst, f, c, s, right-w.Round(), baseline)
}
func drawText(dst *image.RGBA, f font.Face, c color.Color, s string, x, baseline int) {
d := &font.Drawer{
Dst: dst,
Src: &image.Uniform{c},
Face: f,
Dot: fixed.P(x, baseline),
}
d.DrawString(s)
}
// ---- colour -----------------------------------------------------------------
func rgb(r, g, b uint8) color.RGBA { return color.RGBA{r, g, b, 255} }
// alpha returns c at a fraction of its opacity, pre-multiplied, which is what
// image/draw wants.
func alpha(c color.RGBA, f float64) color.RGBA {
return color.RGBA{
R: uint8(float64(c.R) * f),
G: uint8(float64(c.G) * f),
B: uint8(float64(c.B) * f),
A: uint8(255 * f),
}
}
func darken(c color.RGBA, f float64) color.RGBA {
return color.RGBA{
R: uint8(float64(c.R) * f),
G: uint8(float64(c.G) * f),
B: uint8(float64(c.B) * f),
A: c.A,
}
}
func lerp(a, b uint8, f float64) uint8 {
return uint8(float64(a) + (float64(b)-float64(a))*f)
}