Bust every hand and the dealer doesn't draw, which is right, but it was also not turning over: reveal is only emitted by dealerPlay, and busting out skips the dealer entirely. The browser kept the hole card face down while the settled state printed the dealer's whole total under it. Emit the reveal on that path. And standing your bet back up after a reload read the hand's bet straight off the settled state, which a double has already doubled. Reload, double 200, and the next hand starts with 400 on the spot. Plus: the share card was hand-writing a Content-Length that ServeContent overwrites anyway, and serving a zero-byte 200 for a room with no card. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
445 lines
15 KiB
Go
445 lines
15 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
_ "embed"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/png"
|
|
"log/slog"
|
|
"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() })
|
|
room := roomAt(time.Now().Hour())
|
|
card := ogCards[room.Slug]
|
|
if ogErr != nil || len(card) == 0 {
|
|
// A room with no card is a room somebody added to roomAt and not to
|
|
// ogPalettes. Say so rather than serving a zero-byte PNG, which an unfurler
|
|
// will happily cache as the casino's face.
|
|
slog.Error("games: no share card", "room", room.Slug, "err", ogErr)
|
|
http.Error(w, "share card unavailable", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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")
|
|
// ServeContent sets Content-Length itself, and gets it right for a range
|
|
// request, which a hand-written one would not.
|
|
http.ServeContent(w, r, "og.png", time.Time{}, bytes.NewReader(card))
|
|
}
|
|
|
|
// 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
|
|
}
|
|
defer title.Close()
|
|
line, err := face(f, 33)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer line.Close()
|
|
small, err := face(f, 25)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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)
|
|
}
|