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

Binary file not shown.

View File

@@ -0,0 +1,8 @@
Fredoka is copyright the Fredoka Project Authors
(https://github.com/hafontia/Fredoka), licensed under the SIL Open Font
License, Version 1.1: https://openfontlicense.org
It is vendored here because the share card (games_og.go) is drawn on the
server, and a server cannot reach for a font over the network the way the
page does. The site itself still loads Fredoka from Google's CDN, so this is
the same typeface arriving by a second road, not a second typeface.

438
internal/web/games_og.go Normal file
View File

@@ -0,0 +1,438 @@
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)
}

View File

@@ -0,0 +1,166 @@
package web
import (
"bytes"
"image/png"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
)
// The casino had no share card for as long as it existed, and the reason is worth
// keeping in a test rather than in a memory: every route was behind sign-in, so a
// link pasted into a chat window unfurled as whatever the *auth screen* said. Meta
// tags on a page nobody unauthenticated can fetch are meta tags nobody reads.
//
// So the two things these tests hold down are: a stranger gets a real page at the
// front door, and a stranger gets the picture. Everything else stays shut.
func TestTheDoorIsOpenToStrangers(t *testing.T) {
s := newCasino(t)
mux := http.NewServeMux()
s.casinoRoutes(mux)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/games", nil))
if w.Code != http.StatusOK {
t.Fatalf("GET /games as a stranger = %d, want 200: the front door has to be a page an unfurl bot can read", w.Code)
}
body := w.Body.String()
for _, want := range []string{
`property="og:image"`,
`property="og:title"`,
`property="og:description"`,
`name="twitter:card"`,
"og.png",
"Sign in",
} {
if !strings.Contains(body, want) {
t.Errorf("the door is missing %q", want)
}
}
if strings.Contains(body, "unknown page") {
t.Fatal("games_door is not in the games template set in server.go")
}
// A door you can play from is not a door.
if strings.Contains(body, `data-move=`) {
t.Error("the door is serving a table to somebody who has not signed in")
}
}
func TestTheTablesStayShutToStrangers(t *testing.T) {
s := newCasino(t)
mux := http.NewServeMux()
s.casinoRoutes(mux)
for _, path := range []string{"/games/blackjack", "/games/holdem", "/games/uno", "/games/trivia", "/games/hangman", "/games/solitaire"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != http.StatusFound {
t.Errorf("GET %s as a stranger = %d, want a 302 to sign-in", path, w.Code)
}
}
}
func TestTheShareCardNeedsNoSignIn(t *testing.T) {
s := newCasino(t)
mux := http.NewServeMux()
s.casinoRoutes(mux)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/games/og.png", nil))
if w.Code != http.StatusOK {
t.Fatalf("GET /games/og.png as a stranger = %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "image/png" {
t.Errorf("Content-Type = %q, want image/png", ct)
}
img, err := png.Decode(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("the share card is not a PNG: %v", err)
}
if b := img.Bounds(); b.Dx() != ogWidth || b.Dy() != ogHeight {
t.Errorf("share card is %dx%d, want %dx%d — every unfurler crops to that", b.Dx(), b.Dy(), ogWidth, ogHeight)
}
}
// An og:image that 404s is worth exactly as much as no og:image, and the two ways
// the casino is reachable disagree about where the picture lives: on the games host
// it's /og.png (hostRouter puts the /games back on), and anywhere else it's
// /games/og.png. So take the URL the page actually advertises and go and get it.
func TestTheAdvertisedShareCardIsReallyThere(t *testing.T) {
for _, host := range []string{"", "games.parodia.dev"} {
name := "no games host"
if host != "" {
name = host
}
t.Run(name, func(t *testing.T) {
s := newCasino(t)
s.cfg.Games.Host = host
mux := http.NewServeMux()
s.casinoRoutes(mux)
srv := s.hostRouter(mux) // what production actually serves
// Ask the door where its picture is.
w := httptest.NewRecorder()
door := httptest.NewRequest("GET", "/games", nil)
if host != "" {
door.Host = host
}
srv.ServeHTTP(w, door)
img := ogImageURL(t, w.Body.String())
// Then go and fetch it, the way a chat server would.
u, err := url.Parse(img)
if err != nil {
t.Fatalf("og:image %q is not a URL: %v", img, err)
}
if u.Host == "" {
t.Fatalf("og:image %q is relative — no unfurler will resolve it", img)
}
w = httptest.NewRecorder()
get := httptest.NewRequest("GET", u.Path, nil)
get.Host = u.Host
srv.ServeHTTP(w, get)
if w.Code != http.StatusOK {
t.Fatalf("the door advertises og:image %s, and fetching it gives %d", img, w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "image/png" {
t.Errorf("og:image %s served %q, want image/png", img, ct)
}
})
}
}
func ogImageURL(t *testing.T, body string) string {
t.Helper()
m := regexp.MustCompile(`property="og:image" content="([^"]+)"`).FindStringSubmatch(body)
if m == nil {
t.Fatal("the door serves no og:image at all")
}
return m[1]
}
// Both rooms have to have a card, because the room is picked at request time and a
// missing one serves zero bytes rather than an error.
func TestTheShareCardKnowsBothRooms(t *testing.T) {
cards, err := drawShareCards()
if err != nil {
t.Fatal(err)
}
for _, rm := range []room{roomDay, roomNight} {
if len(cards[rm.Slug]) == 0 {
t.Errorf("no share card for %s", rm.Slug)
}
}
if len(cards) != len(ogPalettes) {
t.Errorf("drew %d cards for %d palettes", len(cards), len(ogPalettes))
}
}

View File

@@ -2,6 +2,7 @@ package web
import ( import (
"net/http" "net/http"
"strings"
"time" "time"
"pete/internal/games/blackjack" "pete/internal/games/blackjack"
@@ -68,6 +69,11 @@ func roomAt(hour int) room {
// back in one convenient field at a time. // back in one convenient field at a time.
type gamesPage struct { 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 User *SessionUser
Cap int64 Cap int64
RakePct int RakePct int
@@ -94,6 +100,7 @@ type gamesPage struct {
// silently stops including the newest game. // silently stops including the newest game.
func (s *Server) casinoRoutes(mux *http.ServeMux) { func (s *Server) casinoRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /games", s.handleLobby) 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/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /games/hangman", s.handleHangman) mux.HandleFunc("GET /games/hangman", s.handleHangman)
mux.HandleFunc("GET /games/solitaire", s.handleSolitaire) mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
@@ -140,9 +147,35 @@ func (s *Server) requirePlayer(w http.ResponseWriter, r *http.Request) bool {
return false 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 { func (s *Server) gamesPage(r *http.Request) gamesPage {
return gamesPage{ return gamesPage{
Room: roomAt(time.Now().Hour()), 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 User: s.auth.userFromRequest(r), // requirePlayer ran first, so this is non-nil
Cap: storage.MaxChipsOnTable, Cap: storage.MaxChipsOnTable,
RakePct: int(blackjack.DefaultRules().RakePct * 100), 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) { 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 return
} }
s.render(w, "games", s.gamesPage(r)) s.render(w, "games", s.gamesPage(r))

View File

@@ -95,7 +95,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
pages []string pages []string
}{ }{
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}}, {"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}}, {"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
} }
tpls := make(map[string]*template.Template) tpls := make(map[string]*template.Template)
for _, set := range sets { for _, set := range sets {

View File

@@ -0,0 +1,70 @@
{{define "title"}}{{.Room.Name}}{{end}}
{{/* The front door: the only page in here a stranger is allowed to see.
It exists because a link has to unfurl into something, and because a casino
whose first move is to shove you at an auth screen is a casino you'd assume
was broken. Nothing on this page can be played — every table below sends you
through sign-in first, and comes back to the table you picked. */}}
{{define "main"}}
<div class="space-y-8">
<section class="rounded-3xl bg-[color:var(--card)] p-6 sm:p-10 shadow-pete border-2 border-[color:var(--ink)]/10">
{{/* No decoration in here on purpose. The chip stacks want the casino's JS,
which the door doesn't load, and an emoji big enough to balance the hero
is an emoji that isn't on every machine. The share card carries the
pictures; the door carries the way in. */}}
<div class="flex flex-wrap items-center justify-between gap-6">
<div class="min-w-0 max-w-2xl">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[color:var(--accent)]">The doors are open</p>
<h1 class="mt-2 font-display text-3xl sm:text-5xl font-bold">{{.Room.Name}}</h1>
<p class="mt-3 text-[color:var(--ink)]/70">
Six tables, played against the house and its bots, for the same euros you
already have. A chip is worth a euro, the house takes {{.RakePct}}% of what you
win and nothing at all when you lose, and whatever you don't spend comes
back when you cash out.
</p>
<div class="mt-6 flex flex-wrap items-center gap-3">
<a href="/auth/login?next=/games"
class="inline-flex items-center gap-2 rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-[#20180c] shadow-pete hover:-translate-y-0.5 hover:shadow-pete-lg transition">
Sign in and sit down
</a>
<span class="text-sm text-[color:var(--ink)]/45">You'll need a parodia.dev account.</span>
</div>
</div>
</div>
</section>
<section>
<h2 class="font-display text-2xl font-bold mb-4">The tables</h2>
<div class="grid gap-4 sm:grid-cols-2">
{{template "_door_table" dict "Href" "/games/blackjack" "Emoji" "🃏" "Name" "Blackjack" "Line" "Six decks, blackjack pays 3:2."}}
{{template "_door_table" dict "Href" "/games/holdem" "Emoji" "♠️" "Name" "Texas hold'em" "Line" "A cash game against bots that were trained on it."}}
{{template "_door_table" dict "Href" "/games/uno" "Emoji" "🎴" "Name" "UNO" "Line" "Three tables, and a No Mercy deck that bites."}}
{{template "_door_table" dict "Href" "/games/trivia" "Emoji" "🧠" "Name" "Trivia" "Line" "A ladder. Climb it or bank it."}}
{{template "_door_table" dict "Href" "/games/hangman" "Emoji" "🔤" "Name" "Hangman" "Line" "Longer words pay more, for the obvious reason."}}
{{template "_door_table" dict "Href" "/games/solitaire" "Emoji" "🂡" "Name" "Solitaire" "Line" "Klondike, and the deal you pick sets the price."}}
</div>
</section>
<p class="pb-2 text-center text-sm text-[color:var(--ink)]/40">
{{if eq .Room.Slug "casinopolis"}}The lights come on at six, and the place changes its name.
{{else}}It goes back to being Casinopolis at six in the morning.{{end}}
</p>
</div>
{{end}}
{{define "_door_table"}}
<a href="{{.Href}}"
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">{{.Emoji}}</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold">{{.Name}}</h3>
<p class="text-sm text-[color:var(--ink)]/60">{{.Line}}</p>
</div>
</div>
</a>
{{end}}

View File

@@ -5,6 +5,23 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}{{.Room.Name}}{{end}}</title> <title>{{block "title" .}}{{.Room.Name}}{{end}}</title>
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
{{/* What the casino looks like when somebody pastes it into a chat window.
noindex keeps it out of a search engine and does not keep it out of a link
preview: those are two different bots and only one of them is reading this.
The image is drawn by games_og.go and follows the clock, so a link shared
after six unfurls with the neon on. */}}
<meta property="og:type" content="website">
<meta property="og:site_name" content="{{.Room.Name}}">
<meta property="og:title" content="{{block "og_title" .}}{{.Room.Name}}{{end}}">
<meta property="og:description" content="{{block "og_desc" .}}Blackjack, hold'em, UNO, trivia, hangman and solitaire, played for real gogobee euros. Sign in and pull up a chair.{{end}}">
<meta property="og:url" content="{{.URL}}">
<meta property="og:image" content="{{.OGImage}}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{{.Room.Name}}: two cards and a stack of chips on the felt.">
<meta name="twitter:card" content="summary_large_image">
<meta name="description" content="Blackjack, hold'em, UNO, trivia, hangman and solitaire, played for real gogobee euros.">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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 href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
@@ -61,6 +78,12 @@
<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="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> <span class="hidden sm:inline max-w-[7rem] truncate">{{.User.Display}}</span>
</a> </a>
{{else}}
{{/* Nobody is signed in, which on any page but the front door can't happen. */}}
<a href="/auth/login?next=/games"
class="inline-flex shrink-0 items-center rounded-full bg-[color:var(--accent)] px-4 py-1.5 text-sm font-bold text-[#20180c] shadow-pete hover:-translate-y-0.5 transition">
Sign in
</a>
{{end}} {{end}}
</div> </div>
</header> </header>