From 7ca1f7a030b7d55138a71d768956bad9dba504f9 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:30:52 -0700 Subject: [PATCH] games: the door you can see from outside, and the picture on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../web/assets/fonts/Fredoka-SemiBold.ttf | Bin 0 -> 48768 bytes internal/web/assets/fonts/LICENSE.txt | 8 + internal/web/games_og.go | 438 ++++++++++++++++++ internal/web/games_og_test.go | 166 +++++++ internal/web/games_pages.go | 50 +- internal/web/server.go | 2 +- internal/web/templates/games_door.html | 70 +++ internal/web/templates/games_layout.html | 23 + 8 files changed, 754 insertions(+), 3 deletions(-) create mode 100644 internal/web/assets/fonts/Fredoka-SemiBold.ttf create mode 100644 internal/web/assets/fonts/LICENSE.txt create mode 100644 internal/web/games_og.go create mode 100644 internal/web/games_og_test.go create mode 100644 internal/web/templates/games_door.html diff --git a/internal/web/assets/fonts/Fredoka-SemiBold.ttf b/internal/web/assets/fonts/Fredoka-SemiBold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e260be190f8fab654f514df71f7ab6d5afc0943b GIT binary patch literal 48768 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 WS;{%|M2(WN^k_efd2(`DZCp1 literal 0 HcmV?d00001 diff --git a/internal/web/assets/fonts/LICENSE.txt b/internal/web/assets/fonts/LICENSE.txt new file mode 100644 index 0000000..d03b78a --- /dev/null +++ b/internal/web/assets/fonts/LICENSE.txt @@ -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. diff --git a/internal/web/games_og.go b/internal/web/games_og.go new file mode 100644 index 0000000..eaabf3b --- /dev/null +++ b/internal/web/games_og.go @@ -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) +} diff --git a/internal/web/games_og_test.go b/internal/web/games_og_test.go new file mode 100644 index 0000000..c881c7c --- /dev/null +++ b/internal/web/games_og_test.go @@ -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)) + } +} diff --git a/internal/web/games_pages.go b/internal/web/games_pages.go index 0018bce..a0149fd 100644 --- a/internal/web/games_pages.go +++ b/internal/web/games_pages.go @@ -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)) diff --git a/internal/web/server.go b/internal/web/server.go index 4d55626..532a903 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -95,7 +95,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo pages []string }{ {"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) for _, set := range sets { diff --git a/internal/web/templates/games_door.html b/internal/web/templates/games_door.html new file mode 100644 index 0000000..e138826 --- /dev/null +++ b/internal/web/templates/games_door.html @@ -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"}} +
+ +
+ {{/* 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. */}} + +
+ +
+

The tables

+
+ {{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."}} +
+
+ +

+ {{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}} +

+
+{{end}} + +{{define "_door_table"}} + +
+ {{.Emoji}} +
+

{{.Name}}

+

{{.Line}}

+
+
+
+{{end}} diff --git a/internal/web/templates/games_layout.html b/internal/web/templates/games_layout.html index 097cfaf..be054b4 100644 --- a/internal/web/templates/games_layout.html +++ b/internal/web/templates/games_layout.html @@ -5,6 +5,23 @@ {{block "title" .}}{{.Room.Name}}{{end}} + + {{/* 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. */}} + + + + + + + + + + + @@ -61,6 +78,12 @@ {{.User.Initial}} + {{else}} + {{/* Nobody is signed in, which on any page but the front door can't happen. */}} + + Sign in + {{end}}