diff --git a/internal/web/assets/fonts/Fredoka-SemiBold.ttf b/internal/web/assets/fonts/Fredoka-SemiBold.ttf new file mode 100644 index 0000000..e260be1 Binary files /dev/null and b/internal/web/assets/fonts/Fredoka-SemiBold.ttf differ 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 doors are open

+

{{.Room.Name}}

+

+ 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. +

+
+ + Sign in and sit down + + You'll need a parodia.dev account. +
+
+ +
+
+ +
+

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