Compare commits
107 Commits
ec1f130ed5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
790a118273 | ||
|
|
8df2212aad | ||
|
|
1ca794ea1a | ||
|
|
30b0e8debb | ||
|
|
4189c03a82 | ||
|
|
16711e13e6 | ||
|
|
2ac6ec6b91 | ||
|
|
983748ea98 | ||
|
|
e5af5326d5 | ||
|
|
dbde827f75 | ||
|
|
18049f6f59 | ||
|
|
927ed84163 | ||
|
|
f8b07d8e6c | ||
|
|
4ad96dcb5e | ||
|
|
5139385350 | ||
|
|
5b381b03ff | ||
|
|
004fca3f25 | ||
|
|
4b3e5fe4c5 | ||
|
|
1f1a6cb6e8 | ||
|
|
a5b7e41929 | ||
|
|
57c445ff29 | ||
|
|
6f34a89622 | ||
|
|
7ca1f7a030 | ||
|
|
39ed293f4f | ||
|
|
a4666866a8 | ||
|
|
03524aefbc | ||
|
|
8db8845feb | ||
|
|
aca523e511 | ||
|
|
4bc38859d4 | ||
|
|
638e28263a | ||
|
|
b96879d25c | ||
|
|
903c5accdb | ||
|
|
e6c1bd3b54 | ||
|
|
6e20883e5d | ||
|
|
d7e63d86a6 | ||
|
|
79c857023f | ||
|
|
3e9b93af55 | ||
|
|
2d653bf439 | ||
|
|
c62d736223 | ||
|
|
feb353f789 | ||
|
|
5ca056bf20 | ||
|
|
fe2195e85f | ||
|
|
d29a311eff | ||
|
|
6961f90634 | ||
|
|
b00da21a47 | ||
|
|
8ec13eab5b | ||
|
|
c69fbb63db | ||
|
|
cb84e1d549 | ||
|
|
44613c4760 | ||
|
|
a442cfccaa | ||
|
|
f9a98f72a6 | ||
|
|
8310b30439 | ||
|
|
6ccd18452c | ||
|
|
e85ebe56f7 | ||
|
|
99574db3e9 | ||
|
|
8cb5b38599 | ||
|
|
a614077cff | ||
|
|
82d1c6ebeb | ||
|
|
10bcc78c51 | ||
|
|
8e0d6aff3e | ||
|
|
9bf56cbb4e | ||
|
|
4c671fb410 | ||
|
|
0a723418ff | ||
|
|
9b20040b49 | ||
|
|
e91b423b1a | ||
|
|
dbcb459908 | ||
|
|
fceeb12ad5 | ||
|
|
74aa578a2d | ||
|
|
8f9fcc45f3 | ||
|
|
616055a704 | ||
|
|
2ea5f7a6f7 | ||
|
|
ff3a0e87be | ||
|
|
77581ac152 | ||
|
|
35850eaf73 | ||
|
|
8863b75916 | ||
|
|
71f7050f41 | ||
|
|
55aa167151 | ||
|
|
410f8dda0a | ||
|
|
e65ffb1373 | ||
|
|
aaf6e551a0 | ||
|
|
4bdf9a7615 | ||
|
|
d5d0656dba | ||
|
|
cbbedd9894 | ||
|
|
1a43616248 | ||
|
|
95f6e71933 | ||
|
|
7b76f9ed23 | ||
|
|
0b94ce7fe5 | ||
|
|
8aceb259ca | ||
|
|
fc4cab3ad6 | ||
|
|
3e29acaa23 | ||
|
|
78fc3ef811 | ||
|
|
7d469cf8c5 | ||
|
|
59658e7ebb | ||
|
|
b617d403b7 | ||
|
|
a15025089d | ||
|
|
92d700f2bb | ||
|
|
f52f26ff8d | ||
|
|
58493006e1 | ||
|
|
04a3c41bed | ||
|
|
b3002c8e12 | ||
|
|
70e1dfc2b8 | ||
|
|
83b1216541 | ||
|
|
dd99f2c7ab | ||
|
|
9e5ba5aafc | ||
|
|
e428f37c9e | ||
|
|
b8dcaa2aa1 | ||
|
|
71dc6d77ab |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ pete
|
||||
config.yaml
|
||||
config.toml
|
||||
node_modules/
|
||||
holdem-train
|
||||
|
||||
22
README.md
22
README.md
@@ -14,21 +14,29 @@ A Matrix news bot that ingests RSS feeds from curated sources and routes each st
|
||||
- **Paywall detection** — if an article's visible body text is below threshold, Pete swaps in a Wayback Machine snapshot URL for both the lead image and the posted link
|
||||
- **FTS5 search** — full-text search across headlines and ledes
|
||||
- **Image validation** — HEAD-based checks filter tracking pixels, uploads valid images via MXC
|
||||
- **Web UI** — read-only browser interface (Tailwind, Animal-Crossing-ish vibe, day/night palette that follows the visitor's clock) at `/`, `/gaming`, `/tech`, `/politics`. Designed for a `news.parodia.dev`-style deployment
|
||||
- **Video thumbnails** — extracts a frame via `ffmpeg` for video sources; also falls back to `ffmpeg` when Go's stdlib JPEG decoder rejects a source image
|
||||
- **Seasonal weather** — canvas-based ambient overlay (snow, leaves, rain, etc.) driven by a Portugal/Lisbon calendar; visitors can toggle it with the ⭐ star button in the nav
|
||||
- **Web UI** — read-only browser interface (Tailwind, Animal-Crossing-ish vibe, day/night palette that follows the visitor's clock) at `/` plus a section per channel. Designed for a `news.parodia.dev`-style deployment
|
||||
|
||||
## Channels
|
||||
|
||||
| Channel | Purpose |
|
||||
|---|---|
|
||||
| `gaming` | Gaming news, releases, platform announcements |
|
||||
| `tech` | Technology news, product/industry stories |
|
||||
| `politics` | Political, policy, current events |
|
||||
| `gaming` | Gaming news, releases, platform announcements |
|
||||
| `eu` | Portugal and the wider European beat (web-only — does not post to Matrix) |
|
||||
| `music` | Records, scenes, artists |
|
||||
| `anime` | Series, studios, manga |
|
||||
| `foss` | Kernel, distros, free/open source |
|
||||
| `kids` | World news written for younger readers (web-only) |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.25+
|
||||
- A Matrix account for Pete with access to target rooms
|
||||
- SQLite (bundled via pure Go driver, no CGo)
|
||||
- `ffmpeg` on `PATH` — used for video-frame thumbnails and as a fallback decoder for JPEGs the Go stdlib rejects
|
||||
- No CGo dependencies — E2EE uses pure Go crypto (goolm) via mautrix v0.28
|
||||
|
||||
## Setup
|
||||
@@ -39,7 +47,7 @@ git clone <repo-url> && cd pete
|
||||
go build -tags goolm .
|
||||
|
||||
# Create config from example
|
||||
cp config.example.yaml config.toml
|
||||
cp config.example.toml config.toml
|
||||
# Edit config.toml — fill in Matrix credentials, room IDs, and a direct_route for every source
|
||||
|
||||
# First run: seed current feed items as seen (prevents flood)
|
||||
@@ -54,19 +62,19 @@ cp config.example.yaml config.toml
|
||||
|
||||
## Configuration
|
||||
|
||||
See `config.example.yaml` for the full structure. Key sections:
|
||||
See `config.example.toml` for the full structure. Key sections:
|
||||
|
||||
- `matrix` — homeserver, credentials, channel room IDs, optional admin room
|
||||
- `posting` — rate limiting (min interval, burst cap, daily cap), optional `round_robin` block
|
||||
- `storage` — database path, retention windows
|
||||
- `sources` — RSS feeds with tier, polling interval, and **required** `direct_route` (must match a key in `matrix.channels`)
|
||||
- `sources` — RSS feeds with tier, polling interval, and **required** `direct_route` (must match a key in `matrix.channels`). Optional `language = "en"` drops items whose per-item `<language>` tag doesn't prefix-match — handy for multilingual feeds like Politico Europe
|
||||
- `web` — read-only HTTP UI (enabled toggle, listen address, site title, public base URL)
|
||||
|
||||
Environment variables can be referenced with `${VAR}` syntax in the TOML.
|
||||
|
||||
### Web UI
|
||||
|
||||
Set `web.enabled: true` (default port `:8080`) to expose Pete's classified-story archive over HTTP. Three sections (`/gaming`, `/tech`, `/politics`) plus a landing page at `/`, all pulling from the `stories` table. Adding a section is two lines in `internal/web/server.go` (the `channels` slice) plus a matching theme color in `internal/web/static/css/input.css`.
|
||||
Set `web.enabled: true` (default port `:8080`) to expose Pete's classified-story archive over HTTP. A landing page at `/` plus one section per channel (`/gaming`, `/tech`, `/politics`, `/eu`, `/music`, `/anime`, `/foss`, `/kids`), all pulling from the `stories` table. A `/weather` page demos the seasonal canvas overlay in isolation. Adding a section is two lines in `internal/web/server.go` (the `channels` slice) plus a matching theme color in `internal/web/static/css/input.css`.
|
||||
|
||||
The frontend uses a small Tailwind build:
|
||||
|
||||
@@ -116,7 +124,7 @@ RSS Feed → Poller → GUID/Canonical/Headline Dedup → Article Fetch → Stor
|
||||
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `internal/config` | YAML config loading with `${ENV}` expansion |
|
||||
| `internal/config` | TOML config loading with `${ENV}` expansion |
|
||||
| `internal/storage` | SQLite with WAL, FTS5, all queries |
|
||||
| `internal/ingestion` | Per-source RSS polling, feed parsing, image validation, paywall detection |
|
||||
| `internal/dedup` | Canonical URL + headline normalization helpers |
|
||||
|
||||
89
cmd/holdem-train/main.go
Normal file
89
cmd/holdem-train/main.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Command holdem-train trains the casino's poker bots and writes the policy the
|
||||
// table embeds.
|
||||
//
|
||||
// go run ./cmd/holdem-train -iterations 5000000 -out internal/games/holdem/policy.gob
|
||||
//
|
||||
// It is not part of Pete. It runs when the engine's rules change, takes half an
|
||||
// hour, and produces a file. Nothing at runtime imports it.
|
||||
//
|
||||
// The bots are trained heads-up, at every stack depth the table deals — that
|
||||
// range is the point, because poker at twenty big blinds and poker at a hundred
|
||||
// are different games and a bot that only knows one of them folds into the other.
|
||||
// A six-handed table then reuses the same policy, which is an approximation, and
|
||||
// a documented one.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/holdem"
|
||||
)
|
||||
|
||||
func main() {
|
||||
iterations := flag.Int("iterations", 5_000_000, "hands to train on")
|
||||
workers := flag.Int("workers", runtime.NumCPU(), "parallel workers")
|
||||
out := flag.String("out", "internal/games/holdem/policy.gob", "where to write the policy")
|
||||
stakes := flag.String("stakes", "low", "which table's blinds to train at")
|
||||
minBB := flag.Int64("min-bb", 20, "shallowest stack, in big blinds")
|
||||
maxBB := flag.Int64("max-bb", 100, "deepest stack, in big blinds")
|
||||
seed := flag.Uint64("seed", 20260714, "seed, so a run can be repeated")
|
||||
flag.Parse()
|
||||
|
||||
tier, err := holdem.TierBySlug(*stakes)
|
||||
if err != nil {
|
||||
log.Fatalf("no such table %q", *stakes)
|
||||
}
|
||||
|
||||
fmt.Printf("training %s hands on %d workers — %s blinds, %d–%d BB deep\n",
|
||||
commas(*iterations), *workers, tier.Name, *minBB, *maxBB)
|
||||
|
||||
started := time.Now()
|
||||
last := started
|
||||
policy := holdem.Train(*iterations, *workers, tier, *minBB, *maxBB, *seed, func(done int) {
|
||||
if time.Since(last) < 20*time.Second {
|
||||
return
|
||||
}
|
||||
last = time.Now()
|
||||
frac := float64(done) / float64(*iterations)
|
||||
if frac <= 0 {
|
||||
return
|
||||
}
|
||||
left := time.Duration(float64(time.Since(started)) * (1 - frac) / frac)
|
||||
fmt.Printf(" %s / %s hands (%.0f%%), about %s to go\n",
|
||||
commas(done), commas(*iterations), frac*100, left.Round(time.Second))
|
||||
})
|
||||
|
||||
f, err := os.Create(*out)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := holdem.Save(f, policy); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
info, _ := f.Stat()
|
||||
fmt.Printf("\ndone in %s: %s nodes, %.1f MB → %s\n",
|
||||
time.Since(started).Round(time.Second), commas(policy.Meta.Nodes),
|
||||
float64(info.Size())/(1<<20), *out)
|
||||
}
|
||||
|
||||
func commas(n int) string {
|
||||
s := fmt.Sprint(n)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
var out []byte
|
||||
for i, c := range []byte(s) {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
out = append(out, ',')
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -6,13 +6,18 @@ pickle_key = "${PETE_PICKLE_KEY}"
|
||||
display_name = "Pete"
|
||||
data_dir = "./data"
|
||||
admin_room = "!adminroomid:matrix.example.org"
|
||||
# Matrix user IDs allowed to run in-room commands like !post. Empty disables
|
||||
# commands entirely (channel rooms may be public, so empty != "anyone").
|
||||
admins = ["@you:matrix.example.org"]
|
||||
|
||||
[matrix.channels]
|
||||
tech = "!techroomid:matrix.example.org"
|
||||
politics = "!politicsroomid:matrix.example.org"
|
||||
gaming = "!gamingroomid:matrix.example.org"
|
||||
lego = "!legoroomid:matrix.example.org"
|
||||
|
||||
[posting]
|
||||
enabled = true # master switch for auto-posting news to Matrix; false = web-only (commands still work)
|
||||
min_interval_seconds = 300
|
||||
burst_cap_count = 3
|
||||
burst_cap_window_seconds = 1800
|
||||
@@ -32,8 +37,87 @@ listen_addr = ":8080"
|
||||
site_title = "Pete"
|
||||
base_url = "https://news.parodia.dev"
|
||||
|
||||
# OIDC subjects allowed to view the owner-facing source-health dashboard at
|
||||
# /status (per-feed poll status, failures, content stats). Requires web.auth
|
||||
# below. Empty = /status returns 404 for everyone. Find a user's subject in the
|
||||
# server logs ("auth: user signed in" sub=...) after they sign in once.
|
||||
admin_subs = []
|
||||
|
||||
# Optional OIDC sign-in (Authentik). When enabled, signed-in users get their
|
||||
# preferences (hidden feeds, weather location, toggles) stored server-side keyed
|
||||
# by their OIDC subject and synced across devices. Anonymous visitors keep using
|
||||
# browser localStorage — the site stays public. If the provider is unreachable
|
||||
# at startup, Pete logs a warning and serves anonymously rather than refusing to
|
||||
# boot. Create an OAuth2/OIDC provider + application in Authentik, set the
|
||||
# redirect URI to <base_url>/auth/callback, then fill these in.
|
||||
[web.auth]
|
||||
enabled = false
|
||||
issuer = "https://authentik.parodia.dev/application/o/pete/"
|
||||
client_id = "${PETE_OIDC_CLIENT_ID}"
|
||||
client_secret = "${PETE_OIDC_CLIENT_SECRET}"
|
||||
redirect_url = "https://news.parodia.dev/auth/callback"
|
||||
# HMAC key that signs the session cookie. Generate with: openssl rand -hex 32
|
||||
session_secret = "${PETE_SESSION_SECRET}"
|
||||
# Share the session across sibling hosts, so signing in on news.parodia.dev also
|
||||
# signs you in on games.parodia.dev. This widens the cookie to every host under
|
||||
# the domain, so leave it empty to keep the session host-only. Each host that
|
||||
# starts a login also needs its own redirect URI registered in Authentik
|
||||
# (<host>/auth/callback) — the login round-trip returns to the host it began on.
|
||||
cookie_domain = ""
|
||||
|
||||
# Optional Web Push digests. When enabled, signed-in users can opt in (from the
|
||||
# feed-settings panel) to a periodic "N new stories" notification, delivered via
|
||||
# a service worker so it also works when the site is installed as a PWA. Push is
|
||||
# signed-in only, so it needs web.auth above; it does nothing otherwise.
|
||||
# Generate the VAPID keypair once with: pete -genvapid
|
||||
# then paste the two keys here (the private key is a secret — treat it like a
|
||||
# password). subject identifies you to the push service (a mailto: or https: URL).
|
||||
[web.push]
|
||||
enabled = false
|
||||
vapid_public_key = "${PETE_VAPID_PUBLIC_KEY}"
|
||||
vapid_private_key = "${PETE_VAPID_PRIVATE_KEY}"
|
||||
subject = "mailto:admin@parodia.dev"
|
||||
# How often the sender wakes to look for new stories per subscriber (minutes).
|
||||
interval_minutes = 360
|
||||
# Smallest number of new (non-hidden) stories that triggers a digest, so a lone
|
||||
# item doesn't ping everyone.
|
||||
min_stories = 3
|
||||
|
||||
# Server-side neural read-aloud (Piper, https://github.com/rhasspy/piper).
|
||||
# When enabled, the reader's "Listen" button streams real Piper voices instead
|
||||
# of the browser's robotic Web Speech voice. Signed-in only, so it needs
|
||||
# [web.auth] on too. Install the piper binary and one or more voice models
|
||||
# (<id>.onnx + <id>.onnx.json) into voices_dir first.
|
||||
[web.tts]
|
||||
enabled = false
|
||||
piper_bin = "/opt/piper/piper" # path to the piper executable
|
||||
voices_dir = "/opt/piper/voices" # dir holding <id>.onnx (+ .onnx.json) models
|
||||
default = "en_US-amy-medium" # voice id selected until the reader picks another
|
||||
# List the voices to offer, in menu order. Omit the whole [[web.tts.voices]]
|
||||
# list to auto-discover every *.onnx in voices_dir (labelled by filename).
|
||||
[[web.tts.voices]]
|
||||
id = "en_US-amy-medium"
|
||||
label = "Amy (US, female)"
|
||||
[[web.tts.voices]]
|
||||
id = "en_US-ryan-high"
|
||||
label = "Ryan (US, male, HQ)"
|
||||
|
||||
# The casino (games.parodia.dev). Signed-in only — there is real money in it — so
|
||||
# it needs [web.auth] above, and web.auth.cookie_domain if the games host is a
|
||||
# different subdomain from the news one. Chips are 1:1 with gogobee euros and
|
||||
# cross the border through the escrow endpoints, which gogobee polls; matrix_server
|
||||
# is how a player is named to that ledger (@<authentik username>:<matrix_server>).
|
||||
[web.games]
|
||||
enabled = false
|
||||
host = "games.parodia.dev"
|
||||
matrix_server = "parodia.dev"
|
||||
|
||||
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
|
||||
# There is no automatic classification — Pete posts each story to its configured channel.
|
||||
# Optional: language = "en" drops feed items whose per-item <language> tag is
|
||||
# set and doesn't match (prefix). Useful for multilingual feeds like Politico
|
||||
# Europe that publish the same story in en / fr / de side-by-side. Items with
|
||||
# no language tag pass through unchanged.
|
||||
[[sources]]
|
||||
name = "The Guardian — World"
|
||||
feed_url = "https://www.theguardian.com/world/rss"
|
||||
@@ -41,6 +125,9 @@ tier = 1
|
||||
poll_interval_minutes = 20
|
||||
direct_route = "politics"
|
||||
enabled = true
|
||||
# Optional: override the User-Agent for this feed only. Leave unset to use
|
||||
# Pete's honest bot UA; set a browser string for feeds whose WAF blocks bots.
|
||||
# user_agent = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
|
||||
|
||||
[[sources]]
|
||||
name = "The Guardian — Politics"
|
||||
@@ -73,3 +160,131 @@ tier = 1
|
||||
poll_interval_minutes = 20
|
||||
direct_route = "gaming"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "Pitchfork"
|
||||
feed_url = "https://pitchfork.com/feed/feed-news/rss"
|
||||
tier = 1
|
||||
poll_interval_minutes = 60
|
||||
direct_route = "music"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "The Quietus"
|
||||
feed_url = "https://thequietus.com/feed"
|
||||
tier = 1
|
||||
poll_interval_minutes = 60
|
||||
direct_route = "music"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "Consequence"
|
||||
feed_url = "https://consequence.net/feed/"
|
||||
tier = 1
|
||||
poll_interval_minutes = 60
|
||||
direct_route = "music"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "Brooklyn Vegan"
|
||||
feed_url = "https://www.brooklynvegan.com/feed/"
|
||||
tier = 1
|
||||
poll_interval_minutes = 60
|
||||
direct_route = "music"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "NME — Music"
|
||||
feed_url = "https://www.nme.com/news/music/feed"
|
||||
tier = 1
|
||||
poll_interval_minutes = 60
|
||||
direct_route = "music"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "BBC Newsround"
|
||||
feed_url = "http://feeds.bbci.co.uk/newsround/rss.xml"
|
||||
tier = 1
|
||||
poll_interval_minutes = 30
|
||||
direct_route = "kids"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "DOGO News"
|
||||
feed_url = "https://www.dogonews.com/articles.rss"
|
||||
tier = 1
|
||||
poll_interval_minutes = 60
|
||||
direct_route = "kids"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "Science News Explores"
|
||||
feed_url = "https://www.snexplores.org/feed"
|
||||
tier = 1
|
||||
poll_interval_minutes = 60
|
||||
direct_route = "kids"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "NASA for Students"
|
||||
feed_url = "https://www.nasa.gov/rss/dyn/educationnews.rss"
|
||||
tier = 1
|
||||
poll_interval_minutes = 60
|
||||
direct_route = "kids"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "Post-Punk.com"
|
||||
feed_url = "https://post-punk.com/feed/"
|
||||
tier = 1
|
||||
poll_interval_minutes = 60
|
||||
direct_route = "music"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "Naked Capitalism"
|
||||
feed_url = "https://www.nakedcapitalism.com/feed"
|
||||
tier = 1
|
||||
poll_interval_minutes = 60
|
||||
direct_route = "finance"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "Wolf Street"
|
||||
feed_url = "https://wolfstreet.com/feed/"
|
||||
tier = 1
|
||||
poll_interval_minutes = 60
|
||||
direct_route = "finance"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "MarketBeat"
|
||||
feed_url = "https://www.marketbeat.com/feed/"
|
||||
tier = 1
|
||||
poll_interval_minutes = 60
|
||||
direct_route = "finance"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "Seeking Alpha"
|
||||
feed_url = "https://seekingalpha.com/feed.xml"
|
||||
tier = 2
|
||||
poll_interval_minutes = 60
|
||||
direct_route = "finance"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "Brickset"
|
||||
feed_url = "https://brickset.com/feed"
|
||||
tier = 1
|
||||
poll_interval_minutes = 60
|
||||
direct_route = "lego"
|
||||
enabled = true
|
||||
|
||||
[[sources]]
|
||||
name = "The Brick Fan"
|
||||
feed_url = "https://www.thebrickfan.com/feed/"
|
||||
tier = 1
|
||||
poll_interval_minutes = 60
|
||||
direct_route = "lego"
|
||||
enabled = true
|
||||
|
||||
10
go.mod
10
go.mod
@@ -5,7 +5,12 @@ go 1.25.0
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/coreos/go-oidc/v3 v3.19.0
|
||||
github.com/mmcdole/gofeed v1.3.0
|
||||
go.mau.fi/util v0.9.9
|
||||
golang.org/x/image v0.41.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
maunium.net/go/mautrix v0.28.0
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
@@ -13,7 +18,10 @@ require (
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/chehsunliu/poker v0.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
@@ -30,10 +38,8 @@ require (
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
go.mau.fi/util v0.9.9 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
||||
golang.org/x/image v0.41.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
|
||||
17
go.sum
17
go.sum
@@ -6,13 +6,23 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/chehsunliu/poker v0.1.0 h1:OeB4O+QROhA/DiXUhBBlkgbzCx0ZVWMpWgKNu+PX9vI=
|
||||
github.com/chehsunliu/poker v0.1.0/go.mod h1:V6K4yyDbafp0k6lUnYbwoTS/KsHSB1EWiJdEk54uB1w=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
@@ -23,6 +33,7 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/loganjspears/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:76SAnflG7ZFhgtnaVCpP6A5Z1S/VMFzRBN7KGm5j4oc=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
@@ -40,6 +51,7 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:L0Sdr2nYdktjerdXpIn9wOCn+GebPs/nCL2qH6RTGa0=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -50,6 +62,7 @@ github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
@@ -96,6 +109,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -147,6 +162,8 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
|
||||
|
||||
@@ -13,19 +13,135 @@ import (
|
||||
var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`)
|
||||
|
||||
type Config struct {
|
||||
Matrix MatrixConfig `toml:"matrix"`
|
||||
Posting PostingConfig `toml:"posting"`
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Web WebConfig `toml:"web"`
|
||||
Sources []SourceConfig `toml:"sources"`
|
||||
Matrix MatrixConfig `toml:"matrix"`
|
||||
Posting PostingConfig `toml:"posting"`
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Web WebConfig `toml:"web"`
|
||||
Adventure AdventureConfig `toml:"adventure"`
|
||||
Sources []SourceConfig `toml:"sources"`
|
||||
}
|
||||
|
||||
// AdventureConfig wires the gogobee adventure-news seam: gogobee POSTs
|
||||
// game-event facts to Pete's ingest endpoint, Pete templates them into stories
|
||||
// on the /adventure section and posts PRIORITY beats live to Matrix. Disabled by
|
||||
// default — the endpoint 404s and no adventure channel appears until enabled.
|
||||
type AdventureConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// IngestToken is the shared bearer secret gogobee presents on
|
||||
// POST /api/ingest/adventure. Required when enabled. Use ${ENV_VAR}.
|
||||
IngestToken string `toml:"ingest_token"`
|
||||
// Channel is the Matrix channel name (a key in [matrix.channels]) that
|
||||
// PRIORITY adventure beats post to live. If it isn't a configured Matrix
|
||||
// channel, adventure runs website-only (stories still appear on /adventure).
|
||||
Channel string `toml:"channel"`
|
||||
// DigestHour is the UTC hour [0,23] the daily bulletin digest posts. Default
|
||||
// 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from
|
||||
// "unset" and doesn't get silently rewritten to the default.
|
||||
DigestHour *int `toml:"digest_hour"`
|
||||
}
|
||||
|
||||
// DigestHourOrDefault is the UTC hour the daily digest posts, resolving the
|
||||
// unset case. Safe on a zero-value AdventureConfig.
|
||||
func (a AdventureConfig) DigestHourOrDefault() int {
|
||||
if a.DigestHour == nil {
|
||||
return defaultDigestHour
|
||||
}
|
||||
return *a.DigestHour
|
||||
}
|
||||
|
||||
const defaultDigestHour = 17
|
||||
|
||||
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
|
||||
type WebConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
ListenAddr string `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080"
|
||||
SiteTitle string `toml:"site_title"` // display name in the header
|
||||
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
||||
Enabled bool `toml:"enabled"`
|
||||
ListenAddr string `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080"
|
||||
SiteTitle string `toml:"site_title"` // display name in the header
|
||||
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
||||
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
||||
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
|
||||
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
|
||||
Games GamesConfig `toml:"games"` // optional casino (games.parodia.dev)
|
||||
// AdminSubs is the allowlist of OIDC subjects allowed to view the
|
||||
// owner-facing source-health dashboard at /status. Empty means the page is
|
||||
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
||||
AdminSubs []string `toml:"admin_subs"`
|
||||
}
|
||||
|
||||
// PushConfig wires the Web Push digest sender. Push is signed-in only (it keys
|
||||
// off the OIDC subject) so it does nothing unless auth is also enabled. Generate
|
||||
// a VAPID keypair once with `pete -genvapid` and paste the two keys here.
|
||||
type PushConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
VAPIDPublicKey string `toml:"vapid_public_key"`
|
||||
VAPIDPrivateKey string `toml:"vapid_private_key"`
|
||||
// Subject identifies the sender to the push service; a mailto: or https: URL
|
||||
// per RFC 8292. Defaults to mailto:admin@<base_url host> is not attempted —
|
||||
// set it explicitly.
|
||||
Subject string `toml:"subject"`
|
||||
// IntervalMinutes is how often the digest sender wakes to look for new
|
||||
// stories per subscriber. Defaults to 360 (6h).
|
||||
IntervalMinutes int `toml:"interval_minutes"`
|
||||
// MinStories is the smallest number of new stories that triggers a digest
|
||||
// for a subscriber, so they aren't pinged for a single item. Defaults to 3.
|
||||
MinStories int `toml:"min_stories"`
|
||||
}
|
||||
|
||||
// TTSConfig wires server-side neural read-aloud (Piper). When enabled,
|
||||
// signed-in users get the reader's "Listen" button backed by real Piper voices
|
||||
// instead of the browser's robotic Web Speech voice. Read-aloud is a signed-in
|
||||
// perk, so this does nothing unless auth is also enabled.
|
||||
type TTSConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
PiperBin string `toml:"piper_bin"` // path to the piper executable
|
||||
VoicesDir string `toml:"voices_dir"` // directory holding <id>.onnx (+ .onnx.json) models
|
||||
// Voices lists the voices to offer, in menu order. Each id is a model
|
||||
// filename stem, so id "en_US-ryan-high" maps to <voices_dir>/en_US-ryan-high.onnx.
|
||||
// Leave empty to auto-discover every *.onnx in voices_dir.
|
||||
Voices []VoiceConfig `toml:"voices"`
|
||||
// Default is the voice id selected until the reader picks another. Empty
|
||||
// falls back to the first available voice.
|
||||
Default string `toml:"default"`
|
||||
}
|
||||
|
||||
// VoiceConfig is one selectable Piper voice.
|
||||
type VoiceConfig struct {
|
||||
ID string `toml:"id"` // model filename stem, e.g. "en_US-ryan-high"
|
||||
Label string `toml:"label"` // menu label, e.g. "Ryan — US, male"
|
||||
}
|
||||
|
||||
// GamesConfig wires the casino. It is signed-in only — there is money in it —
|
||||
// so it does nothing without web.auth, and it needs the Matrix server name
|
||||
// because that is how a player's identity reaches gogobee's euro ledger: an
|
||||
// Authentik username is the Matrix localpart, and the Matrix id is the account.
|
||||
type GamesConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// Host is the public hostname the casino answers on, e.g.
|
||||
// "games.parodia.dev". Requests arriving on it are served the casino at "/";
|
||||
// everywhere else the same pages live under /games. Empty means no host
|
||||
// branching, which is the normal state of affairs in local development.
|
||||
Host string `toml:"host"`
|
||||
// MatrixServer is the server name half of a player's Matrix id
|
||||
// ("parodia.dev" -> @reala:parodia.dev). Without it, no player can be
|
||||
// identified in the economy and the casino stays shut.
|
||||
MatrixServer string `toml:"matrix_server"`
|
||||
}
|
||||
|
||||
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
|
||||
// When enabled, signed-in users get their preferences stored server-side keyed
|
||||
// by the OIDC subject; anonymous visitors keep using browser localStorage.
|
||||
type AuthConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Issuer string `toml:"issuer"` // e.g. https://authentik.parodia.dev/application/o/pete/
|
||||
ClientID string `toml:"client_id"`
|
||||
ClientSecret string `toml:"client_secret"`
|
||||
RedirectURL string `toml:"redirect_url"` // e.g. https://news.parodia.dev/auth/callback
|
||||
SessionSecret string `toml:"session_secret"` // HMAC key for signing the session cookie
|
||||
// CookieDomain widens the session cookie beyond the host that set it, so a
|
||||
// sign-in on news.parodia.dev is also a sign-in on games.parodia.dev. Set it
|
||||
// to ".parodia.dev" to share the session across every parodia.dev host —
|
||||
// which is every host, including the landing site, so it is opt-in rather
|
||||
// than the default. Empty keeps the cookie host-only.
|
||||
CookieDomain string `toml:"cookie_domain"`
|
||||
}
|
||||
|
||||
type MatrixConfig struct {
|
||||
@@ -37,13 +153,23 @@ type MatrixConfig struct {
|
||||
DataDir string `toml:"data_dir"`
|
||||
AdminRoom string `toml:"admin_room"`
|
||||
Channels map[string]string `toml:"channels"`
|
||||
// Admins is the allowlist of Matrix user IDs permitted to run in-room
|
||||
// commands like !post. Empty means commands are disabled — the channel
|
||||
// rooms are public, so an empty list must NOT mean "anyone".
|
||||
Admins []string `toml:"admins"`
|
||||
}
|
||||
|
||||
type PostingConfig struct {
|
||||
MinIntervalSeconds int `toml:"min_interval_seconds"`
|
||||
BurstCapCount int `toml:"burst_cap_count"`
|
||||
BurstCapWindowSeconds int `toml:"burst_cap_window_seconds"`
|
||||
DedupCooldownHours int `toml:"dedup_cooldown_hours"`
|
||||
// Enabled is the master switch for automatic news posting to Matrix.
|
||||
// When false, stories are still ingested, classified, and served to the
|
||||
// web UI, but Pete never auto-posts them to rooms. Command replies
|
||||
// (!post, !petestats) still work. Pointer so an absent key defaults to
|
||||
// true (posting on) rather than Go's zero-value false.
|
||||
Enabled *bool `toml:"enabled"`
|
||||
MinIntervalSeconds int `toml:"min_interval_seconds"`
|
||||
BurstCapCount int `toml:"burst_cap_count"`
|
||||
BurstCapWindowSeconds int `toml:"burst_cap_window_seconds"`
|
||||
DedupCooldownHours int `toml:"dedup_cooldown_hours"`
|
||||
// DailyCapTotal is the hard global cap on posts across ALL channels in a
|
||||
// rolling 24-hour window. 0 disables the cap.
|
||||
DailyCapTotal int `toml:"daily_cap_total"`
|
||||
@@ -70,6 +196,14 @@ type SourceConfig struct {
|
||||
PollIntervalMinutes int `toml:"poll_interval_minutes"`
|
||||
DirectRoute string `toml:"direct_route"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
// Language, when set, drops feed items whose per-item language tag is
|
||||
// present and does not match (prefix). Useful for multilingual feeds
|
||||
// like Politico Europe that publish English + French side-by-side.
|
||||
Language string `toml:"language"`
|
||||
// UserAgent overrides the User-Agent sent when fetching this feed. Empty
|
||||
// uses Pete's honest default bot UA. Set a browser-like string only for
|
||||
// sources whose WAF (e.g. AWS WAF on The Portugal News) blocks bot UAs.
|
||||
UserAgent string `toml:"user_agent"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
@@ -113,6 +247,12 @@ func (c *Config) validate() error {
|
||||
if c.Matrix.Password == "" {
|
||||
return fmt.Errorf("matrix.password is required")
|
||||
}
|
||||
// pickle_key encrypts the E2EE crypto store (olm/megolm + cross-signing
|
||||
// keys) at rest. Never fall back to a hardcoded default: a key baked into
|
||||
// the source is no protection if crypto.db ever leaks.
|
||||
if len(c.Matrix.PickleKey) < 16 {
|
||||
return fmt.Errorf("matrix.pickle_key must be set to a strong secret (>=16 chars); it encrypts the E2EE crypto store at rest")
|
||||
}
|
||||
if len(c.Matrix.Channels) == 0 {
|
||||
return fmt.Errorf("matrix.channels must have at least one entry")
|
||||
}
|
||||
@@ -120,6 +260,44 @@ func (c *Config) validate() error {
|
||||
return fmt.Errorf("storage.db_path is required")
|
||||
}
|
||||
|
||||
if c.Web.Auth.Enabled {
|
||||
a := c.Web.Auth
|
||||
if a.Issuer == "" || a.ClientID == "" || a.ClientSecret == "" || a.RedirectURL == "" {
|
||||
return fmt.Errorf("web.auth requires issuer, client_id, client_secret, and redirect_url when enabled")
|
||||
}
|
||||
if len(a.SessionSecret) < 16 {
|
||||
return fmt.Errorf("web.auth.session_secret must be at least 16 characters")
|
||||
}
|
||||
}
|
||||
|
||||
if c.Web.Push.Enabled {
|
||||
if !c.Web.Auth.Enabled {
|
||||
return fmt.Errorf("web.push requires web.auth to be enabled (push is signed-in only)")
|
||||
}
|
||||
p := c.Web.Push
|
||||
if p.VAPIDPublicKey == "" || p.VAPIDPrivateKey == "" {
|
||||
return fmt.Errorf("web.push requires vapid_public_key and vapid_private_key (generate with: pete -genvapid)")
|
||||
}
|
||||
if p.Subject == "" {
|
||||
return fmt.Errorf("web.push.subject is required (a mailto: or https: URL identifying the sender)")
|
||||
}
|
||||
}
|
||||
|
||||
if c.Adventure.Enabled {
|
||||
if c.Adventure.IngestToken == "" {
|
||||
return fmt.Errorf("adventure.ingest_token is required when adventure is enabled (the bearer secret gogobee presents)")
|
||||
}
|
||||
if h := c.Adventure.DigestHourOrDefault(); h < 0 || h > 23 {
|
||||
return fmt.Errorf("adventure.digest_hour must be 0-23")
|
||||
}
|
||||
if c.Adventure.Channel != "" {
|
||||
if _, ok := c.Matrix.Channels[c.Adventure.Channel]; !ok {
|
||||
slog.Warn("adventure.channel is not a configured Matrix channel — adventure runs website-only",
|
||||
"channel", c.Adventure.Channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, s := range c.Sources {
|
||||
if s.Name == "" {
|
||||
return fmt.Errorf("sources[%d].name is required", i)
|
||||
@@ -157,8 +335,9 @@ func (c *Config) applyDefaults() {
|
||||
if c.Matrix.DisplayName == "" {
|
||||
c.Matrix.DisplayName = "Pete"
|
||||
}
|
||||
if c.Matrix.PickleKey == "" {
|
||||
c.Matrix.PickleKey = "pete_pickle_key"
|
||||
if c.Posting.Enabled == nil {
|
||||
on := true
|
||||
c.Posting.Enabled = &on
|
||||
}
|
||||
if c.Posting.MinIntervalSeconds == 0 {
|
||||
c.Posting.MinIntervalSeconds = 300
|
||||
@@ -184,6 +363,12 @@ func (c *Config) applyDefaults() {
|
||||
if c.Web.SiteTitle == "" {
|
||||
c.Web.SiteTitle = "Pete"
|
||||
}
|
||||
if c.Web.Push.IntervalMinutes == 0 {
|
||||
c.Web.Push.IntervalMinutes = 360
|
||||
}
|
||||
if c.Web.Push.MinStories == 0 {
|
||||
c.Web.Push.MinStories = 3
|
||||
}
|
||||
for i := range c.Sources {
|
||||
if c.Sources[i].PollIntervalMinutes == 0 {
|
||||
c.Sources[i].PollIntervalMinutes = 20
|
||||
|
||||
@@ -12,6 +12,7 @@ const validMatrix = `
|
||||
homeserver = "https://matrix.example.org"
|
||||
user_id = "@pete:example.org"
|
||||
password = "testpass"
|
||||
pickle_key = "test_pickle_key_1234"
|
||||
[matrix.channels]
|
||||
tech = "!tech:example.org"
|
||||
politics = "!politics:example.org"
|
||||
@@ -57,6 +58,7 @@ func TestEnvVarExpansion(t *testing.T) {
|
||||
homeserver = "https://matrix.example.org"
|
||||
user_id = "@pete:example.org"
|
||||
password = "${TEST_PETE_PASS}"
|
||||
pickle_key = "test_pickle_key_1234"
|
||||
[matrix.channels]
|
||||
tech = "!tech:example.org"
|
||||
|
||||
@@ -114,6 +116,7 @@ func TestValidationErrors(t *testing.T) {
|
||||
[matrix]
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
pickle_key = "test_pickle_key_1234"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
@@ -123,6 +126,7 @@ db_path = "/tmp/t.db"
|
||||
[matrix]
|
||||
homeserver = "https://h"
|
||||
password = "pw"
|
||||
pickle_key = "test_pickle_key_1234"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
@@ -142,6 +146,7 @@ db_path = "/tmp/t.db"
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
pickle_key = "test_pickle_key_1234"
|
||||
[storage]
|
||||
db_path = "/tmp/t.db"
|
||||
`},
|
||||
@@ -150,6 +155,7 @@ db_path = "/tmp/t.db"
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
pickle_key = "test_pickle_key_1234"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
@@ -159,6 +165,7 @@ tech = "!t:e"
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
pickle_key = "test_pickle_key_1234"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
@@ -176,6 +183,7 @@ enabled = true
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
pickle_key = "test_pickle_key_1234"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
@@ -192,6 +200,7 @@ enabled = true
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
pickle_key = "test_pickle_key_1234"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
@@ -202,6 +211,43 @@ feed_url = "https://e.com/rss"
|
||||
tier = 1
|
||||
poll_interval_minutes = 20
|
||||
enabled = true
|
||||
`},
|
||||
{"push enabled without auth", `
|
||||
[matrix]
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
pickle_key = "test_pickle_key_1234"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
db_path = "/tmp/t.db"
|
||||
[web.push]
|
||||
enabled = true
|
||||
vapid_public_key = "pub"
|
||||
vapid_private_key = "priv"
|
||||
subject = "mailto:a@b.c"
|
||||
`},
|
||||
{"push enabled missing keys", `
|
||||
[matrix]
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
pickle_key = "test_pickle_key_1234"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
db_path = "/tmp/t.db"
|
||||
[web.auth]
|
||||
enabled = true
|
||||
issuer = "https://i"
|
||||
client_id = "id"
|
||||
client_secret = "secret"
|
||||
redirect_url = "https://r/cb"
|
||||
session_secret = "session_secret_16chars_long"
|
||||
[web.push]
|
||||
enabled = true
|
||||
subject = "mailto:a@b.c"
|
||||
`},
|
||||
}
|
||||
|
||||
@@ -225,6 +271,7 @@ func TestDisabledSourceSkipsDirectRouteCheck(t *testing.T) {
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
pickle_key = "test_pickle_key_1234"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
|
||||
@@ -21,7 +21,7 @@ var trackingParams = map[string]struct{}{
|
||||
"share": {},
|
||||
"shared": {},
|
||||
"cmpid": {},
|
||||
"CMP": {},
|
||||
"cmp": {},
|
||||
"icid": {},
|
||||
"ito": {},
|
||||
"yclid": {},
|
||||
|
||||
594
internal/games/blackjack/blackjack.go
Normal file
594
internal/games/blackjack/blackjack.go
Normal file
@@ -0,0 +1,594 @@
|
||||
// Package blackjack is a pure blackjack engine.
|
||||
//
|
||||
// It knows nothing about HTTP, sockets, timers, euros or players' names. You
|
||||
// hand it a state and a move, it hands you back a new state and the list of
|
||||
// things that just happened. Everything else — who is sitting there, what their
|
||||
// chips are, when their clock runs out — belongs to the shell in internal/games/table.
|
||||
//
|
||||
// That seam is the one thing gogobee's blackjack never had: there, the engine
|
||||
// *was* the message sender, so an "error" meant a Matrix send had failed rather
|
||||
// than that a player had tried something illegal. Here an error means exactly
|
||||
// one thing: the move was not legal in this state.
|
||||
//
|
||||
// The state is a plain value. It serializes, so a hand survives a redeploy, and
|
||||
// it replays, so a disputed hand can be dealt again from its seed.
|
||||
package blackjack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// Errors an illegal move can produce. Callers can match on these to tell a
|
||||
// player "not now" rather than "something broke".
|
||||
var (
|
||||
ErrHandOver = errors.New("blackjack: the hand is already over")
|
||||
ErrNotYourTurn = errors.New("blackjack: it is not the player's turn to act")
|
||||
ErrUnknownMove = errors.New("blackjack: unknown move")
|
||||
ErrCantDouble = errors.New("blackjack: double is only allowed on the opening two cards")
|
||||
ErrCantSplit = errors.New("blackjack: split is only allowed on two cards of the same rank")
|
||||
ErrDeckExhausted = errors.New("blackjack: the shoe is empty")
|
||||
ErrBadBet = errors.New("blackjack: bet must be positive")
|
||||
)
|
||||
|
||||
// MaxHands is how many hands one deal can turn into: the opening hand, plus
|
||||
// three splits. Four is the usual house limit and it is also the point past
|
||||
// which the felt runs out of room.
|
||||
const MaxHands = 4
|
||||
|
||||
// Phase is whose turn it is.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlayer Phase = "player" // the player is acting on the active hand
|
||||
PhaseDealer Phase = "dealer" // transient: the dealer is drawing out
|
||||
PhaseDone Phase = "done" // settled, Outcome and Payout are final
|
||||
)
|
||||
|
||||
// Outcome is how a finished hand finished, from the player's point of view.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeNone Outcome = ""
|
||||
OutcomeBlackjack Outcome = "blackjack" // natural 21, paid 3:2
|
||||
OutcomeWin Outcome = "win"
|
||||
OutcomeLose Outcome = "lose"
|
||||
OutcomePush Outcome = "push" // tie, stake returned
|
||||
OutcomeBust Outcome = "bust" // player went over 21
|
||||
OutcomeDealerBust Outcome = "dealer_bust"
|
||||
)
|
||||
|
||||
// Won reports whether this outcome pays the player more than their stake back.
|
||||
func (o Outcome) Won() bool {
|
||||
return o == OutcomeWin || o == OutcomeBlackjack || o == OutcomeDealerBust
|
||||
}
|
||||
|
||||
// Rules are the table's terms. They're part of the state rather than a global,
|
||||
// so a hand always settles under the rules it was dealt under — even if the
|
||||
// house changes them mid-session.
|
||||
type Rules struct {
|
||||
Decks int `json:"decks"` // shoe size
|
||||
BlackjackPays float64 `json:"blackjack_pays"` // 1.5 = the honest 3:2
|
||||
DealerHitsSoft17 bool `json:"dealer_hits_soft17"` // gogobee's dealer does
|
||||
RakePct float64 `json:"rake_pct"` // house cut, taken from winnings only
|
||||
}
|
||||
|
||||
// DefaultRules match the blackjack gogobee has been dealing in Matrix for years:
|
||||
// six decks, 3:2 on a natural, dealer hits soft 17. The rake is the one new term
|
||||
// — see settle for exactly what it touches.
|
||||
func DefaultRules() Rules {
|
||||
return Rules{Decks: 6, BlackjackPays: 1.5, DealerHitsSoft17: true, RakePct: 0.05}
|
||||
}
|
||||
|
||||
// Hand is one hand the player is holding, with the chips that are on it.
|
||||
//
|
||||
// A deal starts with one. A split turns one into two, and the new hand carries a
|
||||
// bet of its own — which is the whole reason split is not just a card trick: it
|
||||
// is the only move in the game that takes more chips out of a player's stack
|
||||
// *after* the cards are out.
|
||||
type Hand struct {
|
||||
Cards []cards.Card `json:"cards"`
|
||||
Bet int64 `json:"bet"`
|
||||
Doubled bool `json:"doubled"`
|
||||
|
||||
// Split marks a hand that came out of a split. It exists for one rule: 21 on
|
||||
// a split hand is twenty-one, not a natural, and is paid 1:1 like any other.
|
||||
// Otherwise splitting aces would print money.
|
||||
Split bool `json:"split"`
|
||||
|
||||
// Done means this hand will not be acted on again: it stood, it busted, it
|
||||
// doubled, or it is a split ace, which gets exactly one card and no say.
|
||||
Done bool `json:"done"`
|
||||
|
||||
Outcome Outcome `json:"outcome"`
|
||||
Payout int64 `json:"payout"` // stake plus winnings, net of rake. Zero on a loss.
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Value totals the hand. See HandValue.
|
||||
func (h Hand) Value() (int, bool) { return HandValue(h.Cards) }
|
||||
|
||||
// Natural reports a blackjack: 21 on the opening two cards of a hand that was
|
||||
// dealt, not split.
|
||||
func (h Hand) Natural() bool { return !h.Split && IsBlackjack(h.Cards) }
|
||||
|
||||
// State is one deal of heads-up blackjack: the player's hands against the
|
||||
// dealer's one.
|
||||
type State struct {
|
||||
Rules Rules `json:"rules"`
|
||||
Deck cards.Deck `json:"deck"` // the shoe, top card first — never shown to the browser
|
||||
Dealer []cards.Card `json:"dealer"`
|
||||
|
||||
// Hands is always at least one, and Active indexes the one being played. The
|
||||
// player works left to right: a hand is finished before the next is looked at,
|
||||
// which is both how a real table does it and what keeps the felt legible.
|
||||
Hands []Hand `json:"hands"`
|
||||
Active int `json:"active"`
|
||||
|
||||
Phase Phase `json:"phase"`
|
||||
Outcome Outcome `json:"outcome"` // the deal as a whole; per-hand outcomes live on the hands
|
||||
|
||||
// Bet, Payout and Rake are the totals across every hand: what the player has
|
||||
// staked, what comes back, and what the house kept. The ledger and the chip
|
||||
// stack only ever deal in these.
|
||||
Bet int64 `json:"bet"`
|
||||
Payout int64 `json:"payout"`
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Event is something the table can narrate or animate. The engine emits them
|
||||
// instead of drawing anything itself.
|
||||
//
|
||||
// Hand is which of the player's hands an event landed on — meaningless for the
|
||||
// dealer's, and the reason it is here at all is that after a split the browser
|
||||
// has to know which fan a card is flying to.
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // "deal" | "player_card" | "dealer_card" | "split" | "double" | "reveal" | "settle"
|
||||
Card *cards.Card `json:"card,omitempty"`
|
||||
Hand int `json:"hand"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// Move is a player action.
|
||||
type Move string
|
||||
|
||||
const (
|
||||
Hit Move = "hit"
|
||||
Stand Move = "stand"
|
||||
Double Move = "double"
|
||||
Split Move = "split"
|
||||
)
|
||||
|
||||
// HandValue totals a hand, counting each ace as 11 until that would bust, then
|
||||
// demoting them one at a time. soft reports whether an ace is still counting as
|
||||
// 11 — which is what makes "soft 17" a different thing from 17.
|
||||
func HandValue(hand []cards.Card) (total int, soft bool) {
|
||||
aces := 0
|
||||
for _, c := range hand {
|
||||
switch {
|
||||
case c.Rank == cards.Ace:
|
||||
aces++
|
||||
total += 11
|
||||
case c.Rank >= 10:
|
||||
total += 10
|
||||
default:
|
||||
total += int(c.Rank)
|
||||
}
|
||||
}
|
||||
for total > 21 && aces > 0 {
|
||||
total -= 10 // demote an ace from 11 to 1
|
||||
aces--
|
||||
}
|
||||
return total, aces > 0
|
||||
}
|
||||
|
||||
// IsBlackjack reports a natural: 21 on the opening two cards. A 21 assembled
|
||||
// from three cards is not one, and does not get paid 3:2.
|
||||
func IsBlackjack(hand []cards.Card) bool {
|
||||
if len(hand) != 2 {
|
||||
return false
|
||||
}
|
||||
v, _ := HandValue(hand)
|
||||
return v == 21
|
||||
}
|
||||
|
||||
// New deals a fresh hand: two to the player, two to the dealer. If either side
|
||||
// has a natural the hand is already over and the returned State is settled — a
|
||||
// player with blackjack never gets asked whether they'd like to hit.
|
||||
func New(bet int64, r Rules, rng *rand.Rand) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
if r.Decks < 1 {
|
||||
r.Decks = 1
|
||||
}
|
||||
deck := cards.NewDeck(r.Decks)
|
||||
deck.Shuffle(rng)
|
||||
|
||||
s := State{
|
||||
Rules: r,
|
||||
Deck: deck,
|
||||
Hands: []Hand{{Bet: bet}},
|
||||
Bet: bet,
|
||||
Phase: PhasePlayer,
|
||||
}
|
||||
evs := []Event{{Kind: "deal"}}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := s.hit(0, &evs); err != nil {
|
||||
return State{}, nil, err
|
||||
}
|
||||
if err := s.drawDealer(&evs); err != nil {
|
||||
return State{}, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// A natural on either side ends it before the player ever acts.
|
||||
if s.Hands[0].Natural() || IsBlackjack(s.Dealer) {
|
||||
s.settle(&evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// hit puts one card on a player hand. Pointer receiver: it mutates the deck and
|
||||
// the hand together, and neither may end up applied to a stale copy of the state.
|
||||
func (s *State) hit(i int, evs *[]Event) error {
|
||||
c, ok := s.Deck.Draw()
|
||||
if !ok {
|
||||
return ErrDeckExhausted
|
||||
}
|
||||
s.Hands[i].Cards = append(s.Hands[i].Cards, c)
|
||||
card := c
|
||||
*evs = append(*evs, Event{Kind: "player_card", Card: &card, Hand: i})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) drawDealer(evs *[]Event) error {
|
||||
c, ok := s.Deck.Draw()
|
||||
if !ok {
|
||||
return ErrDeckExhausted
|
||||
}
|
||||
s.Dealer = append(s.Dealer, c)
|
||||
card := c
|
||||
*evs = append(*evs, Event{Kind: "dealer_card", Card: &card})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyMove is the whole engine: a legal move in, a new state and the events it
|
||||
// produced out. An error means the move was illegal and the state is unchanged.
|
||||
//
|
||||
// s is taken by value, so the caller's state is only replaced on success.
|
||||
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
if s.Phase == PhaseDone {
|
||||
return s, nil, ErrHandOver
|
||||
}
|
||||
// A copied State still shares its slices' backing arrays with the original.
|
||||
// Two moves applied from the same starting state would then append cards over
|
||||
// each other. Clone first: the caller's state is genuinely untouched, and a
|
||||
// state can be replayed as many times as we like.
|
||||
s = s.clone()
|
||||
if s.Phase != PhasePlayer {
|
||||
return s, nil, ErrNotYourTurn
|
||||
}
|
||||
switch m {
|
||||
case Hit, Stand, Double, Split:
|
||||
default:
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
if m == Double && !s.CanDouble() {
|
||||
// Doubling means doubling the stake for exactly one more card. Only ever
|
||||
// legal on the opening two — after that you're just describing a hit.
|
||||
return s, nil, ErrCantDouble
|
||||
}
|
||||
if m == Split && !s.CanSplit() {
|
||||
return s, nil, ErrCantSplit
|
||||
}
|
||||
|
||||
i := s.Active
|
||||
evs := []Event{}
|
||||
|
||||
switch m {
|
||||
case Split:
|
||||
// The second card moves to a hand of its own, carrying a bet the same size
|
||||
// as the one it came from, and both hands are topped up to two cards.
|
||||
h := &s.Hands[i]
|
||||
moved := h.Cards[1]
|
||||
h.Cards = h.Cards[:1]
|
||||
h.Split = true
|
||||
|
||||
fresh := Hand{Cards: []cards.Card{moved}, Bet: h.Bet, Split: true}
|
||||
s.Hands = append(s.Hands, Hand{})
|
||||
copy(s.Hands[i+2:], s.Hands[i+1:]) // the new hand sits immediately to the right
|
||||
s.Hands[i+1] = fresh
|
||||
s.Bet += fresh.Bet
|
||||
|
||||
evs = append(evs, Event{Kind: "split", Hand: i})
|
||||
if err := s.hit(i, &evs); err != nil {
|
||||
return s, nil, err
|
||||
}
|
||||
if err := s.hit(i+1, &evs); err != nil {
|
||||
return s, nil, err
|
||||
}
|
||||
|
||||
// Split aces get one card each and no say in it. Without this rule a pair
|
||||
// of aces is the best hand in the game and everybody would split them
|
||||
// forever; with it, splitting aces is a gamble like everything else.
|
||||
if moved.Rank == cards.Ace {
|
||||
s.Hands[i].Done = true
|
||||
s.Hands[i+1].Done = true
|
||||
}
|
||||
// A hand that has just been dealt a card can still be sitting on 21, and a
|
||||
// 21 has nothing left to decide.
|
||||
s.finishIfDone(i)
|
||||
s.finishIfDone(i + 1)
|
||||
|
||||
case Double:
|
||||
h := &s.Hands[i]
|
||||
s.Bet += h.Bet
|
||||
h.Bet *= 2
|
||||
h.Doubled = true
|
||||
// Announced before the card, because that is the order it happens in: the
|
||||
// chips go down, and *then* you find out what you bought with them.
|
||||
evs = append(evs, Event{Kind: "double", Hand: i})
|
||||
if err := s.hit(i, &evs); err != nil {
|
||||
return s, nil, err
|
||||
}
|
||||
h.Done = true // one card, and that is the deal you made
|
||||
|
||||
case Hit:
|
||||
if err := s.hit(i, &evs); err != nil {
|
||||
return s, nil, err
|
||||
}
|
||||
s.finishIfDone(i)
|
||||
|
||||
case Stand:
|
||||
s.Hands[i].Done = true
|
||||
}
|
||||
|
||||
s.advance(&evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// finishIfDone closes a hand that has nothing left to decide: it busted, or it
|
||||
// is sitting on 21 and would only be hitting it to be polite.
|
||||
func (s *State) finishIfDone(i int) {
|
||||
if v, _ := s.Hands[i].Value(); v >= 21 {
|
||||
s.Hands[i].Done = true
|
||||
}
|
||||
}
|
||||
|
||||
// advance moves to the next hand still owed a decision. When there are none, the
|
||||
// dealer plays — unless every hand busted, in which case there is nothing to beat
|
||||
// and the dealer does not draw.
|
||||
func (s *State) advance(evs *[]Event) {
|
||||
for i := s.Active; i < len(s.Hands); i++ {
|
||||
if !s.Hands[i].Done {
|
||||
s.Active = i
|
||||
return
|
||||
}
|
||||
}
|
||||
s.Active = len(s.Hands) - 1
|
||||
|
||||
if s.allBust() {
|
||||
// The dealer draws no cards, but the hole card still turns over: the
|
||||
// browser has been showing a face-down card since the deal, and the settled
|
||||
// state it is about to be handed has the dealer's full total on it. Without
|
||||
// the reveal you get a nineteen printed under a card nobody has looked at.
|
||||
*evs = append(*evs, Event{Kind: "reveal"})
|
||||
s.settle(evs)
|
||||
return
|
||||
}
|
||||
s.Phase = PhaseDealer
|
||||
s.dealerPlay(evs)
|
||||
}
|
||||
|
||||
func (s *State) allBust() bool {
|
||||
for _, h := range s.Hands {
|
||||
if v, _ := h.Value(); v <= 21 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// dealerPlay draws the dealer out to the house rule, then settles. The dealer
|
||||
// has no choices to make — that's the game — so this needs no move.
|
||||
func (s *State) dealerPlay(evs *[]Event) {
|
||||
*evs = append(*evs, Event{Kind: "reveal"}) // the hole card turns over
|
||||
for {
|
||||
v, soft := HandValue(s.Dealer)
|
||||
hitSoft17 := s.Rules.DealerHitsSoft17 && v == 17 && soft
|
||||
if v >= 17 && !hitSoft17 {
|
||||
break
|
||||
}
|
||||
if err := s.drawDealer(evs); err != nil {
|
||||
break // shoe ran dry mid-draw; settle on what's on the table
|
||||
}
|
||||
}
|
||||
s.settle(evs)
|
||||
}
|
||||
|
||||
// settle decides every hand against the dealer and adds up what comes back. It
|
||||
// is the only place chips are computed.
|
||||
//
|
||||
// The rake comes off winnings, never off the stake: a player who pushes gets
|
||||
// exactly their bet back, and a player who loses is never charged for the
|
||||
// privilege. The house only takes a cut of money the house was going to hand
|
||||
// over anyway. That's a rake, as opposed to a fee for showing up.
|
||||
//
|
||||
// Each hand is raked on its own winnings. Netting the hands against each other
|
||||
// first would let a player who won one and lost one pay no rake at all, which is
|
||||
// not a rake, it's a discount for splitting.
|
||||
func (s *State) settle(evs *[]Event) {
|
||||
dealerVal, _ := HandValue(s.Dealer)
|
||||
dealerBJ := IsBlackjack(s.Dealer)
|
||||
|
||||
s.Payout, s.Rake = 0, 0
|
||||
for i := range s.Hands {
|
||||
h := &s.Hands[i]
|
||||
playerVal, _ := h.Value()
|
||||
|
||||
// profit is what this hand wins on top of its stake. Negative means the
|
||||
// stake is gone.
|
||||
var profit int64
|
||||
|
||||
switch {
|
||||
case playerVal > 21:
|
||||
h.Outcome = OutcomeBust
|
||||
profit = -h.Bet
|
||||
case h.Natural() && dealerBJ:
|
||||
h.Outcome = OutcomePush
|
||||
case h.Natural():
|
||||
h.Outcome = OutcomeBlackjack
|
||||
profit = int64(math.Floor(float64(h.Bet) * s.Rules.BlackjackPays))
|
||||
case dealerBJ:
|
||||
h.Outcome = OutcomeLose
|
||||
profit = -h.Bet
|
||||
case dealerVal > 21:
|
||||
h.Outcome = OutcomeDealerBust
|
||||
profit = h.Bet
|
||||
case playerVal > dealerVal:
|
||||
h.Outcome = OutcomeWin
|
||||
profit = h.Bet
|
||||
case playerVal == dealerVal:
|
||||
h.Outcome = OutcomePush
|
||||
default:
|
||||
h.Outcome = OutcomeLose
|
||||
profit = -h.Bet
|
||||
}
|
||||
|
||||
if profit > 0 {
|
||||
h.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct))
|
||||
if h.Rake < 0 {
|
||||
h.Rake = 0
|
||||
}
|
||||
profit -= h.Rake
|
||||
}
|
||||
if profit < 0 {
|
||||
h.Payout = 0 // stake is lost; nothing comes back
|
||||
} else {
|
||||
h.Payout = h.Bet + profit
|
||||
}
|
||||
|
||||
s.Payout += h.Payout
|
||||
s.Rake += h.Rake
|
||||
}
|
||||
|
||||
s.Outcome = s.overall()
|
||||
s.Phase = PhaseDone
|
||||
*evs = append(*evs, Event{Kind: "settle", Text: string(s.Outcome)})
|
||||
}
|
||||
|
||||
// overall is the deal's outcome as one word, which is what the ledger and the
|
||||
// history line want. With one hand it is simply that hand's. With several there
|
||||
// is no honest single word for "won one, lost one", so it reports what the deal
|
||||
// did to the player's chips, which is the thing anybody actually means.
|
||||
func (s State) overall() Outcome {
|
||||
if len(s.Hands) == 1 {
|
||||
return s.Hands[0].Outcome
|
||||
}
|
||||
switch net := s.Payout - s.Bet; {
|
||||
case net > 0:
|
||||
return OutcomeWin
|
||||
case net < 0:
|
||||
return OutcomeLose
|
||||
default:
|
||||
return OutcomePush
|
||||
}
|
||||
}
|
||||
|
||||
// Net is what the deal did to the player's chip stack: everything paid out minus
|
||||
// everything staked. Negative on a loss, zero on a push.
|
||||
func (s State) Net() int64 {
|
||||
if s.Phase != PhaseDone {
|
||||
return 0
|
||||
}
|
||||
return s.Payout - s.Bet
|
||||
}
|
||||
|
||||
// Hand returns the hand being played. There is always one.
|
||||
func (s State) Hand() Hand {
|
||||
if s.Active < 0 || s.Active >= len(s.Hands) {
|
||||
return Hand{}
|
||||
}
|
||||
return s.Hands[s.Active]
|
||||
}
|
||||
|
||||
// CanDouble reports whether Double is legal right now — the shell asks this to
|
||||
// decide whether to light the button up.
|
||||
func (s State) CanDouble() bool {
|
||||
h := s.Hand()
|
||||
return s.Phase == PhasePlayer && !h.Done && len(h.Cards) == 2
|
||||
}
|
||||
|
||||
// CanSplit reports whether Split is legal: two cards of the same rank, and room
|
||||
// at the table for another hand.
|
||||
//
|
||||
// Same *rank*, not same value: a king and a queen are both worth ten and are not
|
||||
// a pair, which is the stricter of the two house rules and the one that doesn't
|
||||
// need explaining on the felt.
|
||||
func (s State) CanSplit() bool {
|
||||
h := s.Hand()
|
||||
if s.Phase != PhasePlayer || h.Done || len(h.Cards) != 2 || len(s.Hands) >= MaxHands {
|
||||
return false
|
||||
}
|
||||
return h.Cards[0].Rank == h.Cards[1].Rank
|
||||
}
|
||||
|
||||
// SplitCost is what splitting the active hand would take out of the player's
|
||||
// stack: another bet the same size as the one already on it. The shell has to
|
||||
// take these chips before the move, because a split the player cannot cover is
|
||||
// not a legal move.
|
||||
func (s State) SplitCost() int64 { return s.Hand().Bet }
|
||||
|
||||
// DoubleCost is the same idea for a double: the stake again.
|
||||
func (s State) DoubleCost() int64 { return s.Hand().Bet }
|
||||
|
||||
// clone deep-copies the slices so a derived state shares no backing array with
|
||||
// the one it came from.
|
||||
func (s State) clone() State {
|
||||
s.Deck = append(cards.Deck(nil), s.Deck...)
|
||||
s.Dealer = append([]cards.Card(nil), s.Dealer...)
|
||||
hands := make([]Hand, len(s.Hands))
|
||||
copy(hands, s.Hands)
|
||||
for i := range hands {
|
||||
hands[i].Cards = append([]cards.Card(nil), hands[i].Cards...)
|
||||
}
|
||||
s.Hands = hands
|
||||
return s
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads a state, including one written before split existed.
|
||||
//
|
||||
// A live hand outlives a deploy: it is a blob in the database, and somebody is
|
||||
// mid-hand when the new binary starts. Those blobs have a "player" array and no
|
||||
// "hands", and without this they would come back as a state with no hands at all
|
||||
// — which is not a decoding error, it's a player whose cards vanished. So the old
|
||||
// shape is read as what it always was: one hand, holding the whole stake.
|
||||
func (s *State) UnmarshalJSON(b []byte) error {
|
||||
type state State // shed the method, or this recurses forever
|
||||
var v struct {
|
||||
state
|
||||
Player []cards.Card `json:"player"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = State(v.state)
|
||||
if len(s.Hands) == 0 && len(v.Player) > 0 {
|
||||
s.Hands = []Hand{{
|
||||
Cards: v.Player,
|
||||
Bet: s.Bet,
|
||||
Outcome: s.Outcome,
|
||||
Payout: s.Payout,
|
||||
Rake: s.Rake,
|
||||
Done: s.Phase == PhaseDone,
|
||||
}}
|
||||
s.Active = 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
415
internal/games/blackjack/blackjack_test.go
Normal file
415
internal/games/blackjack/blackjack_test.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package blackjack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// hand builds a hand from "A♠"-ish shorthand: rank letters/numbers only.
|
||||
func hand(ranks ...cards.Rank) []cards.Card {
|
||||
h := make([]cards.Card, len(ranks))
|
||||
for i, r := range ranks {
|
||||
h[i] = cards.Card{Rank: r, Suit: cards.Spades}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestHandValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hand []cards.Card
|
||||
want int
|
||||
soft bool
|
||||
}{
|
||||
{"two aces are 12, not 22", hand(cards.Ace, cards.Ace), 12, true},
|
||||
{"ace plus king is a soft 21", hand(cards.Ace, cards.King), 21, true},
|
||||
{"faces are all ten", hand(cards.Jack, cards.Queen), 20, false},
|
||||
{"ace demotes to save the hand", hand(cards.Ace, 9, 5), 15, false},
|
||||
{"three aces and an eight", hand(cards.Ace, cards.Ace, cards.Ace, 8), 21, true},
|
||||
{"soft 17 is an ace and a six", hand(cards.Ace, 6), 17, true},
|
||||
{"hard 17 has no ace", hand(cards.King, 7), 17, false},
|
||||
{"a bust stays busted", hand(cards.King, cards.Queen, 5), 25, false},
|
||||
{"empty hand", nil, 0, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, soft := HandValue(tc.hand)
|
||||
if got != tc.want || soft != tc.soft {
|
||||
t.Fatalf("HandValue = (%d, soft=%v), want (%d, soft=%v)", got, soft, tc.want, tc.soft)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBlackjack_OnlyOnTwoCards(t *testing.T) {
|
||||
if !IsBlackjack(hand(cards.Ace, cards.King)) {
|
||||
t.Fatal("A+K is a natural")
|
||||
}
|
||||
// 21 built from three cards is not a natural and must not be paid 3:2.
|
||||
if IsBlackjack(hand(7, 7, 7)) {
|
||||
t.Fatal("7+7+7 is 21 but not a blackjack")
|
||||
}
|
||||
if IsBlackjack(hand(cards.Ace)) {
|
||||
t.Fatal("one card is not a blackjack")
|
||||
}
|
||||
}
|
||||
|
||||
// settleWith forces a finished hand and reads back the money, bypassing the
|
||||
// deal so the payout math can be checked case by case.
|
||||
func settleWith(t *testing.T, r Rules, bet int64, player, dealer []cards.Card) State {
|
||||
t.Helper()
|
||||
s := State{Rules: r, Bet: bet, Hands: []Hand{{Cards: player, Bet: bet}}, Dealer: dealer}
|
||||
evs := []Event{}
|
||||
s.settle(&evs)
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatal("settle left the hand unfinished")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSettle_PayoutsAndRake(t *testing.T) {
|
||||
r := DefaultRules() // 3:2, 5% rake
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
player []cards.Card
|
||||
dealer []cards.Card
|
||||
wantOutcome Outcome
|
||||
wantPayout int64 // chips returned to the stack
|
||||
wantRake int64
|
||||
}{
|
||||
{
|
||||
// 100 stake, 100 profit, 5 raked → 195 back, net +95.
|
||||
name: "a plain win is raked on the profit only",
|
||||
player: hand(cards.King, 9), dealer: hand(cards.King, 8),
|
||||
wantOutcome: OutcomeWin, wantPayout: 195, wantRake: 5,
|
||||
},
|
||||
{
|
||||
// 3:2 on 100 is 150 profit, 7 raked (floor of 7.5) → 243 back.
|
||||
name: "a natural pays 3:2 less rake",
|
||||
player: hand(cards.Ace, cards.King), dealer: hand(cards.King, 8),
|
||||
wantOutcome: OutcomeBlackjack, wantPayout: 243, wantRake: 7,
|
||||
},
|
||||
{
|
||||
name: "a push returns the stake untouched — the house takes nothing",
|
||||
player: hand(cards.King, 9), dealer: hand(cards.Queen, 9),
|
||||
wantOutcome: OutcomePush, wantPayout: 100, wantRake: 0,
|
||||
},
|
||||
{
|
||||
name: "two naturals push",
|
||||
player: hand(cards.Ace, cards.King), dealer: hand(cards.Ace, cards.Queen),
|
||||
wantOutcome: OutcomePush, wantPayout: 100, wantRake: 0,
|
||||
},
|
||||
{
|
||||
name: "a loss pays nothing and is not charged a rake",
|
||||
player: hand(cards.King, 8), dealer: hand(cards.King, 9),
|
||||
wantOutcome: OutcomeLose, wantPayout: 0, wantRake: 0,
|
||||
},
|
||||
{
|
||||
name: "a bust pays nothing even if the dealer would have busted too",
|
||||
player: hand(cards.King, 8, 9), dealer: hand(cards.King, 6, 9),
|
||||
wantOutcome: OutcomeBust, wantPayout: 0, wantRake: 0,
|
||||
},
|
||||
{
|
||||
name: "dealer blackjack beats the player's twenty",
|
||||
player: hand(cards.King, cards.Queen), dealer: hand(cards.Ace, cards.Jack),
|
||||
wantOutcome: OutcomeLose, wantPayout: 0, wantRake: 0,
|
||||
},
|
||||
{
|
||||
name: "dealer bust pays even money less rake",
|
||||
player: hand(cards.King, 5), dealer: hand(cards.King, 6, 9),
|
||||
wantOutcome: OutcomeDealerBust, wantPayout: 195, wantRake: 5,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := settleWith(t, r, 100, tc.player, tc.dealer)
|
||||
if s.Outcome != tc.wantOutcome {
|
||||
t.Errorf("outcome = %q, want %q", s.Outcome, tc.wantOutcome)
|
||||
}
|
||||
if s.Payout != tc.wantPayout {
|
||||
t.Errorf("payout = %d, want %d", s.Payout, tc.wantPayout)
|
||||
}
|
||||
if s.Rake != tc.wantRake {
|
||||
t.Errorf("rake = %d, want %d", s.Rake, tc.wantRake)
|
||||
}
|
||||
// The invariant the ledger depends on: every chip the player staked
|
||||
// either comes back, goes to the house as rake, or is lost to the table.
|
||||
if s.Payout < 0 || s.Rake < 0 {
|
||||
t.Errorf("negative chips: payout=%d rake=%d", s.Payout, s.Rake)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettle_RakeNeverTouchesTheStake(t *testing.T) {
|
||||
// A 100% rake is absurd, but it must still never claw back a player's own
|
||||
// stake: the worst a rake can do is take all the winnings.
|
||||
r := Rules{Decks: 6, BlackjackPays: 1.5, RakePct: 1.0}
|
||||
s := settleWith(t, r, 100, hand(cards.King, 9), hand(cards.King, 8))
|
||||
if s.Payout != 100 {
|
||||
t.Fatalf("payout = %d, want the stake back (100)", s.Payout)
|
||||
}
|
||||
if s.Net() != 0 {
|
||||
t.Fatalf("net = %d, want 0", s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_DealsFourCardsAndAskThePlayer(t *testing.T) {
|
||||
rng := cards.NewRNG(1, 2)
|
||||
s, evs, err := New(50, DefaultRules(), rng)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Hands[0].Cards) != 2 || len(s.Dealer) != 2 {
|
||||
t.Fatalf("dealt %d/%d cards, want 2/2", len(s.Hands[0].Cards), len(s.Dealer))
|
||||
}
|
||||
if len(s.Deck) != 6*52-4 {
|
||||
t.Fatalf("shoe has %d cards left, want %d", len(s.Deck), 6*52-4)
|
||||
}
|
||||
if len(evs) == 0 || evs[0].Kind != "deal" {
|
||||
t.Fatal("no deal event")
|
||||
}
|
||||
// Unless somebody was dealt a natural, it's the player's move.
|
||||
if !IsBlackjack(s.Hands[0].Cards) && !IsBlackjack(s.Dealer) && s.Phase != PhasePlayer {
|
||||
t.Fatalf("phase = %q, want %q", s.Phase, PhasePlayer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_RejectsNonPositiveBet(t *testing.T) {
|
||||
for _, bet := range []int64{0, -100} {
|
||||
if _, _, err := New(bet, DefaultRules(), cards.NewRNG(1, 2)); err == nil {
|
||||
t.Fatalf("bet %d was accepted", bet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_NaturalSettlesImmediately(t *testing.T) {
|
||||
// Search seeds for a deal that gives the player a natural, then assert the
|
||||
// hand is already over — a player holding blackjack is never asked to hit.
|
||||
for seed := uint64(1); seed < 200; seed++ {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(seed, seed))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if IsBlackjack(s.Hands[0].Cards) {
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("seed %d: player has a natural but phase = %q", seed, s.Phase)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Hit); err != ErrHandOver {
|
||||
t.Fatalf("seed %d: hitting a settled natural gave %v, want ErrHandOver", seed, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Skip("no natural dealt in 200 seeds")
|
||||
}
|
||||
|
||||
func TestApplyMove_HitUntilBustSettles(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(7, 7))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural; not the hand under test")
|
||||
}
|
||||
for i := 0; i < 12 && s.Phase == PhasePlayer; i++ {
|
||||
s, _, err = ApplyMove(s, Hit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatal("hitting a dozen times never ended the hand")
|
||||
}
|
||||
if v, _ := HandValue(s.Hands[0].Cards); v <= 21 {
|
||||
t.Fatalf("player stopped at %d without busting — the loop should have gone over", v)
|
||||
}
|
||||
if s.Outcome != OutcomeBust || s.Payout != 0 {
|
||||
t.Fatalf("outcome=%q payout=%d, want bust/0", s.Outcome, s.Payout)
|
||||
}
|
||||
// A busted player must not have made the dealer draw.
|
||||
if len(s.Dealer) != 2 {
|
||||
t.Fatalf("dealer drew %d cards against a busted player", len(s.Dealer)-2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMove_StandRunsTheDealerOut(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(3, 9))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
s, evs, err := ApplyMove(s, Stand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("phase = %q after stand, want done", s.Phase)
|
||||
}
|
||||
v, soft := HandValue(s.Dealer)
|
||||
if v < 17 {
|
||||
t.Fatalf("dealer stood on %d, must draw below 17", v)
|
||||
}
|
||||
if v == 17 && soft {
|
||||
t.Fatal("dealer stood on soft 17; the house rule says hit")
|
||||
}
|
||||
var reveal bool
|
||||
for _, e := range evs {
|
||||
if e.Kind == "reveal" {
|
||||
reveal = true
|
||||
}
|
||||
}
|
||||
if !reveal {
|
||||
t.Fatal("dealer played without a reveal event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMove_DoubleTakesOneCardThenStands(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(11, 4))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
if !s.CanDouble() {
|
||||
t.Fatal("double should be legal on the opening two cards")
|
||||
}
|
||||
s, _, err = ApplyMove(s, Double)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Hands[0].Doubled || s.Bet != 200 {
|
||||
t.Fatalf("bet = %d doubled = %v, want 200/true", s.Bet, s.Hands[0].Doubled)
|
||||
}
|
||||
if len(s.Hands[0].Cards) != 3 {
|
||||
t.Fatalf("player has %d cards after a double, want exactly 3", len(s.Hands[0].Cards))
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatal("a double must end the player's turn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMove_DoubleIsIllegalAfterHitting(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(5, 5))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
s, _, err = ApplyMove(s, Hit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhasePlayer {
|
||||
t.Skip("busted on the hit; not the hand under test")
|
||||
}
|
||||
before := s.Bet
|
||||
after, _, err := ApplyMove(s, Double)
|
||||
if err != ErrCantDouble {
|
||||
t.Fatalf("double after a hit gave %v, want ErrCantDouble", err)
|
||||
}
|
||||
if after.Bet != before {
|
||||
t.Fatalf("a rejected double still moved the bet: %d -> %d", before, after.Bet)
|
||||
}
|
||||
if s.CanDouble() {
|
||||
t.Fatal("CanDouble says yes on a three-card hand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMove_RejectsGarbage(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(2, 8))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Move("surrender")); err != ErrUnknownMove {
|
||||
t.Fatalf("got %v, want ErrUnknownMove", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The engine's state has to survive a redeploy: no timers, no pointers, no
|
||||
// unexported fields that JSON would quietly drop.
|
||||
func TestState_RoundTripsThroughJSON(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(13, 21))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
blob, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back State
|
||||
if err := json.Unmarshal(blob, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Play both forward identically; a state that survives the trip settles the same.
|
||||
live, _, err := ApplyMove(s, Stand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revived, _, err := ApplyMove(back, Stand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if live.Outcome != revived.Outcome || live.Payout != revived.Payout {
|
||||
t.Fatalf("revived hand settled differently: %q/%d vs %q/%d",
|
||||
revived.Outcome, revived.Payout, live.Outcome, live.Payout)
|
||||
}
|
||||
}
|
||||
|
||||
// Same seed, same shoe — this is what lets a disputed hand be re-dealt.
|
||||
func TestNew_IsReproducibleFromItsSeed(t *testing.T) {
|
||||
a, _, err := New(100, DefaultRules(), cards.NewRNG(42, 42))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _, err := New(100, DefaultRules(), cards.NewRNG(42, 42))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cards.Hand(a.Hands[0].Cards) != cards.Hand(b.Hands[0].Cards) || cards.Hand(a.Dealer) != cards.Hand(b.Dealer) {
|
||||
t.Fatalf("same seed dealt different hands: %s/%s vs %s/%s",
|
||||
cards.Hand(a.Hands[0].Cards), cards.Hand(a.Dealer), cards.Hand(b.Hands[0].Cards), cards.Hand(b.Dealer))
|
||||
}
|
||||
}
|
||||
|
||||
// A State handed to ApplyMove twice must produce two independent hands. If the
|
||||
// engine let derived states share a backing array, the second deal would scribble
|
||||
// over the first one's cards — and a player could watch a card change under them.
|
||||
func TestApplyMove_DerivedStatesDoNotShareCards(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(23, 5))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
before := cards.Hand(s.Hands[0].Cards)
|
||||
|
||||
a, _, err := ApplyMove(s, Hit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aHand := cards.Hand(a.Hands[0].Cards)
|
||||
|
||||
if _, _, err := ApplyMove(s, Hit); err != nil { // same start, applied again
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cards.Hand(a.Hands[0].Cards); got != aHand {
|
||||
t.Fatalf("the first hand changed under us: %q became %q", aHand, got)
|
||||
}
|
||||
if got := cards.Hand(s.Hands[0].Cards); got != before {
|
||||
t.Fatalf("ApplyMove mutated the state it was given: %q became %q", before, got)
|
||||
}
|
||||
}
|
||||
402
internal/games/blackjack/split_test.go
Normal file
402
internal/games/blackjack/split_test.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package blackjack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// Split is the only move in blackjack that takes chips out of a player's stack
|
||||
// after the cards are already out, so most of what can go wrong with it is money
|
||||
// rather than cards. These tests are mostly about the money.
|
||||
|
||||
// dealt builds a state mid-hand: the player holding `player`, the dealer showing
|
||||
// `dealer`, and a shoe stacked with `shoe` so the next cards are known. It skips
|
||||
// New() because a split needs a pair, and waiting for one out of a shuffled shoe
|
||||
// is not a test, it's a slot machine.
|
||||
func dealt(bet int64, player, dealer, shoe []cards.Card) State {
|
||||
return State{
|
||||
Rules: DefaultRules(),
|
||||
Deck: cards.Deck(shoe),
|
||||
Dealer: dealer,
|
||||
Hands: []Hand{{Cards: player, Bet: bet}},
|
||||
Bet: bet,
|
||||
Phase: PhasePlayer,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitDealsTwoHandsAndTakesASecondBet(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(8, 8),
|
||||
hand(cards.King, 6),
|
||||
hand(3, 9, 5, 5), // one card to each hand, then whatever the dealer needs
|
||||
)
|
||||
if !s.CanSplit() {
|
||||
t.Fatal("CanSplit says no to a pair of eights")
|
||||
}
|
||||
if s.SplitCost() != 100 {
|
||||
t.Fatalf("SplitCost = %d, want the same bet again (100)", s.SplitCost())
|
||||
}
|
||||
|
||||
s, evs, err := ApplyMove(s, Split)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Hands) != 2 {
|
||||
t.Fatalf("split made %d hands, want 2", len(s.Hands))
|
||||
}
|
||||
for i, h := range s.Hands {
|
||||
if len(h.Cards) != 2 {
|
||||
t.Errorf("hand %d holds %d cards after the split, want 2", i, len(h.Cards))
|
||||
}
|
||||
if h.Cards[0].Rank != 8 {
|
||||
t.Errorf("hand %d didn't keep an eight: %s", i, cards.Hand(h.Cards))
|
||||
}
|
||||
if h.Bet != 100 {
|
||||
t.Errorf("hand %d carries a bet of %d, want 100", i, h.Bet)
|
||||
}
|
||||
if !h.Split {
|
||||
t.Errorf("hand %d isn't marked as split", i)
|
||||
}
|
||||
}
|
||||
// The whole point, in one line: there is twice as much on the table as there
|
||||
// was, and the shell has to have taken it.
|
||||
if s.Bet != 200 {
|
||||
t.Fatalf("total stake = %d after splitting a 100 hand, want 200", s.Bet)
|
||||
}
|
||||
if s.Active != 0 {
|
||||
t.Fatalf("active hand = %d, want the left one first", s.Active)
|
||||
}
|
||||
|
||||
var split, cardsDealt int
|
||||
for _, e := range evs {
|
||||
switch e.Kind {
|
||||
case "split":
|
||||
split++
|
||||
case "player_card":
|
||||
cardsDealt++
|
||||
}
|
||||
}
|
||||
if split != 1 || cardsDealt != 2 {
|
||||
t.Fatalf("the split emitted %d split and %d card events, want 1 and 2", split, cardsDealt)
|
||||
}
|
||||
}
|
||||
|
||||
// Split aces get one card each and no further say. Without that rule a pair of
|
||||
// aces is the best hand in the game every single time.
|
||||
func TestSplitAcesGetOneCardEachAndNoMore(t *testing.T) {
|
||||
s := dealt(50,
|
||||
hand(cards.Ace, cards.Ace),
|
||||
hand(9, 7), // dealer sits on 16, has to draw
|
||||
hand(cards.King, 9, 5),
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, h := range s.Hands {
|
||||
if len(h.Cards) != 2 {
|
||||
t.Errorf("split ace %d holds %d cards, want exactly 2", i, len(h.Cards))
|
||||
}
|
||||
if !h.Done {
|
||||
t.Errorf("split ace %d is still being asked for a decision", i)
|
||||
}
|
||||
}
|
||||
// Both hands are finished the moment they're dealt, so the dealer plays and
|
||||
// the deal settles without the player ever acting again.
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("phase = %q after splitting aces, want the deal to have run to the end", s.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
// An ace and a ten on a split hand is twenty-one. It is not a blackjack, and the
|
||||
// house does not pay 3:2 for it — which is the single most expensive rule in this
|
||||
// file, because splitting aces makes 21s for a living.
|
||||
func TestTwentyOneOnASplitHandIsNotANatural(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(cards.Ace, cards.Ace),
|
||||
hand(cards.King, 7), // dealer stands on 17
|
||||
hand(cards.King, cards.Queen),
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("phase = %q, want done", s.Phase)
|
||||
}
|
||||
for i, h := range s.Hands {
|
||||
if v, _ := h.Value(); v != 21 {
|
||||
t.Fatalf("hand %d is %d, want the 21 this test is about", i, v)
|
||||
}
|
||||
if h.Natural() {
|
||||
t.Errorf("hand %d counts as a natural — a split 21 must not pay 3:2", i)
|
||||
}
|
||||
if h.Outcome != OutcomeWin {
|
||||
t.Errorf("hand %d settled as %q, want a plain win", i, h.Outcome)
|
||||
}
|
||||
// 100 staked, 100 profit, 5% rake off the profit: 195 back, not 245.
|
||||
if h.Payout != 195 {
|
||||
t.Errorf("hand %d paid %d, want 195 (a 1:1 win less the rake), not a 3:2 payout", i, h.Payout)
|
||||
}
|
||||
}
|
||||
if s.Payout != 390 {
|
||||
t.Fatalf("the deal paid %d, want 390", s.Payout)
|
||||
}
|
||||
}
|
||||
|
||||
// Same rank, not same value. A king and a queen are both worth ten and are not a
|
||||
// pair.
|
||||
func TestSplitNeedsAPairOfTheSameRank(t *testing.T) {
|
||||
s := dealt(100, hand(cards.King, cards.Queen), hand(9, 9), hand(5))
|
||||
if s.CanSplit() {
|
||||
t.Error("CanSplit says a king and a queen are a pair")
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Split); err != ErrCantSplit {
|
||||
t.Errorf("splitting K+Q gave %v, want ErrCantSplit", err)
|
||||
}
|
||||
|
||||
// And you cannot split a hand you have already hit.
|
||||
s = dealt(100, hand(8, 8, 5), hand(9, 9), hand(5))
|
||||
if s.CanSplit() {
|
||||
t.Error("CanSplit says yes to a three-card hand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplittingStopsAtFourHands(t *testing.T) {
|
||||
// Every card an eight, so every hand splits again for as long as it's allowed.
|
||||
shoe := make([]cards.Card, 0, 12)
|
||||
for i := 0; i < 12; i++ {
|
||||
shoe = append(shoe, cards.Card{Rank: 8, Suit: cards.Spades})
|
||||
}
|
||||
s := dealt(100, hand(8, 8), hand(9, 7), shoe)
|
||||
|
||||
for i := 0; i < MaxHands-1; i++ {
|
||||
var err error
|
||||
if s, _, err = ApplyMove(s, Split); err != nil {
|
||||
t.Fatalf("split %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
if len(s.Hands) != MaxHands {
|
||||
t.Fatalf("ended with %d hands, want %d", len(s.Hands), MaxHands)
|
||||
}
|
||||
if s.CanSplit() {
|
||||
t.Fatalf("CanSplit says yes at %d hands", MaxHands)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Split); err != ErrCantSplit {
|
||||
t.Errorf("the fifth split gave %v, want ErrCantSplit", err)
|
||||
}
|
||||
if s.Bet != 400 {
|
||||
t.Errorf("four hands of 100 = %d staked, want 400", s.Bet)
|
||||
}
|
||||
}
|
||||
|
||||
// Each hand is raked on its own winnings. Netting the hands against each other
|
||||
// first would mean a player who wins one and loses one pays no rake at all, which
|
||||
// is not a rake, it's a discount for splitting.
|
||||
func TestEachSplitHandIsSettledAndRakedOnItsOwn(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(8, 8),
|
||||
hand(cards.King, 9), // dealer stands on 19
|
||||
// left hand gets a 2 (10, then it hits to 20 and stands);
|
||||
// right hand gets a 3 (11) and will bust on a king.
|
||||
hand(2, 3, cards.King, cards.King),
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // left: 8+2+K = 20
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, _ := s.Hands[0].Value(); v != 20 {
|
||||
t.Fatalf("left hand is %d, want 20", v)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Stand); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Active != 1 {
|
||||
t.Fatalf("standing on the left hand left the active hand at %d, want 1", s.Active)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // right: 8+3+K = 21... no: 8+3=11, +K = 21
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("phase = %q, want done", s.Phase)
|
||||
}
|
||||
|
||||
left, right := s.Hands[0], s.Hands[1]
|
||||
if left.Outcome != OutcomeWin { // 20 beats 19
|
||||
t.Errorf("left hand settled %q, want a win", left.Outcome)
|
||||
}
|
||||
if right.Outcome != OutcomeWin { // 21 beats 19
|
||||
t.Errorf("right hand settled %q, want a win", right.Outcome)
|
||||
}
|
||||
// Two 100 hands, each winning 100, each raked 5 on its own profit.
|
||||
if left.Rake != 5 || right.Rake != 5 || s.Rake != 10 {
|
||||
t.Errorf("rake = %d/%d (total %d), want 5/5 (10) — each hand raked on its own winnings", left.Rake, right.Rake, s.Rake)
|
||||
}
|
||||
if s.Payout != 390 || s.Net() != 190 {
|
||||
t.Errorf("payout = %d net = %d, want 390 and 190", s.Payout, s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
// A hand that busts loses its own bet and nothing else. The other hand is a
|
||||
// separate bet and settles on its own merits.
|
||||
func TestBustingOneSplitHandDoesNotTouchTheOther(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(9, 9),
|
||||
hand(cards.King, 8), // dealer stands on 18
|
||||
hand(5, 2, cards.King, cards.King),
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split) // left: 9+5=14, right: 9+2=11
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // left: 14+K = 24, bust
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Hands[0].Outcome != "" && s.Hands[0].Outcome != OutcomeBust {
|
||||
t.Fatalf("left hand outcome %q before settling", s.Hands[0].Outcome)
|
||||
}
|
||||
if !s.Hands[0].Done || s.Active != 1 {
|
||||
t.Fatalf("a bust hand didn't hand over: done=%v active=%d", s.Hands[0].Done, s.Active)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // right: 11+K = 21
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if s.Hands[0].Outcome != OutcomeBust || s.Hands[0].Payout != 0 {
|
||||
t.Errorf("left hand = %q paying %d, want a bust paying nothing", s.Hands[0].Outcome, s.Hands[0].Payout)
|
||||
}
|
||||
if s.Hands[1].Outcome != OutcomeWin || s.Hands[1].Payout != 195 {
|
||||
t.Errorf("right hand = %q paying %d, want a win paying 195", s.Hands[1].Outcome, s.Hands[1].Payout)
|
||||
}
|
||||
// Staked 200, got 195 back: down 5 on the deal, and the word for that is lose.
|
||||
if s.Payout != 195 || s.Net() != -5 || s.Outcome != OutcomeLose {
|
||||
t.Errorf("deal settled %q paying %d (net %d), want lose/195/-5", s.Outcome, s.Payout, s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
// If every hand busts there is nothing left to beat, and the dealer does not turn
|
||||
// over — same as it always was with one hand.
|
||||
func TestTheDealerDoesNotPlayWhenEveryHandIsBust(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(9, 9),
|
||||
hand(cards.King, 6),
|
||||
hand(8, 8, cards.King, cards.King, 4), // both hands reach 17 then bust on a king
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // left 9+8+K = 27
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // right 9+8+K = 27
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("phase = %q, want done", s.Phase)
|
||||
}
|
||||
if len(s.Dealer) != 2 {
|
||||
t.Errorf("the dealer drew to %d cards with every hand already bust", len(s.Dealer))
|
||||
}
|
||||
if s.Payout != 0 {
|
||||
t.Errorf("two bust hands paid %d, want nothing", s.Payout)
|
||||
}
|
||||
}
|
||||
|
||||
// The dealer not drawing is not the same as the dealer not turning over. The
|
||||
// browser has been showing a face-down card since the deal, and the settled state
|
||||
// it gets handed has the dealer's whole total on it — so a bust-out still owes it
|
||||
// a reveal, or the felt prints a nineteen under a card nobody has looked at.
|
||||
func TestBustingOutStillTurnsTheHoleCardOver(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(cards.King, 6),
|
||||
hand(cards.King, 9),
|
||||
hand(cards.King), // 16 + K = 26
|
||||
)
|
||||
s, evs, err := ApplyMove(s, Hit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone || s.Outcome != OutcomeBust {
|
||||
t.Fatalf("phase/outcome = %q/%q, want done/bust", s.Phase, s.Outcome)
|
||||
}
|
||||
var reveal bool
|
||||
for _, e := range evs {
|
||||
if e.Kind == "reveal" {
|
||||
reveal = true
|
||||
}
|
||||
}
|
||||
if !reveal {
|
||||
t.Error("the player busted out and the hole card was never revealed")
|
||||
}
|
||||
}
|
||||
|
||||
// Doubling after a split doubles *that hand's* bet, not the whole table's.
|
||||
func TestDoublingAfterASplitOnlyDoublesThatHand(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(5, 5),
|
||||
hand(cards.King, 7),
|
||||
hand(6, 4, 9, cards.King),
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split) // left: 5+6=11, right: 5+4=9
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.DoubleCost() != 100 {
|
||||
t.Fatalf("DoubleCost = %d on a split hand of 100, want 100", s.DoubleCost())
|
||||
}
|
||||
s, _, err = ApplyMove(s, Double) // left doubles: 11 + 9 = 20
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Hands[0].Bet != 200 || s.Hands[1].Bet != 100 {
|
||||
t.Fatalf("bets are %d/%d after doubling the left hand, want 200/100", s.Hands[0].Bet, s.Hands[1].Bet)
|
||||
}
|
||||
if s.Bet != 300 {
|
||||
t.Fatalf("total staked = %d, want 300 (100 + a doubled 100 + 100)", s.Bet)
|
||||
}
|
||||
if !s.Hands[0].Done || s.Active != 1 {
|
||||
t.Fatal("a doubled hand takes one card and hands over")
|
||||
}
|
||||
}
|
||||
|
||||
// A live hand outlives a deploy. The blobs written before split existed have a
|
||||
// "player" array and no "hands", and a state that decodes to no hands at all is a
|
||||
// player whose cards vanished mid-deal.
|
||||
func TestALiveHandDealtBeforeSplitExistedStillLoads(t *testing.T) {
|
||||
legacy := []byte(`{
|
||||
"rules": {"decks": 6, "blackjack_pays": 1.5, "dealer_hits_soft17": true, "rake_pct": 0.05},
|
||||
"deck": [],
|
||||
"player": [{"r": 10, "s": 1}, {"r": 7, "s": 2}],
|
||||
"dealer": [{"r": 9, "s": 0}, {"r": 5, "s": 3}],
|
||||
"bet": 250,
|
||||
"doubled": false,
|
||||
"phase": "player"
|
||||
}`)
|
||||
|
||||
var s State
|
||||
if err := json.Unmarshal(legacy, &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Hands) != 1 {
|
||||
t.Fatalf("an old live hand decoded to %d hands, want 1 — the player's cards went missing", len(s.Hands))
|
||||
}
|
||||
if len(s.Hands[0].Cards) != 2 {
|
||||
t.Fatalf("the revived hand holds %d cards, want 2", len(s.Hands[0].Cards))
|
||||
}
|
||||
if s.Hands[0].Bet != 250 || s.Bet != 250 {
|
||||
t.Fatalf("the revived hand carries %d of the %d staked, want all of it", s.Hands[0].Bet, s.Bet)
|
||||
}
|
||||
if v, _ := s.Hands[0].Value(); v != 17 {
|
||||
t.Fatalf("the revived hand is worth %d, want 17", v)
|
||||
}
|
||||
// And it can still be played to the end.
|
||||
if _, _, err := ApplyMove(s, Stand); err != nil {
|
||||
t.Fatalf("the revived hand could not be stood on: %v", err)
|
||||
}
|
||||
}
|
||||
126
internal/games/cards/cards.go
Normal file
126
internal/games/cards/cards.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Package cards holds the deck primitives every card game on Pete shares.
|
||||
//
|
||||
// gogobee never had this: blackjack carried its own deck, UNO carried another,
|
||||
// and hold'em leaned on a third-party one. Three shuffles, three bugs to fix
|
||||
// three times. The games ported over here consolidate onto this instead.
|
||||
//
|
||||
// Two rules hold throughout:
|
||||
//
|
||||
// The RNG is threaded, never global. Every shuffle takes an explicit *rand.Rand,
|
||||
// so a hand is reproducible from its seed — which is what makes the engines
|
||||
// testable, and what lets us re-deal a disputed hand and show the player exactly
|
||||
// what the shoe did.
|
||||
//
|
||||
// A Deck is a plain value. No pointers into it, no timers hanging off it, so a
|
||||
// game in progress serializes to JSON and survives a redeploy.
|
||||
package cards
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// Suit is one of the four French suits.
|
||||
type Suit uint8
|
||||
|
||||
const (
|
||||
Spades Suit = iota
|
||||
Hearts
|
||||
Diamonds
|
||||
Clubs
|
||||
)
|
||||
|
||||
// Rank runs Ace(1) through King(13). Ace is low here; games that want it high
|
||||
// (blackjack's soft 11, hold'em's wheel) say so themselves.
|
||||
type Rank uint8
|
||||
|
||||
const (
|
||||
Ace Rank = 1
|
||||
Jack Rank = 11
|
||||
Queen Rank = 12
|
||||
King Rank = 13
|
||||
)
|
||||
|
||||
var (
|
||||
suitGlyphs = [4]string{"♠", "♥", "♦", "♣"}
|
||||
rankNames = [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
|
||||
)
|
||||
|
||||
// Card is one playing card. The short JSON keys keep a serialized shoe small —
|
||||
// a six-deck blackjack state is 312 of these.
|
||||
type Card struct {
|
||||
Rank Rank `json:"r"`
|
||||
Suit Suit `json:"s"`
|
||||
}
|
||||
|
||||
// String renders the card the way a table shows it: "A♠", "10♥".
|
||||
func (c Card) String() string {
|
||||
if c.Rank < Ace || c.Rank > King || c.Suit > Clubs {
|
||||
return "??"
|
||||
}
|
||||
return rankNames[c.Rank] + suitGlyphs[c.Suit]
|
||||
}
|
||||
|
||||
// Red reports whether the card is a red suit — the one thing every renderer
|
||||
// needs and nobody should re-derive.
|
||||
func (c Card) Red() bool { return c.Suit == Hearts || c.Suit == Diamonds }
|
||||
|
||||
// Deck is an ordered pile of cards. The next card to come off is at index 0.
|
||||
type Deck []Card
|
||||
|
||||
// NewDeck builds n standard 52-card decks in fixed order. Shuffle before use:
|
||||
// an unshuffled deck is a bug at a table, but it's exactly what a test wants.
|
||||
func NewDeck(n int) Deck {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
d := make(Deck, 0, 52*n)
|
||||
for i := 0; i < n; i++ {
|
||||
for s := Spades; s <= Clubs; s++ {
|
||||
for r := Ace; r <= King; r++ {
|
||||
d = append(d, Card{Rank: r, Suit: s})
|
||||
}
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Shuffle permutes the deck in place using the supplied RNG. Passing a seeded
|
||||
// *rand.Rand gives the same shuffle every time, which is the whole point.
|
||||
func (d Deck) Shuffle(rng *rand.Rand) {
|
||||
rng.Shuffle(len(d), func(i, j int) { d[i], d[j] = d[j], d[i] })
|
||||
}
|
||||
|
||||
// Draw takes the top card. ok is false when the deck is spent; the caller
|
||||
// decides whether that means reshuffle or fold, because the two games that hit
|
||||
// it disagree.
|
||||
func (d *Deck) Draw() (c Card, ok bool) {
|
||||
if len(*d) == 0 {
|
||||
return Card{}, false
|
||||
}
|
||||
c = (*d)[0]
|
||||
*d = (*d)[1:]
|
||||
return c, true
|
||||
}
|
||||
|
||||
// Hand renders a run of cards for display: "A♠ 10♥".
|
||||
func Hand(cs []Card) string {
|
||||
s := ""
|
||||
for i, c := range cs {
|
||||
if i > 0 {
|
||||
s += " "
|
||||
}
|
||||
s += c.String()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// NewRNG seeds a generator from two uint64s. Games store the seed alongside the
|
||||
// hand so a finished hand can be replayed exactly as it was dealt.
|
||||
func NewRNG(seed1, seed2 uint64) *rand.Rand {
|
||||
return rand.New(rand.NewPCG(seed1, seed2))
|
||||
}
|
||||
|
||||
func (s Suit) String() string {
|
||||
if s > Clubs {
|
||||
return "?"
|
||||
}
|
||||
return suitGlyphs[s]
|
||||
}
|
||||
102
internal/games/cards/cards_test.go
Normal file
102
internal/games/cards/cards_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package cards
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewDeck_IsAFullShoe(t *testing.T) {
|
||||
d := NewDeck(6)
|
||||
if len(d) != 312 {
|
||||
t.Fatalf("six decks hold %d cards, want 312", len(d))
|
||||
}
|
||||
seen := map[Card]int{}
|
||||
for _, c := range d {
|
||||
seen[c]++
|
||||
}
|
||||
if len(seen) != 52 {
|
||||
t.Fatalf("%d distinct cards, want 52", len(seen))
|
||||
}
|
||||
for c, n := range seen {
|
||||
if n != 6 {
|
||||
t.Fatalf("%s appears %d times in a six-deck shoe, want 6", c, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeck_ClampsToAtLeastOne(t *testing.T) {
|
||||
if len(NewDeck(0)) != 52 {
|
||||
t.Fatal("a zero-deck shoe should still hold one deck")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShuffle_SameSeedSameOrder(t *testing.T) {
|
||||
a, b := NewDeck(1), NewDeck(1)
|
||||
a.Shuffle(NewRNG(99, 1))
|
||||
b.Shuffle(NewRNG(99, 1))
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
t.Fatalf("same seed diverged at %d: %s vs %s", i, a[i], b[i])
|
||||
}
|
||||
}
|
||||
// And a different seed must not give the same order, or the RNG isn't wired up.
|
||||
c := NewDeck(1)
|
||||
c.Shuffle(NewRNG(100, 1))
|
||||
same := true
|
||||
for i := range a {
|
||||
if a[i] != c[i] {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if same {
|
||||
t.Fatal("a different seed produced an identical shuffle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShuffle_KeepsEveryCard(t *testing.T) {
|
||||
d := NewDeck(1)
|
||||
d.Shuffle(NewRNG(4, 4))
|
||||
seen := map[Card]bool{}
|
||||
for _, c := range d {
|
||||
seen[c] = true
|
||||
}
|
||||
if len(d) != 52 || len(seen) != 52 {
|
||||
t.Fatalf("shuffle lost cards: %d cards, %d distinct", len(d), len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_TakesFromTheTopAndRunsOut(t *testing.T) {
|
||||
d := NewDeck(1)
|
||||
top := d[0]
|
||||
c, ok := d.Draw()
|
||||
if !ok || c != top {
|
||||
t.Fatalf("drew %s (ok=%v), want the top card %s", c, ok, top)
|
||||
}
|
||||
if len(d) != 51 {
|
||||
t.Fatalf("deck has %d cards after one draw, want 51", len(d))
|
||||
}
|
||||
for len(d) > 0 {
|
||||
d.Draw()
|
||||
}
|
||||
if _, ok := d.Draw(); ok {
|
||||
t.Fatal("an empty deck kept dealing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCard_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
card Card
|
||||
want string
|
||||
}{
|
||||
{Card{Ace, Spades}, "A♠"},
|
||||
{Card{10, Hearts}, "10♥"},
|
||||
{Card{King, Clubs}, "K♣"},
|
||||
{Card{Rank: 99, Suit: Spades}, "??"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := tc.card.String(); got != tc.want {
|
||||
t.Errorf("String() = %q, want %q", got, tc.want)
|
||||
}
|
||||
}
|
||||
if !(Card{Ace, Hearts}).Red() || (Card{Ace, Spades}).Red() {
|
||||
t.Error("Red() disagrees about which suits are red")
|
||||
}
|
||||
}
|
||||
400
internal/games/hangman/hangman.go
Normal file
400
internal/games/hangman/hangman.go
Normal file
@@ -0,0 +1,400 @@
|
||||
// Package hangman is a pure hangman engine, played for chips.
|
||||
//
|
||||
// Same seam as blackjack: ApplyMove(state, move) (state, events, error), where
|
||||
// an error means the move was illegal and nothing else. No HTTP, no timers, no
|
||||
// player names. The state is a plain value, so a game survives a redeploy and
|
||||
// replays from its seed.
|
||||
//
|
||||
// The casino version differs from the one gogobee plays in Matrix in one way
|
||||
// that matters: there is money on it. That makes the gallows a payout meter as
|
||||
// well as a death clock — every wrong guess takes a tenth off what a win is
|
||||
// worth. You can still win from five wrong, you just won't win much.
|
||||
package hangman
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Errors an illegal move can produce.
|
||||
var (
|
||||
ErrGameOver = errors.New("hangman: the game is already over")
|
||||
ErrNotALetter = errors.New("hangman: that is not a letter")
|
||||
ErrAlreadyTried = errors.New("hangman: that letter has been tried")
|
||||
ErrUnknownMove = errors.New("hangman: unknown move")
|
||||
ErrBadBet = errors.New("hangman: bet must be positive")
|
||||
ErrEmptySolution = errors.New("hangman: guess something")
|
||||
ErrUnknownTier = errors.New("hangman: no such tier")
|
||||
)
|
||||
|
||||
// MaxWrong is how many wrong guesses the gallows holds: head, body, two arms,
|
||||
// two legs. It is not a tier setting — the drawing has six parts, so the game
|
||||
// has six lives, and the tiers vary what a win pays instead.
|
||||
const MaxWrong = 6
|
||||
|
||||
// Decay is what one wrong guess costs, as a fraction of the tier's base
|
||||
// multiple. Six wrong is death, so the worst a living player can be is 50% off.
|
||||
const Decay = 0.10
|
||||
|
||||
// Tier is a difficulty, chosen before the bet. Short phrases pay the most:
|
||||
// there is less of them to read, and fewer distinct letters to hit by accident.
|
||||
// A long phrase mostly reveals itself.
|
||||
type Tier struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Min int `json:"min"` // phrase length, in characters
|
||||
Max int `json:"max"` // inclusive
|
||||
Base float64 `json:"base"` // what a win pays, before any wrong guesses
|
||||
Blurb string `json:"blurb"`
|
||||
}
|
||||
|
||||
// Tiers are the three tables. The bank has 74 short phrases, 67 medium and 64
|
||||
// long, so none of them runs thin.
|
||||
var Tiers = []Tier{
|
||||
{Slug: "short", Name: "Short", Min: 8, Max: 20, Base: 2.6,
|
||||
Blurb: "A handful of letters and nothing to go on."},
|
||||
{Slug: "medium", Name: "Medium", Min: 21, Max: 40, Base: 2.0,
|
||||
Blurb: "Long enough to read once it starts falling open."},
|
||||
{Slug: "long", Name: "Long", Min: 41, Max: 9999, Base: 1.6,
|
||||
Blurb: "It gives itself away. It also pays the least."},
|
||||
}
|
||||
|
||||
// TierBySlug finds a tier by the name the browser sent.
|
||||
func TierBySlug(slug string) (Tier, error) {
|
||||
for _, t := range Tiers {
|
||||
if t.Slug == slug {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return Tier{}, ErrUnknownTier
|
||||
}
|
||||
|
||||
// Phase is where the game is.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlaying Phase = "playing"
|
||||
PhaseDone Phase = "done"
|
||||
)
|
||||
|
||||
// Outcome is how it ended.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeNone Outcome = ""
|
||||
OutcomeSolved Outcome = "solved" // guessed the phrase outright
|
||||
OutcomeFilled Outcome = "filled" // revealed the last letter
|
||||
OutcomeHung Outcome = "hung" // six wrong
|
||||
)
|
||||
|
||||
// Won reports whether this outcome pays.
|
||||
func (o Outcome) Won() bool { return o == OutcomeSolved || o == OutcomeFilled }
|
||||
|
||||
// State is one game. The phrase is in here, which is exactly why this value
|
||||
// never leaves the server — the browser gets a Masked() view of it instead.
|
||||
type State struct {
|
||||
Tier Tier `json:"tier"`
|
||||
Phrase string `json:"phrase"`
|
||||
Runes []rune `json:"runes"` // the phrase, indexable safely
|
||||
Shown []bool `json:"shown"` // one per rune: is it face up
|
||||
Tried []rune `json:"tried"` // every letter guessed, in order, right or wrong
|
||||
Wrong []rune `json:"wrong"` // just the ones that missed
|
||||
RakePct float64 `json:"rake_pct"`
|
||||
|
||||
Bet int64 `json:"bet"`
|
||||
Phase Phase `json:"phase"`
|
||||
Outcome Outcome `json:"outcome"`
|
||||
Payout int64 `json:"payout"`
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Event is something the table animates: a letter turning over, a limb being
|
||||
// drawn. The engine emits them rather than drawing anything itself.
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // "start" | "hit" | "miss" | "solve" | "settle"
|
||||
Letter string `json:"letter,omitempty"` // the letter guessed
|
||||
At []int `json:"at,omitempty"` // which rune positions it turned over
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// Move is a player action: a single letter, or the whole phrase.
|
||||
type Move struct {
|
||||
Letter string `json:"letter"`
|
||||
Solve string `json:"solve"`
|
||||
}
|
||||
|
||||
// New starts a game on a phrase drawn from the tier's shelf of the bank.
|
||||
func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
phrase, err := drawPhrase(t, rng)
|
||||
if err != nil {
|
||||
return State{}, nil, err
|
||||
}
|
||||
return start(bet, t, phrase, rakePct)
|
||||
}
|
||||
|
||||
// start builds the opening state for a known phrase. Split out from New so a
|
||||
// test can pin the phrase instead of the seed.
|
||||
func start(bet int64, t Tier, phrase string, rakePct float64) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
rs := []rune(phrase)
|
||||
s := State{
|
||||
Tier: t, Phrase: phrase, Runes: rs,
|
||||
Shown: make([]bool, len(rs)),
|
||||
RakePct: rakePct,
|
||||
Bet: bet, Phase: PhasePlaying,
|
||||
}
|
||||
// Spaces and punctuation are never guessed — they start face up, because a
|
||||
// row of blanks with the word breaks hidden is a puzzle about typography.
|
||||
for i, r := range rs {
|
||||
if !Guessable(r) {
|
||||
s.Shown[i] = true
|
||||
}
|
||||
}
|
||||
return s, []Event{{Kind: "start"}}, nil
|
||||
}
|
||||
|
||||
// Guessable reports whether a rune is one you'd guess: a letter or a digit.
|
||||
// Everything else is scaffolding and is shown from the start.
|
||||
//
|
||||
// Exported because the renderer needs the same answer — a rune you'd guess is a
|
||||
// rune that gets a tile to guess it into. Asking the drawing side to decide that
|
||||
// for itself is how you get a board with no tile for a letter the engine is
|
||||
// waiting on, and a phrase that cannot be finished.
|
||||
func Guessable(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
|
||||
// ApplyMove is the engine. A legal move in, the new state and what happened out.
|
||||
// An error means the move was illegal and the caller's state is untouched.
|
||||
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
if s.Phase == PhaseDone {
|
||||
return s, nil, ErrGameOver
|
||||
}
|
||||
s = s.clone()
|
||||
|
||||
switch {
|
||||
case m.Solve != "":
|
||||
return applySolve(s, m.Solve)
|
||||
case m.Letter != "":
|
||||
return applyLetter(s, m.Letter)
|
||||
default:
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
}
|
||||
|
||||
// applyLetter guesses one letter.
|
||||
func applyLetter(s State, letter string) (State, []Event, error) {
|
||||
rs := []rune(strings.ToLower(strings.TrimSpace(letter)))
|
||||
if len(rs) != 1 || !Guessable(rs[0]) {
|
||||
return s, nil, ErrNotALetter
|
||||
}
|
||||
g := rs[0]
|
||||
if containsRune(s.Tried, g) {
|
||||
// Not a miss — a no-op. Charging a life for a letter the board already
|
||||
// shows you tried is punishing a mis-click, not a bad guess.
|
||||
return s, nil, ErrAlreadyTried
|
||||
}
|
||||
s.Tried = append(s.Tried, g)
|
||||
|
||||
var at []int
|
||||
for i, r := range s.Runes {
|
||||
if !s.Shown[i] && foldEq(r, g) {
|
||||
s.Shown[i] = true
|
||||
at = append(at, i)
|
||||
}
|
||||
}
|
||||
|
||||
evs := []Event{}
|
||||
if len(at) > 0 {
|
||||
evs = append(evs, Event{Kind: "hit", Letter: string(g), At: at})
|
||||
if s.filled() {
|
||||
s.settle(OutcomeFilled, &evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
s.Wrong = append(s.Wrong, g)
|
||||
evs = append(evs, Event{Kind: "miss", Letter: string(g)})
|
||||
if len(s.Wrong) >= MaxWrong {
|
||||
s.settle(OutcomeHung, &evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// applySolve guesses the whole phrase. Right, and it's over at the multiple you
|
||||
// still hold. Wrong, and it costs a life like any other bad guess — otherwise
|
||||
// solving would be a free roll you could spam until it landed.
|
||||
func applySolve(s State, attempt string) (State, []Event, error) {
|
||||
if strings.TrimSpace(attempt) == "" {
|
||||
return s, nil, ErrEmptySolution
|
||||
}
|
||||
evs := []Event{{Kind: "solve", Text: attempt}}
|
||||
|
||||
if normalize(attempt) == normalize(s.Phrase) {
|
||||
for i := range s.Shown {
|
||||
s.Shown[i] = true
|
||||
}
|
||||
s.settle(OutcomeSolved, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// A wrong solve is a miss with no letter attached to it.
|
||||
s.Wrong = append(s.Wrong, '·')
|
||||
evs = append(evs, Event{Kind: "miss", Text: attempt})
|
||||
if len(s.Wrong) >= MaxWrong {
|
||||
s.settle(OutcomeHung, &evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// Multiple is what a win is worth right now: the tier's base, less a tenth of
|
||||
// it for every wrong guess so far. Floored at 1, so a win never hands back less
|
||||
// than the stake — a player who fought through five wrong guesses and got there
|
||||
// has not earned a loss.
|
||||
func (s State) Multiple() float64 {
|
||||
m := s.Tier.Base * (1 - Decay*float64(len(s.Wrong)))
|
||||
if m < 1 {
|
||||
return 1
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Pays is what a win *right now* would actually put back on the player's stack:
|
||||
// the stake, plus the winnings, less the house's cut of the winnings.
|
||||
//
|
||||
// It exists because the felt shows this number while the game is still running,
|
||||
// and settle() is the only other thing that computes it. If the two ever
|
||||
// disagreed the table would be quoting a payout it doesn't honour — so settle
|
||||
// calls this rather than doing the sum a second time.
|
||||
func (s State) Pays() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
|
||||
if total < s.Bet {
|
||||
total = s.Bet // a win never hands back less than the stake
|
||||
}
|
||||
profit := total - s.Bet
|
||||
if profit > 0 {
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake < 0 {
|
||||
rake = 0
|
||||
}
|
||||
profit -= rake
|
||||
}
|
||||
return s.Bet + profit
|
||||
}
|
||||
|
||||
// Rake taken on a win right now — the other half of what Pays works out.
|
||||
func (s State) rakeNow() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
|
||||
if total < s.Bet {
|
||||
return 0
|
||||
}
|
||||
profit := total - s.Bet
|
||||
if profit <= 0 {
|
||||
return 0
|
||||
}
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake < 0 {
|
||||
return 0
|
||||
}
|
||||
return rake
|
||||
}
|
||||
|
||||
// Lives is how many wrong guesses are left.
|
||||
func (s State) Lives() int { return MaxWrong - len(s.Wrong) }
|
||||
|
||||
// filled reports whether every guessable rune is face up.
|
||||
func (s State) filled() bool {
|
||||
for _, up := range s.Shown {
|
||||
if !up {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// settle decides the payout. Same rule as blackjack: the rake comes out of
|
||||
// winnings, never out of the stake. A loss is never charged a fee.
|
||||
func (s *State) settle(o Outcome, evs *[]Event) {
|
||||
s.Outcome = o
|
||||
s.Phase = PhaseDone
|
||||
|
||||
if o.Won() {
|
||||
s.Payout = s.Pays()
|
||||
s.Rake = s.rakeNow()
|
||||
} else {
|
||||
s.Payout = 0
|
||||
}
|
||||
|
||||
// The phrase goes face up when it's over, win or lose. Losing without ever
|
||||
// being told the answer is the one thing hangman must never do.
|
||||
for i := range s.Shown {
|
||||
s.Shown[i] = true
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "settle", Text: string(o)})
|
||||
}
|
||||
|
||||
// Net is what the game did to the player's stack.
|
||||
func (s State) Net() int64 {
|
||||
if s.Phase != PhaseDone {
|
||||
return 0
|
||||
}
|
||||
return s.Payout - s.Bet
|
||||
}
|
||||
|
||||
// Masked is the phrase as the player may see it: revealed runes as themselves,
|
||||
// hidden ones as an underscore. This — not Phrase — is what crosses the wire.
|
||||
func (s State) Masked() string {
|
||||
var b strings.Builder
|
||||
for i, r := range s.Runes {
|
||||
if s.Shown[i] {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// clone deep-copies the slices, so a derived state shares no backing array with
|
||||
// the one it came from and a state can be replayed freely.
|
||||
func (s State) clone() State {
|
||||
s.Runes = append([]rune(nil), s.Runes...)
|
||||
s.Shown = append([]bool(nil), s.Shown...)
|
||||
s.Tried = append([]rune(nil), s.Tried...)
|
||||
s.Wrong = append([]rune(nil), s.Wrong...)
|
||||
return s
|
||||
}
|
||||
|
||||
// normalize flattens a phrase for comparison: case, spacing and punctuation all
|
||||
// stop mattering. "How are you gentlemen!!" is solved by "how are you gentlemen".
|
||||
func normalize(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if Guessable(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// foldEq compares two runes the way a guess should: case-insensitively.
|
||||
func foldEq(a, b rune) bool {
|
||||
return unicode.ToLower(a) == unicode.ToLower(b)
|
||||
}
|
||||
|
||||
func containsRune(rs []rune, r rune) bool {
|
||||
for _, x := range rs {
|
||||
if foldEq(x, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
369
internal/games/hangman/hangman_test.go
Normal file
369
internal/games/hangman/hangman_test.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package hangman
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// tierShort is the tier most tests play on: base 2.6.
|
||||
func tierShort(t *testing.T) Tier {
|
||||
t.Helper()
|
||||
tr, err := TierBySlug("short")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
// play runs a sequence of single-letter guesses against a pinned phrase.
|
||||
func play(t *testing.T, phrase string, bet int64, rake float64, guesses ...string) State {
|
||||
t.Helper()
|
||||
s, _, err := start(bet, tierShort(t), phrase, rake)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, g := range guesses {
|
||||
next, _, err := ApplyMove(s, Move{Letter: g})
|
||||
if err != nil {
|
||||
t.Fatalf("guess %q: %v", g, err)
|
||||
}
|
||||
s = next
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestStartShowsScaffoldingOnly(t *testing.T) {
|
||||
s, evs, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := s.Masked(), "______ ____"; got != want {
|
||||
t.Errorf("masked = %q, want %q — the space is scaffolding and shows, the letters don't", got, want)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "start" {
|
||||
t.Errorf("events = %+v, want one start", evs)
|
||||
}
|
||||
if s.Lives() != MaxWrong {
|
||||
t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPunctuationAndDigitsAreScaffoldingOrNot(t *testing.T) {
|
||||
// Punctuation shows from the start; a digit is guessable like a letter.
|
||||
s, _, err := start(100, tierShort(t), "Level 9!", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := s.Masked(), "_____ _!"; got != want {
|
||||
t.Fatalf("masked = %q, want %q", got, want)
|
||||
}
|
||||
next, _, err := ApplyMove(s, Move{Letter: "9"})
|
||||
if err != nil {
|
||||
t.Fatalf("guessing a digit: %v", err)
|
||||
}
|
||||
if got, want := next.Masked(), "_____ 9!"; got != want {
|
||||
t.Errorf("masked = %q, want %q — a digit is a guess", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHitRevealsEveryOccurrence(t *testing.T) {
|
||||
// "Blue Shell" has an l at 1, 8 and 9. One guess turns over all three.
|
||||
s := play(t, "Blue Shell", 100, 0.05, "l")
|
||||
if got, want := s.Masked(), "_l__ ___ll"; got != want {
|
||||
t.Errorf("masked = %q, want %q — every l turns over, not just the first", got, want)
|
||||
}
|
||||
if s.Lives() != MaxWrong {
|
||||
t.Errorf("a hit cost a life: lives = %d", s.Lives())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissCostsALifeAndTheMultiple(t *testing.T) {
|
||||
tr := tierShort(t)
|
||||
s := play(t, "Insert Coin", 100, 0.05, "z")
|
||||
if s.Lives() != MaxWrong-1 {
|
||||
t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong-1)
|
||||
}
|
||||
want := tr.Base * 0.9
|
||||
if got := s.Multiple(); got != want {
|
||||
t.Errorf("multiple = %v, want %v — one wrong guess is a tenth of the base", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatedLetterIsRefusedNotPunished(t *testing.T) {
|
||||
s := play(t, "Insert Coin", 100, 0.05, "z")
|
||||
_, _, err := ApplyMove(s, Move{Letter: "z"})
|
||||
if err != ErrAlreadyTried {
|
||||
t.Fatalf("err = %v, want ErrAlreadyTried", err)
|
||||
}
|
||||
// And the state the caller holds is untouched: a mis-click is not a life.
|
||||
if s.Lives() != MaxWrong-1 {
|
||||
t.Errorf("the refused move moved the state: lives = %d", s.Lives())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSixWrongHangsYouAndPaysNothing(t *testing.T) {
|
||||
s := play(t, "Insert Coin", 100, 0.05, "z", "x", "q", "y", "w", "k")
|
||||
if s.Phase != PhaseDone || s.Outcome != OutcomeHung {
|
||||
t.Fatalf("phase/outcome = %s/%s, want done/hung", s.Phase, s.Outcome)
|
||||
}
|
||||
if s.Payout != 0 {
|
||||
t.Errorf("payout = %d, want 0", s.Payout)
|
||||
}
|
||||
if s.Net() != -100 {
|
||||
t.Errorf("net = %d, want -100", s.Net())
|
||||
}
|
||||
if strings.Contains(s.Masked(), "_") {
|
||||
t.Error("a lost game must still show the phrase — being hung without being told the answer is the one thing hangman can't do")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillingTheLastLetterWinsCleanAtFullMultiple(t *testing.T) {
|
||||
// "Blue Shell" — every distinct letter, no misses.
|
||||
s := play(t, "Blue Shell", 100, 0, "b", "l", "u", "e", "s", "h")
|
||||
if s.Outcome != OutcomeFilled {
|
||||
t.Fatalf("outcome = %s, want filled", s.Outcome)
|
||||
}
|
||||
// Base 2.6, no wrong guesses, no rake: 100 -> 260.
|
||||
if s.Payout != 260 {
|
||||
t.Errorf("payout = %d, want 260", s.Payout)
|
||||
}
|
||||
if s.Net() != 160 {
|
||||
t.Errorf("net = %d, want 160", s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveOutrightWinsAtTheMultipleYouStillHold(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two wrong first: 2.6 -> 2.08.
|
||||
for _, g := range []string{"z", "x"} {
|
||||
s, _, err = ApplyMove(s, Move{Letter: g})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "insert coin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Outcome != OutcomeSolved {
|
||||
t.Fatalf("outcome = %s, want solved", s.Outcome)
|
||||
}
|
||||
if s.Payout != 208 {
|
||||
t.Errorf("payout = %d, want 208 — solving pays the multiple you still hold, not the base", s.Payout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveIgnoresCaseAndPunctuation(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "How are you gentlemen!!", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "HOW ARE YOU GENTLEMEN"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Outcome != OutcomeSolved {
|
||||
t.Errorf("outcome = %s — a solve shouldn't turn on shouting or the exclamation marks", s.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongSolveCostsALife(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "insert quarter"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Lives() != MaxWrong-1 {
|
||||
t.Errorf("lives = %d, want %d — a free solve is a solve you spam until it lands", s.Lives(), MaxWrong-1)
|
||||
}
|
||||
if s.Phase != PhasePlaying {
|
||||
t.Errorf("phase = %s, want playing", s.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWinNeverReturnsLessThanTheStake(t *testing.T) {
|
||||
// Long pays 1.6, and five wrong guesses would take it to 0.8 — under water.
|
||||
long, err := TierBySlug("long")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _, err := start(100, long, "the quick brown fox jumps over the lazy dog and keeps going", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Five clean misses. Every letter in the pangram is in the pangram, so the
|
||||
// only things guaranteed to miss it are digits.
|
||||
for _, g := range []string{"1", "2", "3", "4", "5"} {
|
||||
s, _, err = ApplyMove(s, Move{Letter: g})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := s.Multiple(); got != 1 {
|
||||
t.Fatalf("multiple = %v, want 1 — the floor", got)
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "the quick brown fox jumps over the lazy dog and keeps going"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Payout != 100 {
|
||||
t.Errorf("payout = %d, want 100 — a win hands back the stake at worst, never less", s.Payout)
|
||||
}
|
||||
if s.Rake != 0 {
|
||||
t.Errorf("rake = %d, want 0 — there was no profit to take a cut of", s.Rake)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRakeComesOutOfWinningsNeverTheStake(t *testing.T) {
|
||||
s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h")
|
||||
// Total 260, profit 160, rake 5% = 8, so 252 comes back.
|
||||
if s.Rake != 8 {
|
||||
t.Errorf("rake = %d, want 8 (5%% of the 160 profit)", s.Rake)
|
||||
}
|
||||
if s.Payout != 252 {
|
||||
t.Errorf("payout = %d, want 252", s.Payout)
|
||||
}
|
||||
if s.Payout < s.Bet {
|
||||
t.Error("the rake ate into the stake")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatTheFeltQuotesIsWhatTheHousePays(t *testing.T) {
|
||||
// The table shows Pays() while the game is still running. If that number and
|
||||
// the one settle() lands on ever came apart, the felt would be advertising a
|
||||
// payout the house doesn't honour. Walk a game and check they agree at every
|
||||
// step — including after a miss, which is where they'd drift.
|
||||
for _, wrong := range []int{0, 1, 2, 3} {
|
||||
s, _, err := start(200, tierShort(t), "Blue Shell", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
misses := []string{"z", "x", "q", "y"}
|
||||
for i := 0; i < wrong; i++ {
|
||||
s, _, err = ApplyMove(s, Move{Letter: misses[i]})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
quoted := s.Pays() // what the felt is telling the player right now
|
||||
|
||||
s, _, err = ApplyMove(s, Move{Solve: "blue shell"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Payout != quoted {
|
||||
t.Errorf("%d wrong: felt quoted %d, house paid %d", wrong, quoted, s.Payout)
|
||||
}
|
||||
if s.Payout != s.Bet+s.Net() {
|
||||
t.Errorf("%d wrong: payout %d doesn't square with net %d on a %d bet", wrong, s.Payout, s.Net(), s.Bet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveOnAFinishedGameIsRefused(t *testing.T) {
|
||||
s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h")
|
||||
if _, _, err := ApplyMove(s, Move{Letter: "z"}); err != ErrGameOver {
|
||||
t.Errorf("err = %v, want ErrGameOver", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGarbageGuessesAreRefused(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []Move{{Letter: "ab"}, {Letter: "!"}, {Letter: " "}, {}} {
|
||||
if _, _, err := ApplyMove(s, m); err == nil {
|
||||
t.Errorf("move %+v was accepted", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMoveDoesNotTouchTheCallersState(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := s.Masked()
|
||||
if _, _, err := ApplyMove(s, Move{Letter: "i"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Masked() != before {
|
||||
t.Errorf("applying a move mutated the state it was given: %q -> %q", before, s.Masked())
|
||||
}
|
||||
if len(s.Tried) != 0 {
|
||||
t.Errorf("tried = %v, want empty — the caller's state was written through", s.Tried)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateSurvivesASerializationRoundTrip(t *testing.T) {
|
||||
// A redeploy mid-game is a JSON round-trip. It has to come back playable.
|
||||
s := play(t, "Insert Coin", 100, 0.05, "i", "z")
|
||||
blob, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back State
|
||||
if err := json.Unmarshal(blob, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.Masked() != s.Masked() || back.Lives() != s.Lives() || back.Multiple() != s.Multiple() {
|
||||
t.Fatalf("round trip changed the game: %+v", back)
|
||||
}
|
||||
next, _, err := ApplyMove(back, Move{Solve: "insert coin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if next.Outcome != OutcomeSolved {
|
||||
t.Errorf("a game restored from JSON couldn't be finished: %s", next.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIsReproducibleFromItsSeed(t *testing.T) {
|
||||
// The seed is in the audit log so a disputed game can be replayed. That is
|
||||
// only true if the phrase comes back the same.
|
||||
one, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
two, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if one.Phrase != two.Phrase {
|
||||
t.Errorf("same seed dealt different phrases: %q vs %q", one.Phrase, two.Phrase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDrawsFromTheRightShelf(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
for _, tr := range Tiers {
|
||||
for i := 0; i < 50; i++ {
|
||||
s, _, err := New(100, tr, 0.05, rng)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tr.Slug, err)
|
||||
}
|
||||
if n := len([]rune(s.Phrase)); n < tr.Min || n > tr.Max {
|
||||
t.Fatalf("%s drew %q (%d chars), outside %d-%d", tr.Slug, s.Phrase, n, tr.Min, tr.Max)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryTierHasAShelfWorthPlaying(t *testing.T) {
|
||||
// If someone edits phrases.txt and empties a tier, the game 500s at the
|
||||
// table rather than here. Catch it here.
|
||||
for _, tr := range Tiers {
|
||||
if n := Shelf(tr.Slug); n < 20 {
|
||||
t.Errorf("tier %s has %d phrases — too few to not repeat", tr.Slug, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
68
internal/games/hangman/phrases.go
Normal file
68
internal/games/hangman/phrases.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package hangman
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The bank. gogobee kept its phrases in a file it read at boot out of a path in
|
||||
// an env var, which meant the game was one missing file away from not existing.
|
||||
// Embedding it means the casino cannot start without its phrases, which is the
|
||||
// correct relationship between a game and the thing it is about.
|
||||
//
|
||||
//go:embed phrases.txt
|
||||
var phrasesTxt string
|
||||
|
||||
// ErrNoPhrases means the bank has nothing at this length. It can only happen if
|
||||
// someone edits phrases.txt down past a tier, and it is a programming error
|
||||
// rather than anything a player did — but it's an error, not a panic, because a
|
||||
// casino that won't boot is worse than one game being shut.
|
||||
var ErrNoPhrases = errors.New("hangman: no phrases in that tier")
|
||||
|
||||
var (
|
||||
shelvesOnce sync.Once
|
||||
shelves map[string][]string // tier slug -> the phrases that fit it
|
||||
)
|
||||
|
||||
// load sorts the bank onto one shelf per tier, once. Comments and blank lines
|
||||
// are dropped, and so is anything too short to be a game — the tiers' own Min
|
||||
// is the floor.
|
||||
func load() {
|
||||
shelves = make(map[string][]string, len(Tiers))
|
||||
sc := bufio.NewScanner(strings.NewReader(phrasesTxt))
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
n := len([]rune(line))
|
||||
for _, t := range Tiers {
|
||||
if n >= t.Min && n <= t.Max {
|
||||
shelves[t.Slug] = append(shelves[t.Slug], line)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shelf is how many phrases a tier has. Exists so a test can assert the bank
|
||||
// hasn't been edited out from under a tier.
|
||||
func Shelf(slug string) int {
|
||||
shelvesOnce.Do(load)
|
||||
return len(shelves[slug])
|
||||
}
|
||||
|
||||
// drawPhrase picks one phrase from a tier's shelf. The rng is threaded, never
|
||||
// the package global, so a game replays exactly from the seed in its audit row.
|
||||
func drawPhrase(t Tier, rng *rand.Rand) (string, error) {
|
||||
shelvesOnce.Do(load)
|
||||
shelf := shelves[t.Slug]
|
||||
if len(shelf) == 0 {
|
||||
return "", ErrNoPhrases
|
||||
}
|
||||
return shelf[rng.IntN(len(shelf))], nil
|
||||
}
|
||||
237
internal/games/hangman/phrases.txt
Normal file
237
internal/games/hangman/phrases.txt
Normal file
@@ -0,0 +1,237 @@
|
||||
# GogoBee Hangman Seed Phrases -- Video Game Edition
|
||||
#
|
||||
# Tiers are assigned automatically by character count at load time.
|
||||
# Section headers below are for human readability only -- the bot ignores them.
|
||||
#
|
||||
# Easy: 8-20 characters
|
||||
# Medium: 21-40 characters
|
||||
# Hard: 41-80 characters
|
||||
#
|
||||
# Add community phrases via: !hangman submit [phrase]
|
||||
# All submissions require LLM approval before entering the pool.
|
||||
# This file can be edited directly. Bot reloads on restart.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EASY (8-20 characters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Finish Him!
|
||||
Game Over
|
||||
Insert Coin
|
||||
Hadouken!
|
||||
Fatality!
|
||||
Big Boss
|
||||
Konami Code
|
||||
Warp Zone
|
||||
Blue Shell
|
||||
God Mode
|
||||
BFG 9000
|
||||
Cacodemon
|
||||
Quad Damage
|
||||
Samus Aran
|
||||
Morph Ball
|
||||
Mother Brain
|
||||
Dracula!
|
||||
Simon Belmont
|
||||
Ecclesia
|
||||
Outer Heaven
|
||||
Spread Gun
|
||||
Power Pellet
|
||||
Checkpoint
|
||||
Rocket Jump
|
||||
Mushroom Kingdom
|
||||
Princess Peach
|
||||
Bowser's Castle
|
||||
Fire Flower
|
||||
Varia Suit
|
||||
Space Jump
|
||||
Vampire Killer
|
||||
Holy Water
|
||||
Trevor Belmont
|
||||
Soma Cruz
|
||||
Julius Belmont
|
||||
Waluigi!
|
||||
Richter!
|
||||
Phantoon
|
||||
Speed Run
|
||||
High Score
|
||||
Continue?
|
||||
Press Start
|
||||
Ryu Hayabusa
|
||||
Plasma Gun
|
||||
What is a man?
|
||||
Serious Sam
|
||||
Shoryuken!
|
||||
Duck Hunt
|
||||
The cake is a lie
|
||||
War never changes
|
||||
Would you kindly?
|
||||
Do a barrel roll!
|
||||
Praise the Sun!
|
||||
A winner is you
|
||||
You're pretty good
|
||||
La-Li-Lu-Le-Lo
|
||||
I am error
|
||||
Leeroy Jenkins!
|
||||
For the Horde!
|
||||
For the Alliance!
|
||||
Frostmourne hungers
|
||||
Falcon Punch!
|
||||
Rip and tear!
|
||||
Vic Viper
|
||||
Salamander!
|
||||
Parodius Da!
|
||||
We'll bang, okay?
|
||||
What you say!!
|
||||
You spoony bard!
|
||||
One-Winged Angel
|
||||
Morning Star
|
||||
Glyph Union
|
||||
TwinBee, scramble!
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEDIUM (21-40 characters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Stay a while and listen
|
||||
It's dangerous to go alone!
|
||||
Kept you waiting, huh?
|
||||
A man chooses, a slave obeys
|
||||
Metal Gear?! Metal Gear!!
|
||||
We're not tools of the government
|
||||
The winds of destruction
|
||||
Who are the Patriots?
|
||||
This is good, isn't it?
|
||||
You have died of dysentery
|
||||
The Triforce of Courage
|
||||
Ganon has broken the seal!
|
||||
May the wind guide you home
|
||||
Dodongo dislikes smoke
|
||||
The right man in the wrong place
|
||||
Nothing is true, everything is permitted
|
||||
I used to be an adventurer like you
|
||||
You can't hide from the Grim Reaper
|
||||
All your base are belong to us!
|
||||
Somebody set up us the bomb!
|
||||
For great justice, take off every Zig!
|
||||
How are you gentlemen!!
|
||||
Kain has betrayed us!
|
||||
Garland will knock you all down!
|
||||
You are not prepared!
|
||||
I am Uther the Lightbringer!
|
||||
Order of Ecclesia calls
|
||||
The Dark Lord rises again!
|
||||
Shanoa, bearer of glyphs
|
||||
In this world, it's kill or be killed
|
||||
Despite everything, it's still you
|
||||
The Underground is your home now
|
||||
Papyrus demands a battle!
|
||||
I'm going to make spaghetti!
|
||||
Toriel will protect you
|
||||
Estus Flask replenished
|
||||
The age of fire fades
|
||||
Prepare to die, undead one
|
||||
Can't let you do that, Star Fox!
|
||||
Andross' empire spans the Lylat system!
|
||||
Captain Falcon, show me your moves!
|
||||
OBJECTION! That testimony is a lie!
|
||||
Hold it! I have new evidence!
|
||||
Phoenix Wright, attorney at law!
|
||||
Does this unit have a soul?
|
||||
Shepard, the Reapers are coming!
|
||||
Tali'Zorah vas Normandy!
|
||||
Gruntilda shall not be defeated!
|
||||
K. Rool has stolen the banana hoard!
|
||||
Kirby, hero of Dream Land!
|
||||
Meta Knight awaits your challenge!
|
||||
Congraturation! This story is happy end.
|
||||
Cecil has become a Paladin
|
||||
Cloud Strife, SOLDIER First Class
|
||||
Time compression is inevitable
|
||||
You require more vespene gas
|
||||
Nuclear launch detected
|
||||
You must construct additional pylons
|
||||
I'm Commander Shepard!
|
||||
War... War has changed.
|
||||
Rip and tear until it is done!
|
||||
Dawn of Sorrow awaits
|
||||
Do you feel like a hero yet?
|
||||
You're a monster. You know that, Walker?
|
||||
Halo... it's not a natural formation.
|
||||
Whip it good, Belmont!
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HARD (41-80 characters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
What is a man? A miserable pile of secrets!
|
||||
Die monster! You don't belong in this world!
|
||||
My name is Dracula, and I bid you welcome.
|
||||
You have no chance to survive make your time!
|
||||
You've met with a terrible fate, haven't you?
|
||||
Fear the old blood... and welcome, good hunter.
|
||||
Welcome to the Liandri Grand Tournament!
|
||||
I am the very model of a scientist Salarian!
|
||||
Metroid Prime has escaped into the impact crater!
|
||||
I want to be the very best, like no one ever was
|
||||
The world needs only one Big Boss... and one Snake.
|
||||
Snake, do you think love can bloom on a battlefield?
|
||||
Thank you Mario, but our princess is in another castle!
|
||||
You must gather your party before venturing forth
|
||||
Snake, we're not tools of the government, or anyone else
|
||||
Did I ever tell you what the definition of insanity is?
|
||||
They're everywhere! The demons... they won't stop coming!
|
||||
Dracula! Your time has come! The Vampire Killer strikes!
|
||||
Link... I'm Navi, your fairy companion! Listen!
|
||||
Hero of Time, your destiny awaits in the Sacred Realm
|
||||
Master Chief, finish the fight. Earth is counting on you.
|
||||
Outer Heaven... a place where warriors can find purpose
|
||||
We passed the point of no return a long time ago, Snake
|
||||
Liquid! I was the one who was meant to be the successor!
|
||||
Revolver Ocelot is a triple agent working for the Patriots
|
||||
Phazon corruption detected! Seek immediate medical attention!
|
||||
Dark Samus has absorbed the Phazon and grown more powerful!
|
||||
I'm the Doom Slayer, and I'm here to kill every last one of you!
|
||||
Praise the Chosen Undead, for they shall link the fire!
|
||||
Ganon is the evil king who stole the Triforce of Power!
|
||||
The legendary soldier who defied his genes... Big Boss
|
||||
Killing spree! Monster kill! Godlike! Unstoppable!
|
||||
Humanity restored! The bonfire blazes with newfound strength!
|
||||
You are the last line of defense against an infinite demonic army
|
||||
Liquid Snake... your dominant genes... give you the edge in battle!
|
||||
All we did was give meaning to the nuclear age by using nukes!
|
||||
I'm a soldier who's been betrayed, abandoned... I fight alone now
|
||||
Samus Aran, the last of the Chozo warriors, descends into the unknown
|
||||
You are the Chosen Undead, fated to succeed where so many have failed
|
||||
The price of living in the past is a slow death in the present
|
||||
A sword wields no strength unless the hands that hold it have courage
|
||||
The flow of time is always cruel, its speed seems different for each person
|
||||
Hey! Listen! There's something important you need to know!
|
||||
We are born of the blood, made men by the blood, undone by the blood
|
||||
War has changed. It's no longer about nations, ideologies, or ethnicity
|
||||
Mental has sent his armies, and I am all that stands between them and Earth!
|
||||
I'm no hero. Never was. Never will be. I'm just an old killer.
|
||||
What is a man? A miserable pile of secrets! But enough talk... have at you!
|
||||
I used to be an adventurer like you, then I took an arrow in the knee
|
||||
I'm not the only one who's responsible. The humans are just as guilty!
|
||||
The Skaarj have invaded, and you must fight your way to freedom
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EXTREME (81+ characters) -- Full quotes. No mercy.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
For you, the day Bison graced your village was the most important day of your life. But for me... it was Tuesday.
|
||||
What is a man? A miserable pile of secrets! But enough talk... have at you!
|
||||
Die monster! You don't belong in this world! It was not by my hand that I am once again given flesh!
|
||||
You can't keep a good man down, and that goes double for a soldier who's been fighting his whole life.
|
||||
They say the definition of insanity is doing the same thing over and over and expecting different results... did I ever tell you that?
|
||||
I've been waiting for this... a professional killer. I'm so excited I may be sick!
|
||||
Kept you waiting, huh? Don't worry though. I'll take you somewhere warm. Back to the battlefield.
|
||||
It's easy to forget what a sin is in the middle of a battlefield. Especially when you're killing to stay alive.
|
||||
We are not tools of the government or anyone else. Fighting was the only thing, the only thing I was good at. But at least I always fought for what I believed in.
|
||||
In the 21st century, the battlefield will once again be a symbol of the glory of nations. I am a weapon. A human weapon.
|
||||
A man's dreams can be his greatest asset... or his most dangerous enemy. The question is: what are you willing to sacrifice to see them through?
|
||||
I need scissors! 61!
|
||||
Outer Heaven, a place where soldiers need not justify their actions -- where warriors can be free of political manipulation.
|
||||
Nothing happened to me. I happened. I'm the one who knocks, the one who causes all the trouble... that is my purpose.
|
||||
Sun Tzu said that. I think he meant it as a metaphor, but he also said never leave home without a healthy supply of rations, so what does he know?
|
||||
279
internal/games/holdem/betting.go
Normal file
279
internal/games/holdem/betting.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package holdem
|
||||
|
||||
import "sort"
|
||||
|
||||
// The betting rules. These are the fiddly ones — min-raise, short all-ins that
|
||||
// don't reopen the action, side pots — and they came over from gogobee, where
|
||||
// they had no tests at all. They have some now.
|
||||
|
||||
// blinds posts the small and the big. Heads-up is the exception every poker
|
||||
// implementation gets wrong once: with two players the button *is* the small
|
||||
// blind and acts first before the flop, and last after it.
|
||||
func (s *State) blinds(evs *[]Event) (bb int) {
|
||||
var sb int
|
||||
if s.dealt() == 2 {
|
||||
sb, bb = s.Button, s.nextIn(s.Button)
|
||||
} else {
|
||||
sb = s.nextIn(s.Button)
|
||||
bb = s.nextIn(sb)
|
||||
}
|
||||
|
||||
s.post(sb, s.Tier.SB, "small", evs)
|
||||
s.post(bb, s.Tier.BB, "big", evs)
|
||||
|
||||
s.Bet = s.Tier.BB
|
||||
s.MinRaise = s.Tier.BB
|
||||
s.Aggressor = bb // the big blind has the option to raise their own blind
|
||||
return bb
|
||||
}
|
||||
|
||||
// post puts a blind up. A player too short to cover it is all-in for what they
|
||||
// have, which is legal and is why the amount is clamped rather than refused.
|
||||
func (s *State) post(seat int, amount int64, which string, evs *[]Event) {
|
||||
p := &s.Seats[seat]
|
||||
if amount > p.Stack {
|
||||
amount = p.Stack
|
||||
}
|
||||
p.Stack -= amount
|
||||
p.Bet = amount
|
||||
p.Total = amount
|
||||
if p.Stack == 0 {
|
||||
p.State = AllIn
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "blind", Seat: seat, Amount: amount, Text: which})
|
||||
}
|
||||
|
||||
// firstPreFlop is under the gun: the seat after the big blind, or the button
|
||||
// itself when the table is heads-up.
|
||||
//
|
||||
// The button only gets it if the button can still act. A short stack can be
|
||||
// all-in on its own blind — post a small blind of 1 with 1 chip left and you are
|
||||
// in the hand with no chips and no say — and handing the action to a seat that
|
||||
// cannot act wedges the table.
|
||||
func (s *State) firstPreFlop(bb int) int {
|
||||
if s.dealt() != 2 {
|
||||
return s.nextCanAct(bb)
|
||||
}
|
||||
if s.Seats[s.Button].State == Active {
|
||||
return s.Button
|
||||
}
|
||||
return s.nextCanAct(s.Button)
|
||||
}
|
||||
|
||||
// firstPostFlop is the first seat left of the button, on every street after the
|
||||
// flop. The button acts last from here on, which is the whole point of it.
|
||||
func (s *State) firstPostFlop() int { return s.nextCanAct(s.Button) }
|
||||
|
||||
// ---- the five things a seat can do ----------------------------------------
|
||||
|
||||
func (s *State) fold(seat int, evs *[]Event) {
|
||||
p := &s.Seats[seat]
|
||||
p.State = Folded
|
||||
p.Acted = true
|
||||
s.History += "f"
|
||||
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "fold"})
|
||||
}
|
||||
|
||||
func (s *State) check(seat int, evs *[]Event) error {
|
||||
p := &s.Seats[seat]
|
||||
if p.Bet < s.Bet {
|
||||
return ErrCantCheck
|
||||
}
|
||||
p.Acted = true
|
||||
s.History += "c"
|
||||
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "check"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) call(seat int, evs *[]Event) error {
|
||||
p := &s.Seats[seat]
|
||||
owed := s.Bet - p.Bet
|
||||
if owed <= 0 {
|
||||
return ErrNothingToCall
|
||||
}
|
||||
if owed > p.Stack {
|
||||
owed = p.Stack // a call for less than the bet is a call all-in
|
||||
}
|
||||
|
||||
p.Stack -= owed
|
||||
p.Bet += owed
|
||||
p.Total += owed
|
||||
p.Acted = true
|
||||
|
||||
text := "call"
|
||||
if p.Stack == 0 {
|
||||
p.State = AllIn
|
||||
text = "allin"
|
||||
s.History += "a"
|
||||
} else {
|
||||
s.History += "c"
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: text, Amount: owed, Total: p.Bet})
|
||||
return nil
|
||||
}
|
||||
|
||||
// raise raises *to* a total, not *by* an amount. Every poker interface in the
|
||||
// world means the total, and a browser that means the other thing bets wrong.
|
||||
func (s *State) raise(seat int, to int64, evs *[]Event) error {
|
||||
p := &s.Seats[seat]
|
||||
most := p.Bet + p.Stack
|
||||
|
||||
if to > most {
|
||||
return ErrTooBig
|
||||
}
|
||||
if to < s.Bet+s.MinRaise && to < most {
|
||||
return ErrTooSmall // only a shove may be smaller than a legal raise
|
||||
}
|
||||
|
||||
added := to - p.Bet
|
||||
over := to - s.Bet
|
||||
|
||||
p.Stack -= added
|
||||
p.Bet = to
|
||||
p.Total += added
|
||||
p.Acted = true
|
||||
|
||||
if over > 0 {
|
||||
s.MinRaise = over
|
||||
}
|
||||
s.Bet = to
|
||||
s.Aggressor = seat
|
||||
|
||||
text := "raise"
|
||||
if p.Stack == 0 {
|
||||
p.State = AllIn
|
||||
text = "allin"
|
||||
s.History += "a"
|
||||
} else {
|
||||
// The policy was trained against a tree with two raise sizes in it, so the
|
||||
// history it reads has to say which one this was: R for a pot-sized raise
|
||||
// or bigger, r for anything smaller.
|
||||
if pot := s.inPlay(); pot > 0 && float64(over) >= float64(pot)*0.75 {
|
||||
s.History += "R"
|
||||
} else {
|
||||
s.History += "r"
|
||||
}
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: text, Amount: added, Total: to})
|
||||
return nil
|
||||
}
|
||||
|
||||
// allin pushes the lot.
|
||||
//
|
||||
// A short all-in does not reopen the betting. If a player shoves for less than
|
||||
// a full raise over the current bet, players who have already acted may call it
|
||||
// but may not raise again — otherwise a tiny stack could be used to reopen the
|
||||
// action for a partner, which is the oldest collusion trick there is.
|
||||
func (s *State) allin(seat int, evs *[]Event) error {
|
||||
p := &s.Seats[seat]
|
||||
if p.Stack <= 0 {
|
||||
return ErrNoChips
|
||||
}
|
||||
added := p.Stack
|
||||
to := p.Bet + added
|
||||
|
||||
p.Stack = 0
|
||||
p.Bet = to
|
||||
p.Total += added
|
||||
p.State = AllIn
|
||||
p.Acted = true
|
||||
|
||||
if to > s.Bet {
|
||||
if over := to - s.Bet; over >= s.MinRaise {
|
||||
s.MinRaise = over
|
||||
s.Aggressor = seat
|
||||
}
|
||||
s.Bet = to
|
||||
}
|
||||
|
||||
s.History += "a"
|
||||
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "allin", Amount: added, Total: to})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- when is a street over ------------------------------------------------
|
||||
|
||||
// streetDone reports whether the betting round is finished, given the seat the
|
||||
// action would pass to next.
|
||||
//
|
||||
// The "has acted" check is the load-bearing half. The big blind has money in
|
||||
// front of them without having chosen to put it there, so a round where
|
||||
// everybody merely limps in has all bets matched while the blind has never had
|
||||
// a say. Without this, they never get their option.
|
||||
func (s *State) streetDone(next int) bool {
|
||||
if s.canActCount() == 0 {
|
||||
return true
|
||||
}
|
||||
for i := range s.Seats {
|
||||
p := &s.Seats[i]
|
||||
if p.State != Active {
|
||||
continue
|
||||
}
|
||||
if p.Bet != s.Bet || !p.Acted {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// The last aggressor being all-in means the action can't get back to them:
|
||||
// everyone left has matched the bet above, so there is nothing more to do.
|
||||
if s.Seats[s.Aggressor].State == AllIn {
|
||||
return true
|
||||
}
|
||||
return next == s.Aggressor
|
||||
}
|
||||
|
||||
// ---- side pots -------------------------------------------------------------
|
||||
|
||||
// sidePots slices the pot into layers, one per distinct all-in level. A player
|
||||
// can only win the part of the pot they could have lost, so each layer is
|
||||
// contested by exactly the players who paid into it.
|
||||
//
|
||||
// Folded players' chips stay in the pot — they paid for the right to fold — but
|
||||
// they are eligible for nothing.
|
||||
func (s *State) sidePots() {
|
||||
s.collect()
|
||||
|
||||
var levels []int64
|
||||
for i := range s.Seats {
|
||||
p := &s.Seats[i]
|
||||
if p.State == Folded || p.State == Out || p.Total == 0 {
|
||||
continue
|
||||
}
|
||||
levels = append(levels, p.Total)
|
||||
}
|
||||
if len(levels) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Slice(levels, func(i, j int) bool { return levels[i] < levels[j] })
|
||||
|
||||
var pots []Pot
|
||||
var prev int64
|
||||
for _, level := range levels {
|
||||
if level <= prev {
|
||||
continue
|
||||
}
|
||||
var amount int64
|
||||
var eligible []int
|
||||
for i := range s.Seats {
|
||||
p := &s.Seats[i]
|
||||
paid := p.Total - prev
|
||||
if paid > level-prev {
|
||||
paid = level - prev
|
||||
}
|
||||
if paid > 0 {
|
||||
amount += paid // folded money counts toward the pot...
|
||||
}
|
||||
if p.State != Folded && p.State != Out && p.Total >= level {
|
||||
eligible = append(eligible, i) // ...but wins no part of it
|
||||
}
|
||||
}
|
||||
if amount > 0 {
|
||||
pots = append(pots, Pot{Amount: amount, Eligible: eligible})
|
||||
}
|
||||
prev = level
|
||||
}
|
||||
|
||||
if len(pots) > 0 {
|
||||
s.Side = pots
|
||||
s.Pot = 0
|
||||
}
|
||||
}
|
||||
416
internal/games/holdem/cfr.go
Normal file
416
internal/games/holdem/cfr.go
Normal file
@@ -0,0 +1,416 @@
|
||||
package holdem
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// The bots' brain.
|
||||
//
|
||||
// gogobee ran counterfactual regret minimisation against this game for a very
|
||||
// long time, and policy.gob is what it converged on: a table from "the situation
|
||||
// I am in" to "how often I fold, call, raise small, raise big, or shove". It is
|
||||
// the single highest-value thing in either repository, and none of it is
|
||||
// re-derived here — this file is the *runtime*, the trainer stayed behind.
|
||||
//
|
||||
// A situation is squeezed down to six things, and this is the whole reason the
|
||||
// table fits in memory: the street, whether the bot is in position, which of
|
||||
// twelve equity buckets its hand falls in, which of five stack-to-pot buckets,
|
||||
// whether the board is dry, wet or paired, and the last six actions. Two hands
|
||||
// that hash to the same key get the same strategy, and that is the approximation
|
||||
// the whole thing is built on.
|
||||
//
|
||||
// The key has to be *exactly* the key the trainer wrote, character for
|
||||
// character. Change the bucket edges, the position label or the history encoding
|
||||
// and every lookup misses — silently, because a miss is not an error, it is a
|
||||
// fall back to the pot-odds rule below. The bots would get quietly, unaccountably
|
||||
// worse. So: don't touch these numbers.
|
||||
|
||||
//go:embed policy.gob
|
||||
var policyGob []byte
|
||||
|
||||
// The five things the trainer let a bot consider.
|
||||
const (
|
||||
actFold = iota
|
||||
actCallCheck
|
||||
actRaiseHalf
|
||||
actRaisePot
|
||||
actAllIn
|
||||
numActions
|
||||
)
|
||||
|
||||
// policyTable maps an info-set key to how often to take each action.
|
||||
type policyTable map[string][numActions]float64
|
||||
|
||||
var (
|
||||
policyOnce sync.Once
|
||||
policy policyTable
|
||||
)
|
||||
|
||||
// loadPolicy decodes the embedded table, once, on the first hand anybody plays.
|
||||
//
|
||||
// Not in an init(): it is megabytes of gob, and Pete is a news server that mostly
|
||||
// never deals a card. Every test in the repo and every cold start would pay for
|
||||
// it. The first player to sit down pays for it instead, and only they do.
|
||||
func loadPolicy() policyTable {
|
||||
policyOnce.Do(func() {
|
||||
t, err := loadTrained(policyGob)
|
||||
if err != nil {
|
||||
// The bots still play — on pot odds — rather than the table 500ing.
|
||||
slog.Error("holdem: cannot decode CFR policy, bots fall back to pot odds", "err", err)
|
||||
policy = policyTable{}
|
||||
return
|
||||
}
|
||||
policy = t.Strategy
|
||||
slog.Info("holdem: CFR policy loaded", "nodes", len(policy),
|
||||
"iterations", t.Meta.Iterations, "stakes", t.Meta.Stakes, "depths", t.Meta.Depths)
|
||||
})
|
||||
return policy
|
||||
}
|
||||
|
||||
// ---- the info-set key ------------------------------------------------------
|
||||
//
|
||||
// Every function below is a load-bearing copy of the trainer's. See the note at
|
||||
// the top of the file before changing a number in any of them.
|
||||
|
||||
// equityBucket puts a hand's strength in one of twelve boxes, from trash to
|
||||
// monster.
|
||||
func equityBucket(eq float64) int {
|
||||
switch {
|
||||
case eq < 0.08:
|
||||
return 0
|
||||
case eq < 0.17:
|
||||
return 1
|
||||
case eq < 0.25:
|
||||
return 2
|
||||
case eq < 0.33:
|
||||
return 3
|
||||
case eq < 0.42:
|
||||
return 4
|
||||
case eq < 0.50:
|
||||
return 5
|
||||
case eq < 0.58:
|
||||
return 6
|
||||
case eq < 0.67:
|
||||
return 7
|
||||
case eq < 0.75:
|
||||
return 8
|
||||
case eq < 0.83:
|
||||
return 9
|
||||
case eq < 0.92:
|
||||
return 10
|
||||
default:
|
||||
return 11
|
||||
}
|
||||
}
|
||||
|
||||
// sprBucket is the stack-to-pot ratio: how much room is left to play. Under 1 is
|
||||
// a pot that is already committed; over 12 is a pot you can still fold out of.
|
||||
func sprBucket(spr float64) int {
|
||||
switch {
|
||||
case spr < 1:
|
||||
return 0
|
||||
case spr < 3:
|
||||
return 1
|
||||
case spr < 6:
|
||||
return 2
|
||||
case spr < 12:
|
||||
return 3
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
// Board textures, which is what makes the same hand a bet or a check.
|
||||
const (
|
||||
boardDry = 0
|
||||
boardWet = 1
|
||||
boardPaired = 2
|
||||
)
|
||||
|
||||
// boardTexture classifies the community cards. A paired board is one somebody
|
||||
// might have trips on; a wet one has a flush or straight coming.
|
||||
// It is on the trainer's hot path — millions of calls — so it counts into arrays
|
||||
// rather than maps. A map here cost more than the poker did.
|
||||
func boardTexture(board []cards.Card) int {
|
||||
if len(board) < 3 {
|
||||
return boardDry
|
||||
}
|
||||
|
||||
var ranks [14]int8
|
||||
var suits [4]int8
|
||||
var vals [5]int
|
||||
for i, c := range board {
|
||||
ranks[c.Rank]++
|
||||
suits[c.Suit]++
|
||||
vals[i] = int(c.Rank)
|
||||
}
|
||||
for _, n := range ranks {
|
||||
if n >= 2 {
|
||||
return boardPaired
|
||||
}
|
||||
}
|
||||
for _, n := range suits {
|
||||
if n >= 3 {
|
||||
return boardWet
|
||||
}
|
||||
}
|
||||
|
||||
// Three cards inside a five-rank window is a straight waiting to happen.
|
||||
v := vals[:len(board)]
|
||||
for i := 1; i < len(v); i++ {
|
||||
for j := i; j > 0 && v[j] < v[j-1]; j-- {
|
||||
v[j], v[j-1] = v[j-1], v[j]
|
||||
}
|
||||
}
|
||||
for i := 0; i+2 < len(v); i++ {
|
||||
if v[i+2]-v[i] <= 4 {
|
||||
return boardWet
|
||||
}
|
||||
}
|
||||
return boardDry
|
||||
}
|
||||
|
||||
// infoSet builds the string the policy is keyed on. The format is the trainer's.
|
||||
//
|
||||
// Position is IP or OOP — in position or out of it — and *nothing else*. This is
|
||||
// the one thing gogobee got wrong, and it got it wrong invisibly for as long as
|
||||
// the game has existed: the trainer packed a single "am I last to act" bit and
|
||||
// wrote its keys as IP/OOP, while the runtime looked them up with the table
|
||||
// labels a player would recognise (BTN, SB, BB, UTG…). Not one key ever matched.
|
||||
// Every bot in every hand of hold'em gogobee ever dealt fell through to the
|
||||
// pot-odds rule, and the five million training iterations sitting in policy.gob
|
||||
// were never once read.
|
||||
//
|
||||
// Nothing about that looks broken from the outside. A missing key is not an
|
||||
// error, it's a fallback — the bots played, they just played a heuristic. This is
|
||||
// why the hit rate is now a test.
|
||||
func infoSet(street Street, inPosition bool, eq, spr, texture int, history string) string {
|
||||
pos := "OOP"
|
||||
if inPosition {
|
||||
pos = "IP"
|
||||
}
|
||||
return fmt.Sprintf("%d|%s|%d|%d|%d|%s", street, pos, eq, spr, texture, history)
|
||||
}
|
||||
|
||||
// recent keeps the last six actions, which is all the trainer's key had room for.
|
||||
func recent(h string) string {
|
||||
if len(h) > 6 {
|
||||
return h[len(h)-6:]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// ---- where a seat stands ---------------------------------------------------
|
||||
|
||||
// mcIters is how many runouts a bot samples to judge its hand at the table.
|
||||
const mcIters = 1000
|
||||
|
||||
// spot is everything the policy knows about a seat's situation, and it is the
|
||||
// one function that builds it.
|
||||
//
|
||||
// The trainer calls this too. That is the point of it: the key the policy is
|
||||
// written under and the key it is read under come out of the same code, so they
|
||||
// cannot quietly stop matching — which is exactly what went wrong the first time
|
||||
// and went unnoticed for the life of the game.
|
||||
func (s State) spot(seat, iters int, rng *rand.Rand) (string, Equity) {
|
||||
eq := s.equityFor(seat, iters, rng)
|
||||
return s.spotKey(seat, eq), eq
|
||||
}
|
||||
|
||||
// equityFor measures how good a seat's hand is right now.
|
||||
//
|
||||
// It depends only on the cards — the hand, the board, how many opponents — and
|
||||
// not on a single thing that happened in the betting. Which is why the trainer
|
||||
// can measure it once per street and reuse it down every branch it explores, and
|
||||
// why doing that is most of the difference between a run that takes half an hour
|
||||
// and one that takes four.
|
||||
func (s State) equityFor(seat, iters int, rng *rand.Rand) Equity {
|
||||
opponents := s.liveCount() - 1
|
||||
if opponents < 1 {
|
||||
opponents = 1
|
||||
}
|
||||
// Preflop heads-up is a lookup, not a simulation: there are only 169 hands
|
||||
// that differ, they have been measured to death, and a sampled answer would
|
||||
// only add noise to a bucket boundary.
|
||||
if s.Street == PreFlop && opponents == 1 {
|
||||
return preflopEquity(s.Seats[seat].Hole)
|
||||
}
|
||||
return equityOf(s.Seats[seat].Hole, s.Community, opponents, iters, rng)
|
||||
}
|
||||
|
||||
// spotKey builds the key from an equity already measured.
|
||||
func (s State) spotKey(seat int, eq Equity) string {
|
||||
pot := s.inPlay()
|
||||
spr := 0.0
|
||||
if pot > 0 {
|
||||
spr = float64(s.Seats[seat].Stack) / float64(pot)
|
||||
}
|
||||
return infoSet(s.Street, s.InPosition(seat), equityBucket(eq.Strength()),
|
||||
sprBucket(spr), boardTexture(s.Community), recent(s.History))
|
||||
}
|
||||
|
||||
// ---- choosing --------------------------------------------------------------
|
||||
|
||||
// hits and misses count how often a bot finds itself in the trained policy.
|
||||
//
|
||||
// They exist because the way this can break is silently. A key the policy has
|
||||
// never seen is not an error — the bot shrugs and plays pot odds — so a policy
|
||||
// that has stopped matching the game looks exactly like a policy that is working.
|
||||
// gogobee's never matched once, for the whole life of the game, and nobody could
|
||||
// have known by watching it play. Now a test reads these and fails.
|
||||
var hits, misses atomic.Int64
|
||||
|
||||
// botActs plays one bot's turn: work out where it stands, look up what it does
|
||||
// there, throw out anything illegal, and roll for it.
|
||||
func (s *State) botActs(seat int, evs *[]Event, rng *rand.Rand) {
|
||||
key, eq := s.spot(seat, mcIters, rng)
|
||||
|
||||
probs, ok := loadPolicy()[key]
|
||||
if ok {
|
||||
hits.Add(1)
|
||||
} else {
|
||||
misses.Add(1)
|
||||
probs = potOdds(eq, s, seat)
|
||||
}
|
||||
probs = legal(probs, s, seat)
|
||||
|
||||
move := s.moveFor(pick(probs, rng), seat)
|
||||
if err := s.act(seat, move, evs); err != nil {
|
||||
// A bot cannot be allowed to wedge the table by choosing something the rules
|
||||
// then refuse: it checks if it can and folds if it can't, and the mismatch
|
||||
// is loud, because it means legal() and the betting rules disagree.
|
||||
slog.Error("holdem: bot chose an illegal move", "seat", seat, "move", move.Kind, "err", err)
|
||||
if s.Owed(seat) > 0 {
|
||||
s.fold(seat, evs)
|
||||
} else {
|
||||
_ = s.check(seat, evs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// potOdds is what a bot does when the trained table has never seen this spot: it
|
||||
// works out whether the price it is being offered beats its chance of winning,
|
||||
// and mixes in enough aggression not to be a calling station.
|
||||
func potOdds(eq Equity, s *State, seat int) [numActions]float64 {
|
||||
p := &s.Seats[seat]
|
||||
strength := eq.Strength()
|
||||
owed := s.Bet - p.Bet
|
||||
pot := s.inPlay()
|
||||
|
||||
price := 0.0
|
||||
if owed > 0 && pot+owed > 0 {
|
||||
price = float64(owed) / float64(pot+owed)
|
||||
}
|
||||
|
||||
var probs [numActions]float64
|
||||
switch {
|
||||
case strength > 0.8:
|
||||
probs[actRaisePot], probs[actAllIn], probs[actCallCheck] = 0.6, 0.2, 0.2
|
||||
case strength > 0.6:
|
||||
probs[actRaiseHalf], probs[actCallCheck], probs[actFold] = 0.4, 0.5, 0.1
|
||||
case owed > 0 && strength > price:
|
||||
probs[actCallCheck], probs[actRaiseHalf], probs[actFold] = 0.7, 0.2, 0.1
|
||||
case owed > 0:
|
||||
probs[actFold], probs[actCallCheck] = 0.7, 0.3
|
||||
default:
|
||||
probs[actCallCheck], probs[actRaiseHalf], probs[actFold] = 0.6, 0.3, 0.1
|
||||
}
|
||||
return probs
|
||||
}
|
||||
|
||||
// mask is what a seat may actually do here. The trainer explores exactly this
|
||||
// set, so it never learns a strategy the table would turn down.
|
||||
func (s State) mask(seat int) (m [numActions]bool) {
|
||||
owed := s.Bet - s.Seats[seat].Bet
|
||||
|
||||
// Folding a hand you could see for free is a bug, not a strategy.
|
||||
m[actFold] = owed > 0
|
||||
m[actCallCheck] = true
|
||||
|
||||
// A raise needs chips behind the call, and somebody left to bet into.
|
||||
raise := s.Seats[seat].Stack > owed && s.canBet()
|
||||
m[actRaiseHalf], m[actRaisePot], m[actAllIn] = raise, raise, raise
|
||||
return m
|
||||
}
|
||||
|
||||
// legal zeroes out what the seat cannot do and renormalises what's left.
|
||||
func legal(probs [numActions]float64, s *State, seat int) [numActions]float64 {
|
||||
m := s.mask(seat)
|
||||
var total float64
|
||||
for i := range probs {
|
||||
if !m[i] {
|
||||
probs[i] = 0
|
||||
}
|
||||
total += probs[i]
|
||||
}
|
||||
if total <= 0 {
|
||||
var only [numActions]float64
|
||||
only[actCallCheck] = 1
|
||||
return only
|
||||
}
|
||||
for i := range probs {
|
||||
probs[i] /= total
|
||||
}
|
||||
return probs
|
||||
}
|
||||
|
||||
// pick rolls against the distribution.
|
||||
func pick(probs [numActions]float64, rng *rand.Rand) int {
|
||||
r := rng.Float64()
|
||||
sum := 0.0
|
||||
for i, p := range probs {
|
||||
sum += p
|
||||
if r < sum {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return actCallCheck
|
||||
}
|
||||
|
||||
// moveFor turns an abstract action — "raise half the pot" — into a legal move at
|
||||
// the actual size the table is at. A raise that would cost the bot everything it
|
||||
// has is a shove, which is the same decision made honestly.
|
||||
func (s *State) moveFor(action, seat int) Move {
|
||||
p := &s.Seats[seat]
|
||||
owed := s.Bet - p.Bet
|
||||
pot := s.inPlay()
|
||||
most := p.Bet + p.Stack
|
||||
|
||||
sized := func(by int64) Move {
|
||||
if by < s.MinRaise {
|
||||
by = s.MinRaise
|
||||
}
|
||||
to := s.Bet + by
|
||||
if to >= most {
|
||||
return Move{Kind: Shove}
|
||||
}
|
||||
return Move{Kind: Raise, To: to}
|
||||
}
|
||||
|
||||
switch action {
|
||||
case actFold:
|
||||
if owed <= 0 {
|
||||
return Move{Kind: Check} // never fold for free
|
||||
}
|
||||
return Move{Kind: Fold}
|
||||
case actCallCheck:
|
||||
if owed > 0 {
|
||||
return Move{Kind: Call}
|
||||
}
|
||||
return Move{Kind: Check}
|
||||
case actRaiseHalf:
|
||||
return sized(pot / 2)
|
||||
case actRaisePot:
|
||||
return sized(pot)
|
||||
case actAllIn:
|
||||
return Move{Kind: Shove}
|
||||
}
|
||||
return Move{Kind: Check}
|
||||
}
|
||||
134
internal/games/holdem/equity.go
Normal file
134
internal/games/holdem/equity.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package holdem
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
|
||||
"github.com/chehsunliu/poker"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// How good is this hand, really?
|
||||
//
|
||||
// A bot's decision starts here: deal the cards it cannot see, a thousand times,
|
||||
// and count how often it wins. That number — plus the board's texture, the
|
||||
// stack-to-pot ratio and the action so far — is the key it looks its trained
|
||||
// strategy up under.
|
||||
//
|
||||
// Monte Carlo rather than exhaustive because exhaustive is 2.1 million river
|
||||
// runouts against one opponent and rather more against five, and a thousand
|
||||
// samples puts the estimate inside a percentage point or so. The bot does not
|
||||
// need the fourth decimal place; it needs to know whether it is ahead.
|
||||
|
||||
// Equity is the fraction of runouts a hand wins, ties and loses against the
|
||||
// given number of unknown opponents.
|
||||
type Equity struct {
|
||||
Win float64
|
||||
Tie float64
|
||||
Loss float64
|
||||
}
|
||||
|
||||
// Strength collapses a result into the one number the policy is keyed on: a tie
|
||||
// is worth half a win, because that is what half a pot is.
|
||||
func (e Equity) Strength() float64 { return e.Win + e.Tie*0.5 }
|
||||
|
||||
// deck52 is the evaluator's whole deck, built once.
|
||||
var deck52 = func() []poker.Card {
|
||||
d := make([]poker.Card, 0, 52)
|
||||
for s := cards.Spades; s <= cards.Clubs; s++ {
|
||||
for r := cards.Ace; r <= cards.King; r++ {
|
||||
d = append(d, pokerOf[s][r])
|
||||
}
|
||||
}
|
||||
return d
|
||||
}()
|
||||
|
||||
// equityOf runs the simulation. The RNG is threaded like everything else here,
|
||||
// so a bot's decision replays from the session's seed along with the deal.
|
||||
func equityOf(hole [2]cards.Card, board []cards.Card, opponents, iterations int, rng *rand.Rand) Equity {
|
||||
if opponents < 1 {
|
||||
opponents = 1
|
||||
}
|
||||
|
||||
h0, h1 := toPoker(hole[0]), toPoker(hole[1])
|
||||
|
||||
// Seven cards, checked by hand. A map here would be the most expensive thing
|
||||
// in the trainer, which calls this function millions of times.
|
||||
var known [7]poker.Card
|
||||
n := 2
|
||||
known[0], known[1] = h0, h1
|
||||
pb := make([]poker.Card, len(board))
|
||||
for i, c := range board {
|
||||
pb[i] = toPoker(c)
|
||||
known[n] = pb[i]
|
||||
n++
|
||||
}
|
||||
|
||||
rest := make([]poker.Card, 0, 52)
|
||||
for _, c := range deck52 {
|
||||
seen := false
|
||||
for _, k := range known[:n] {
|
||||
if k == c {
|
||||
seen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
rest = append(rest, c)
|
||||
}
|
||||
}
|
||||
|
||||
need := opponents*2 + (5 - len(pb))
|
||||
if need > len(rest) {
|
||||
return Equity{Tie: 1}
|
||||
}
|
||||
|
||||
hero := make([]poker.Card, 7)
|
||||
hero[0], hero[1] = h0, h1
|
||||
villain := make([]poker.Card, 7)
|
||||
full := make([]poker.Card, 5)
|
||||
|
||||
var wins, ties int
|
||||
for i := 0; i < iterations; i++ {
|
||||
// A partial Fisher-Yates: only the cards actually needed get shuffled into
|
||||
// place, which is the difference between this being cheap and being the
|
||||
// slowest thing in the request.
|
||||
for j := 0; j < need; j++ {
|
||||
k := j + rng.IntN(len(rest)-j)
|
||||
rest[j], rest[k] = rest[k], rest[j]
|
||||
}
|
||||
|
||||
copy(full, pb)
|
||||
at := opponents * 2
|
||||
for b := len(pb); b < 5; b++ {
|
||||
full[b] = rest[at]
|
||||
at++
|
||||
}
|
||||
|
||||
copy(hero[2:], full)
|
||||
mine := poker.Evaluate(hero)
|
||||
|
||||
best := int32(7463) // one worse than the worst real hand
|
||||
for o := 0; o < opponents; o++ {
|
||||
villain[0], villain[1] = rest[o*2], rest[o*2+1]
|
||||
copy(villain[2:], full)
|
||||
if r := poker.Evaluate(villain); r < best {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case mine < best:
|
||||
wins++
|
||||
case mine == best:
|
||||
ties++
|
||||
}
|
||||
}
|
||||
|
||||
total := float64(iterations)
|
||||
return Equity{
|
||||
Win: float64(wins) / total,
|
||||
Tie: float64(ties) / total,
|
||||
Loss: float64(iterations-wins-ties) / total,
|
||||
}
|
||||
}
|
||||
297
internal/games/holdem/eval.go
Normal file
297
internal/games/holdem/eval.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package holdem
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/chehsunliu/poker"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// The bridge to the evaluator.
|
||||
//
|
||||
// The engine deals Pete's own cards.Card — the same one blackjack, solitaire and
|
||||
// the felt already speak — and converts at the door. Hand strength is the one
|
||||
// thing in this package that is genuinely hard to get right (7-card best-of-5,
|
||||
// 7,462 distinct hands), so it is not homegrown: github.com/chehsunliu/poker is
|
||||
// a lookup table and it is correct.
|
||||
//
|
||||
// The conversion is a table built once. Doing it per evaluation would matter:
|
||||
// a bot's equity estimate is a thousand seven-card evaluations, and it makes
|
||||
// several of those per hand.
|
||||
|
||||
var (
|
||||
pokerRanks = [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K"}
|
||||
pokerSuits = [4]string{"s", "h", "d", "c"} // cards.Spades, Hearts, Diamonds, Clubs
|
||||
|
||||
// A var initializer, not an init(). Go builds package-level variables before
|
||||
// it runs init functions, so anything else in this package that is itself a
|
||||
// var built out of this table — equity.go's deck52 is — would otherwise be
|
||||
// built out of an empty one. It was, briefly: every card came out identical,
|
||||
// every showdown tied, and every bot believed it held exactly 50% equity.
|
||||
pokerOf = func() (t [4][14]poker.Card) {
|
||||
for s := cards.Spades; s <= cards.Clubs; s++ {
|
||||
for r := cards.Ace; r <= cards.King; r++ {
|
||||
t[s][r] = poker.NewCard(pokerRanks[r] + pokerSuits[s])
|
||||
}
|
||||
}
|
||||
return t
|
||||
}()
|
||||
)
|
||||
|
||||
// toPoker converts one card for the evaluator.
|
||||
func toPoker(c cards.Card) poker.Card { return pokerOf[c.Suit][c.Rank] }
|
||||
|
||||
func toPokerAll(cs []cards.Card) []poker.Card {
|
||||
out := make([]poker.Card, len(cs))
|
||||
for i, c := range cs {
|
||||
out[i] = toPoker(c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rankOf evaluates a seat's best five from its hole cards and the board. Lower
|
||||
// is better — 1 is a royal flush — which is the evaluator's convention and not
|
||||
// worth inverting, since nothing outside this file ever sees the number.
|
||||
func rankOf(hole [2]cards.Card, board []cards.Card) (int32, string) {
|
||||
seven := make([]poker.Card, 0, 7)
|
||||
seven = append(seven, toPoker(hole[0]), toPoker(hole[1]))
|
||||
seven = append(seven, toPokerAll(board)...)
|
||||
r := poker.Evaluate(seven)
|
||||
return r, strings.ToLower(poker.RankString(r))
|
||||
}
|
||||
|
||||
// ---- showdown -------------------------------------------------------------
|
||||
|
||||
type ranked struct {
|
||||
seat int
|
||||
rank int32
|
||||
desc string
|
||||
}
|
||||
|
||||
// showdown turns the cards over, splits the pots, and pays. Every player still
|
||||
// in the hand shows, in the order the felt should turn them over: best hand
|
||||
// first, so the winner is the first card face the player sees.
|
||||
func (s *State) showdown(evs *[]Event) {
|
||||
s.collect()
|
||||
s.Street = Showdown
|
||||
|
||||
// Cut the side pots, if nobody has cut them yet.
|
||||
//
|
||||
// runout() does this, but runout only happens when the betting stops because
|
||||
// there is nobody left able to bet. A hand can reach a showdown with an all-in
|
||||
// player in it and the betting having finished perfectly normally: a short stack
|
||||
// shoves, and two players who both have chips behind keep betting past them,
|
||||
// street after street, all the way to the river. Nothing has been cut, and the
|
||||
// short stack is sitting in a single pot marked eligible for all of it.
|
||||
//
|
||||
// Which means they can win every chip the deep players put in *after* they were
|
||||
// already all-in — money they could never have lost. All-in for 100 against two
|
||||
// players who each put in 500, and the best hand takes 1,100 instead of the 300
|
||||
// they were playing for. The chips still balance, so conservation says nothing;
|
||||
// they just go to the wrong seat.
|
||||
if len(s.Side) == 0 && s.anyAllIn() {
|
||||
s.sidePots()
|
||||
}
|
||||
|
||||
// Say so. The last street's bets are still sitting in front of the seats that
|
||||
// made them, as far as the felt knows, and nothing else in the script is going
|
||||
// to tell it they have been swept in.
|
||||
*evs = append(*evs, Event{Kind: "pot", Seat: -1, Amount: s.Total()})
|
||||
|
||||
var live []ranked
|
||||
for i := range s.Seats {
|
||||
p := &s.Seats[i]
|
||||
if p.State == Folded || p.State == Out {
|
||||
continue
|
||||
}
|
||||
r, desc := rankOf(p.Hole, s.Community)
|
||||
live = append(live, ranked{seat: i, rank: r, desc: desc})
|
||||
}
|
||||
sort.Slice(live, func(i, j int) bool { return live[i].rank < live[j].rank })
|
||||
|
||||
for _, e := range live {
|
||||
*evs = append(*evs, Event{
|
||||
Kind: "show", Seat: e.seat,
|
||||
Cards: []cards.Card{s.Seats[e.seat].Hole[0], s.Seats[e.seat].Hole[1]},
|
||||
Text: e.desc,
|
||||
})
|
||||
}
|
||||
|
||||
pots := s.Side
|
||||
if len(pots) == 0 {
|
||||
all := make([]int, 0, len(live))
|
||||
for _, e := range live {
|
||||
all = append(all, e.seat)
|
||||
}
|
||||
pots = []Pot{{Amount: s.Pot, Eligible: all}}
|
||||
s.Pot = 0
|
||||
}
|
||||
s.Side = nil
|
||||
|
||||
for _, pot := range pots {
|
||||
s.payPot(pot, live, evs)
|
||||
}
|
||||
s.endHand(evs)
|
||||
}
|
||||
|
||||
// payPot rakes a pot and splits it between the best eligible hands.
|
||||
//
|
||||
// The rake comes out of the pot before it is split, which is what a cardroom
|
||||
// does and is also the only thing consistent with the rest of this casino: a
|
||||
// player pays it out of a pot they *win*, never out of a bet they lose. A hand
|
||||
// that dies before the flop is not raked at all — no flop, no drop — so folding
|
||||
// your blind round after round costs you exactly the blinds and no fee.
|
||||
func (s *State) payPot(pot Pot, live []ranked, evs *[]Event) {
|
||||
if pot.Amount <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
eligible := make(map[int]bool, len(pot.Eligible))
|
||||
for _, seat := range pot.Eligible {
|
||||
eligible[seat] = true
|
||||
}
|
||||
|
||||
var winners []ranked
|
||||
best := int32(0)
|
||||
for _, e := range live {
|
||||
if !eligible[e.seat] {
|
||||
continue
|
||||
}
|
||||
if len(winners) == 0 || e.rank < best {
|
||||
best, winners = e.rank, []ranked{e}
|
||||
} else if e.rank == best {
|
||||
winners = append(winners, e)
|
||||
}
|
||||
}
|
||||
if len(winners) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
amount := pot.Amount
|
||||
if s.Flopped {
|
||||
rake := int64(math.Floor(float64(amount) * s.Tier.RakePct))
|
||||
if cap := s.Tier.BB * rakeCapBB; rake > cap {
|
||||
rake = cap
|
||||
}
|
||||
if rake > 0 {
|
||||
amount -= rake
|
||||
s.Rake += rake // every chip of it, so the table still balances
|
||||
|
||||
// But only the part that came out of a *human's* winnings is money the
|
||||
// house actually made, and it is the only part worth quoting. The bots'
|
||||
// chips are not real — the only real money at the table is the players' —
|
||||
// so raking a pot a bot won costs nobody anything, and a counter that
|
||||
// climbed while every human folded would be telling them it had.
|
||||
for _, w := range winners {
|
||||
if !s.Seats[w.seat].Bot {
|
||||
// The table total (for the audit's delta) and the winner's own
|
||||
// running tally (for the ledger line the felt shows them). At a
|
||||
// table with two humans these are different numbers: each player is
|
||||
// only ever quoted the rake that came out of a pot they won.
|
||||
s.Paid += rake / int64(len(winners))
|
||||
s.Seats[w.seat].Paid += rake / int64(len(winners))
|
||||
}
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "rake", Seat: -1, Amount: rake})
|
||||
}
|
||||
}
|
||||
|
||||
share := amount / int64(len(winners))
|
||||
odd := amount % int64(len(winners)) // the odd chip goes to the first seat left of the button
|
||||
for i, w := range winners {
|
||||
won := share
|
||||
if i == 0 {
|
||||
won += odd
|
||||
}
|
||||
s.Seats[w.seat].Stack += won
|
||||
s.Seats[w.seat].Won += won
|
||||
*evs = append(*evs, Event{Kind: "win", Seat: w.seat, Amount: won, Text: w.desc})
|
||||
}
|
||||
}
|
||||
|
||||
// takeit ends a hand nobody contested: everyone else folded, so the last player
|
||||
// standing takes the pot without showing. Their own uncalled bet comes back
|
||||
// first — it was never called, so it was never really in the pot.
|
||||
func (s *State) takeit(evs *[]Event) {
|
||||
s.uncalled(evs)
|
||||
s.collect()
|
||||
*evs = append(*evs, Event{Kind: "pot", Seat: -1, Amount: s.Total()})
|
||||
|
||||
winner := -1
|
||||
for i := range s.Seats {
|
||||
if s.Seats[i].State != Folded && s.Seats[i].State != Out {
|
||||
winner = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if winner < 0 {
|
||||
s.endHand(evs)
|
||||
return
|
||||
}
|
||||
|
||||
// There are never side pots here: they are only cut once the betting is over
|
||||
// because everybody is all-in, and a table where everybody is all-in is a table
|
||||
// where nobody is left to fold.
|
||||
pot := Pot{Amount: s.Pot, Eligible: []int{winner}}
|
||||
s.Pot = 0
|
||||
s.payPot(pot, []ranked{{seat: winner, rank: 0}}, evs)
|
||||
s.endHand(evs)
|
||||
}
|
||||
|
||||
// uncalled returns the unmatched top of a bet. If you shove 500 into a player
|
||||
// with 200 behind, 300 of that was never contested and comes straight back.
|
||||
//
|
||||
// It must run *before* the bets are swept into the pot, and the matched level it
|
||||
// measures against counts the players who folded. Their chips are in the pot —
|
||||
// they paid to see the bet and then gave up — so the money they put in is money
|
||||
// that called. Miss that and a bet folded to on the river comes back whole,
|
||||
// including the part that was called on the flop, which mints chips out of air.
|
||||
//
|
||||
// The rake is the other reason this matters at all. When everybody folds, the
|
||||
// winner takes the pot back either way and the arithmetic looks the same — but a
|
||||
// pot with an uncalled bet still in it is a pot the house rakes, and it would be
|
||||
// raking the player on their own money that nobody ever contested.
|
||||
func (s *State) uncalled(evs *[]Event) {
|
||||
top, topSeat := int64(-1), -1
|
||||
for i := range s.Seats {
|
||||
p := &s.Seats[i]
|
||||
if p.State == Folded || p.State == Out {
|
||||
continue
|
||||
}
|
||||
if p.Total > top {
|
||||
top, topSeat = p.Total, i
|
||||
}
|
||||
}
|
||||
if topSeat < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var matched int64 // the most anybody else put in, whether or not they're still in
|
||||
for i := range s.Seats {
|
||||
if i == topSeat || s.Seats[i].State == Out {
|
||||
continue
|
||||
}
|
||||
if s.Seats[i].Total > matched {
|
||||
matched = s.Seats[i].Total
|
||||
}
|
||||
}
|
||||
|
||||
excess := top - matched
|
||||
p := &s.Seats[topSeat]
|
||||
if excess <= 0 || excess > p.Bet {
|
||||
// An uncalled bet is always part of the street it was made on, so it cannot
|
||||
// be bigger than what that seat has in front of them right now.
|
||||
return
|
||||
}
|
||||
|
||||
p.Stack += excess
|
||||
p.Total -= excess
|
||||
p.Bet -= excess
|
||||
if p.State == AllIn && p.Stack > 0 {
|
||||
p.State = Active // they were never really all-in against anybody
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "uncalled", Seat: topSeat, Amount: excess})
|
||||
}
|
||||
1040
internal/games/holdem/holdem.go
Normal file
1040
internal/games/holdem/holdem.go
Normal file
File diff suppressed because it is too large
Load Diff
818
internal/games/holdem/holdem_test.go
Normal file
818
internal/games/holdem/holdem_test.go
Normal file
@@ -0,0 +1,818 @@
|
||||
package holdem
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// The one that matters: no chip is ever created or destroyed.
|
||||
//
|
||||
// Everything else in this package is a rule you could argue about. This is the
|
||||
// one that would lose somebody money. Every chip at the table is in exactly one
|
||||
// place — a stack, a bet in front of a seat, a pot, or the house's rake — and
|
||||
// the only thing that ever adds to the total is a bot reloading. So: play a
|
||||
// hundred sessions of real hands, with the trained bots making real decisions,
|
||||
// and count the chips after every single move.
|
||||
func TestChipsAreConserved(t *testing.T) {
|
||||
for game := 0; game < 100; game++ {
|
||||
rng := rand.New(rand.NewPCG(uint64(game), 99))
|
||||
bots := 1 + game%MaxBots
|
||||
tier := Tiers[game%len(Tiers)]
|
||||
|
||||
s, _, err := New(tier, SoloSeats(tier, bots, tier.MaxBuy), tier.RakePct, uint64(game), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("new table: %v", err)
|
||||
}
|
||||
|
||||
want := chipsAt(s) // what the table started with
|
||||
|
||||
for hand := 0; hand < 8 && s.Phase != PhaseDone; hand++ {
|
||||
var evs []Event
|
||||
s, evs, err = apply(s, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatalf("game %d hand %d: deal: %v", game, hand, err)
|
||||
}
|
||||
want += reloaded(evs) // a bot that rebought brought new chips with it
|
||||
check(t, s, want, game, hand, "deal")
|
||||
|
||||
for step := 0; s.Phase == PhaseBetting; step++ {
|
||||
if step > 200 {
|
||||
t.Fatalf("game %d hand %d: the hand will not end", game, hand)
|
||||
}
|
||||
s, _, err = apply(s, randomMove(s, rng))
|
||||
if err != nil {
|
||||
t.Fatalf("game %d hand %d: %v", game, hand, err)
|
||||
}
|
||||
check(t, s, want, game, hand, "move")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// chipsAt totals every chip the table can see, plus every one the house has
|
||||
// already taken out of it.
|
||||
func chipsAt(s State) int64 {
|
||||
total := s.Rake + s.Pot
|
||||
for _, p := range s.Seats {
|
||||
total += p.Stack + p.Bet
|
||||
}
|
||||
for _, pot := range s.Side {
|
||||
total += pot.Amount
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// reloaded is what the bots brought back to the table on this deal. It is the
|
||||
// only thing in the game that is allowed to make chips out of nothing, which is
|
||||
// exactly why the test has to know about it and nothing else does.
|
||||
func reloaded(evs []Event) int64 {
|
||||
var n int64
|
||||
for _, e := range evs {
|
||||
if e.Kind == "rebuy" {
|
||||
n += e.Amount
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func check(t *testing.T, s State, want int64, game, hand int, when string) {
|
||||
t.Helper()
|
||||
if got := chipsAt(s); got != want {
|
||||
t.Fatalf("game %d hand %d, after %s: %d chips on the table, want %d "+
|
||||
"(pot %d, rake %d, stacks %v)", game, hand, when, got, want, s.Pot, s.Rake, stacks(s))
|
||||
}
|
||||
for i, p := range s.Seats {
|
||||
if p.Stack < 0 {
|
||||
t.Fatalf("game %d hand %d: seat %d has a negative stack (%d)", game, hand, i, p.Stack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stacks(s State) []int64 {
|
||||
out := make([]int64, len(s.Seats))
|
||||
for i, p := range s.Seats {
|
||||
out[i] = p.Stack
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// You is seat zero — the shape every test in this file is written against. The
|
||||
// engine no longer has this constant: a table is a list of seats and which are
|
||||
// human is a per-seat property, not a fixed index. But these tests all seat one
|
||||
// human at zero (the pre-multiplayer solo shape), so a test-local alias keeps
|
||||
// them readable as the regression guard they are. The multiway behaviour has its
|
||||
// own tests, which do not assume it.
|
||||
const You = 0
|
||||
|
||||
// apply plays a move as the seat whose turn it is at a solo table — always the
|
||||
// human at seat zero. It wraps the seat-parameterized ApplyMove so the solo tests
|
||||
// read as they did before the reshape.
|
||||
func apply(s State, m Move) (State, []Event, error) {
|
||||
return ApplyMove(s, You, m)
|
||||
}
|
||||
|
||||
// randomMove picks something legal for the player, without any thought at all.
|
||||
// A bad player is exactly what this test wants: it gets into all-ins, folds,
|
||||
// short stacks and split pots far faster than a good one would.
|
||||
func randomMove(s State, rng *rand.Rand) Move {
|
||||
owed := s.Owed(You)
|
||||
var legal []Move
|
||||
if owed > 0 {
|
||||
legal = append(legal, Move{Kind: Fold}, Move{Kind: Call})
|
||||
} else {
|
||||
legal = append(legal, Move{Kind: Check})
|
||||
}
|
||||
if s.Seats[You].Stack > owed && s.canBet() {
|
||||
legal = append(legal, Move{Kind: Shove})
|
||||
if to := s.MinRaiseTo(You); to < s.MaxRaiseTo(You) {
|
||||
legal = append(legal, Move{Kind: Raise, To: to})
|
||||
}
|
||||
}
|
||||
return legal[rng.IntN(len(legal))]
|
||||
}
|
||||
|
||||
// ---- the rules a poker player would notice were wrong -----------------------
|
||||
|
||||
func TestHeadsUpButtonIsTheSmallBlindAndActsFirst(t *testing.T) {
|
||||
s := table(t, Tiers[0], 1, 200)
|
||||
s, evs, err := apply(s, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The button posted the small blind, not the big one.
|
||||
var small, big int
|
||||
for _, e := range evs {
|
||||
if e.Kind == "blind" && e.Text == "small" {
|
||||
small = e.Seat
|
||||
}
|
||||
if e.Kind == "blind" && e.Text == "big" {
|
||||
big = e.Seat
|
||||
}
|
||||
}
|
||||
if small != s.Button {
|
||||
t.Errorf("heads-up: seat %d posted the small blind, but the button is seat %d", small, s.Button)
|
||||
}
|
||||
if big == s.Button {
|
||||
t.Error("heads-up: the button posted the big blind too")
|
||||
}
|
||||
// And it is the first to act before the flop. (If the button is a bot it has
|
||||
// already acted, so what we can check is that the player didn't get skipped.)
|
||||
if s.Phase != PhaseBetting {
|
||||
t.Fatalf("phase %q: the hand should be waiting on somebody", s.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBigBlindGetsTheirOption(t *testing.T) {
|
||||
// A table where everyone just calls: the big blind has the bet matched without
|
||||
// ever having chosen anything, and the street must not end until they speak.
|
||||
s := table(t, Tiers[0], 1, 200)
|
||||
s, _, _ = apply(s, Move{Kind: Deal})
|
||||
|
||||
// Find a hand where the player is the big blind. The button alternates, so at
|
||||
// most a couple of deals.
|
||||
for i := 0; i < 6 && s.Position(You) != "BB"; i++ {
|
||||
s = playOut(t, s)
|
||||
s, _, _ = apply(s, Move{Kind: Deal})
|
||||
}
|
||||
if s.Position(You) != "BB" {
|
||||
t.Skip("never dealt the big blind")
|
||||
}
|
||||
if s.Phase != PhaseBetting {
|
||||
return // the bot folded or raised; either way the option isn't the question
|
||||
}
|
||||
if s.ToAct == You && s.Owed(You) == 0 {
|
||||
// This is the option: nothing to call, but the hand is still ours to act on.
|
||||
if _, _, err := apply(s, Move{Kind: Check}); err != nil {
|
||||
t.Errorf("the big blind cannot check their option: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAShortAllInDoesNotReopenTheBetting(t *testing.T) {
|
||||
s := State{
|
||||
Tier: Tiers[1], // 5/10
|
||||
Seats: []Seat{{Name: "You", Stack: 1000}, {Name: "Bot", Bot: true, Stack: 1000}},
|
||||
Bet: 100,
|
||||
MinRaise: 100, // a full raise would be to 200
|
||||
Aggressor: You,
|
||||
Phase: PhaseBetting,
|
||||
}
|
||||
s.Seats[You].Bet = 100
|
||||
s.Seats[1].Bet = 0
|
||||
s.Seats[1].Stack = 150 // can only get to 150, which is a raise of 50: not a full one
|
||||
|
||||
var evs []Event
|
||||
if err := s.allin(1, &evs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Bet != 150 {
|
||||
t.Errorf("the bet to call is %d, want 150", s.Bet)
|
||||
}
|
||||
if s.MinRaise != 100 {
|
||||
t.Errorf("min raise moved to %d — a short all-in must not change it", s.MinRaise)
|
||||
}
|
||||
if s.Aggressor != You {
|
||||
t.Errorf("the aggressor moved to seat %d — a short all-in must not reopen the action", s.Aggressor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidePotsPayInLayers(t *testing.T) {
|
||||
// Three players all-in for different amounts. The short stack can only win
|
||||
// what everyone could have lost to them.
|
||||
s := State{
|
||||
Tier: Tiers[1],
|
||||
Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}, {Name: "B", Bot: true}},
|
||||
}
|
||||
s.Seats[0].Total, s.Seats[0].State = 100, AllIn // short
|
||||
s.Seats[1].Total, s.Seats[1].State = 500, AllIn // middle
|
||||
s.Seats[2].Total, s.Seats[2].State = 500, AllIn // covers
|
||||
|
||||
s.sidePots()
|
||||
|
||||
if len(s.Side) != 2 {
|
||||
t.Fatalf("got %d pots, want 2: %+v", len(s.Side), s.Side)
|
||||
}
|
||||
main, side := s.Side[0], s.Side[1]
|
||||
if main.Amount != 300 { // 100 from each of the three
|
||||
t.Errorf("main pot is %d, want 300", main.Amount)
|
||||
}
|
||||
if len(main.Eligible) != 3 {
|
||||
t.Errorf("main pot has %d eligible, want all 3", len(main.Eligible))
|
||||
}
|
||||
if side.Amount != 800 { // 400 more from each of the two who had it
|
||||
t.Errorf("side pot is %d, want 800", side.Amount)
|
||||
}
|
||||
if len(side.Eligible) != 2 {
|
||||
t.Errorf("side pot has %d eligible, want 2 — the short stack cannot win it", len(side.Eligible))
|
||||
}
|
||||
if total := main.Amount + side.Amount; total != 1100 {
|
||||
t.Errorf("the pots hold %d, but %d went in", total, 1100)
|
||||
}
|
||||
}
|
||||
|
||||
// A covered all-in player can only ever win what they matched, and the hand does
|
||||
// not have to end in a run-out for that to be true.
|
||||
//
|
||||
// This one got through everything. The side pots were only ever cut in runout(),
|
||||
// which happens when the betting stops because *nobody* can bet — so a short stack
|
||||
// who shoves and gets called by two players who still have chips behind, and who
|
||||
// then keep betting past them all the way to the river, reached a showdown with the
|
||||
// pots never cut. One pot, everybody eligible, and the short stack takes the lot.
|
||||
//
|
||||
// Chip conservation never saw it: the chips balance perfectly, they just land in
|
||||
// the wrong seat. And every browser session went through runout(), because the
|
||||
// player shoving is what ends the betting. It took reading the code.
|
||||
func TestACoveredAllInCannotWinTheSidePot(t *testing.T) {
|
||||
c := func(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} }
|
||||
|
||||
s := State{
|
||||
Tier: Tiers[1],
|
||||
Phase: PhaseBetting,
|
||||
Street: River,
|
||||
Community: []cards.Card{
|
||||
c(2, cards.Clubs), c(7, cards.Diamonds), c(9, cards.Spades),
|
||||
c(cards.Jack, cards.Hearts), c(4, cards.Clubs),
|
||||
},
|
||||
Seats: []Seat{
|
||||
{Name: "You", Stack: 500, Hole: [2]cards.Card{c(3, cards.Clubs), c(5, cards.Diamonds)}},
|
||||
// All-in for 100, and holding the best hand at the table.
|
||||
{Name: "Short", Bot: true, Hole: [2]cards.Card{c(cards.Ace, cards.Clubs), c(cards.Ace, cards.Diamonds)}},
|
||||
{Name: "Deep", Bot: true, Stack: 500, Hole: [2]cards.Card{c(cards.King, cards.Clubs), c(cards.Queen, cards.Diamonds)}},
|
||||
},
|
||||
}
|
||||
s.Seats[0].Total, s.Seats[0].State = 500, Active
|
||||
s.Seats[1].Total, s.Seats[1].State = 100, AllIn
|
||||
s.Seats[2].Total, s.Seats[2].State = 500, Active
|
||||
s.Pot = 1100 // 100 + 500 + 500
|
||||
|
||||
var evs []Event
|
||||
s.showdown(&evs)
|
||||
|
||||
// The main pot is 100 from each of the three. The other 800 is between the two
|
||||
// who were still betting, and the short stack cannot touch it.
|
||||
if s.Seats[1].Won != 300 {
|
||||
t.Errorf("all-in for 100 against two players, and won %d — the most that can ever "+
|
||||
"be won is the 300 main pot. The side pot was not cut.", s.Seats[1].Won)
|
||||
}
|
||||
if s.Seats[0].Won+s.Seats[2].Won != 800 {
|
||||
t.Errorf("the 800 side pot paid out %d between the two players who were "+
|
||||
"actually contesting it", s.Seats[0].Won+s.Seats[2].Won)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldedChipsStayInThePotButWinNothing(t *testing.T) {
|
||||
s := State{
|
||||
Tier: Tiers[1],
|
||||
Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}, {Name: "B", Bot: true}},
|
||||
}
|
||||
s.Seats[0].Total, s.Seats[0].State = 200, AllIn
|
||||
s.Seats[1].Total, s.Seats[1].State = 50, Folded // called 50 and gave up
|
||||
s.Seats[2].Total, s.Seats[2].State = 200, AllIn
|
||||
|
||||
s.sidePots()
|
||||
|
||||
var total int64
|
||||
for _, p := range s.Side {
|
||||
total += p.Amount
|
||||
for _, seat := range p.Eligible {
|
||||
if seat == 1 {
|
||||
t.Error("a folded seat is eligible to win a pot")
|
||||
}
|
||||
}
|
||||
}
|
||||
if total != 450 {
|
||||
t.Errorf("the pots hold %d, want 450 — the folder's 50 has to still be in there", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUncalledBetComesBack(t *testing.T) {
|
||||
s := State{
|
||||
Tier: Tiers[1],
|
||||
Seats: []Seat{{Name: "You", Stack: 0}, {Name: "A", Bot: true}},
|
||||
}
|
||||
s.Seats[0].Total, s.Seats[0].Bet, s.Seats[0].State = 500, 500, AllIn
|
||||
s.Seats[1].Total, s.Seats[1].State = 200, Folded
|
||||
|
||||
var evs []Event
|
||||
s.uncalled(&evs)
|
||||
|
||||
if s.Seats[You].Stack != 300 {
|
||||
t.Errorf("got %d back, want the 300 nobody called", s.Seats[You].Stack)
|
||||
}
|
||||
if s.Seats[You].Total != 200 {
|
||||
t.Errorf("still committed for %d, want 200 — the rest was never in the pot", s.Seats[You].Total)
|
||||
}
|
||||
if s.Seats[You].State != Active {
|
||||
t.Error("still marked all-in for chips that came back")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the rake --------------------------------------------------------------
|
||||
|
||||
func TestNoFlopNoDrop(t *testing.T) {
|
||||
s := State{Tier: Tiers[1], Flopped: false, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
|
||||
var evs []Event
|
||||
s.payPot(Pot{Amount: 1000, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
|
||||
|
||||
if s.Rake != 0 {
|
||||
t.Errorf("raked %d off a pot that never saw a flop", s.Rake)
|
||||
}
|
||||
if s.Seats[You].Stack != 1000 {
|
||||
t.Errorf("paid %d of a 1000 pot", s.Seats[You].Stack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRakeIsCapped(t *testing.T) {
|
||||
s := State{Tier: Tiers[1], Flopped: true, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
|
||||
var evs []Event
|
||||
// 5% of 10,000 is 500, but the cap is three big blinds — 30 at 5/10.
|
||||
s.payPot(Pot{Amount: 10000, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
|
||||
|
||||
want := s.Tier.BB * rakeCapBB
|
||||
if s.Rake != want {
|
||||
t.Errorf("raked %d, want the %d cap", s.Rake, want)
|
||||
}
|
||||
if s.Seats[You].Stack != 10000-want {
|
||||
t.Errorf("paid %d, want %d", s.Seats[You].Stack, 10000-want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRakeIsFivePercentUnderTheCap(t *testing.T) {
|
||||
s := State{Tier: Tiers[0], Flopped: true, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
|
||||
var evs []Event
|
||||
s.payPot(Pot{Amount: 100, Eligible: []int{You}}, []ranked{{seat: You}}, &evs) // cap is 6 at 1/2
|
||||
|
||||
if s.Rake != 5 {
|
||||
t.Errorf("raked %d off a 100 pot, want 5", s.Rake)
|
||||
}
|
||||
if s.Seats[You].Stack != 95 {
|
||||
t.Errorf("paid %d, want 95", s.Seats[You].Stack)
|
||||
}
|
||||
}
|
||||
|
||||
// The rake has to survive the wiring, not only the arithmetic.
|
||||
//
|
||||
// This is the test that was missing, and a browser found what it would have
|
||||
// found: New() overwrites the tier's rake with the one the casino hands it, and
|
||||
// the casino hands it a *fraction* (blackjack's 0.05). The tier declared 5,
|
||||
// meaning percent. Every other rake test builds a State by hand and sets the tier
|
||||
// itself, so not one of them ever saw the number a real table runs on — and the
|
||||
// house quietly took nothing from every pot for an afternoon.
|
||||
func TestTheRakeSurvivesTheConstructor(t *testing.T) {
|
||||
tier := Tiers[1] // 5/10, so the cap is 30
|
||||
s, _, err := New(tier, SoloSeats(tier, 1, tier.MaxBuy), 0.05, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Flopped = true
|
||||
|
||||
var evs []Event
|
||||
s.payPot(Pot{Amount: 400, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
|
||||
|
||||
if s.Rake != 20 {
|
||||
t.Fatalf("the house took %d of a 400 pot, want 20 — five percent of it. "+
|
||||
"RakePct is a fraction (0.05), not a percentage (5): see the note on Tiers.", s.Rake)
|
||||
}
|
||||
if !has(evs, "rake") {
|
||||
t.Error("the rake was taken with no event to say so, so the felt cannot show it")
|
||||
}
|
||||
}
|
||||
|
||||
// The house only makes money off you. A pot a bot wins is raked — that is what a
|
||||
// pot is — but the chips it comes out of are not real, so it has cost you nothing
|
||||
// and the number the felt quotes you must not move.
|
||||
func TestYouOnlyPayRakeOnPotsYouWin(t *testing.T) {
|
||||
s := State{Tier: Tiers[1], Flopped: true,
|
||||
Seats: []Seat{{Name: "You"}, {Name: "Dice", Bot: true}}}
|
||||
var evs []Event
|
||||
|
||||
// A bot takes it.
|
||||
s.payPot(Pot{Amount: 400, Eligible: []int{1}}, []ranked{{seat: 1}}, &evs)
|
||||
if s.Paid != 0 {
|
||||
t.Errorf("you paid %d in rake on a pot a bot won", s.Paid)
|
||||
}
|
||||
if s.Rake != 20 {
|
||||
t.Errorf("the table lifted %d off that pot, want 20 — the chips have to balance "+
|
||||
"whoever won it", s.Rake)
|
||||
}
|
||||
|
||||
// Now you take one.
|
||||
s.payPot(Pot{Amount: 400, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
|
||||
if s.Paid != 20 {
|
||||
t.Errorf("you paid %d in rake on a 400 pot you won, want 20", s.Paid)
|
||||
}
|
||||
if s.Rake != 40 {
|
||||
t.Errorf("the table has lifted %d in total, want 40", s.Rake)
|
||||
}
|
||||
|
||||
// And a chop costs you half of it.
|
||||
s.Paid, s.Rake = 0, 0
|
||||
s.payPot(Pot{Amount: 400, Eligible: []int{0, 1}},
|
||||
[]ranked{{seat: 0, rank: 9}, {seat: 1, rank: 9}}, &evs)
|
||||
if s.Paid != 10 {
|
||||
t.Errorf("you paid %d in rake on a chopped pot, want 10 — half the rake, "+
|
||||
"because you won half the pot", s.Paid)
|
||||
}
|
||||
}
|
||||
|
||||
// At a table with two humans the session-rake line each is shown is their own,
|
||||
// not the table's: the house took it out of a pot one of them won, and quoting it
|
||||
// to the other says the house has taken money off them that it has not. So the
|
||||
// per-seat tally only ever moves on the seat that won the pot.
|
||||
func TestRakeIsChargedToTheSeatThatWonIt(t *testing.T) {
|
||||
s := State{Tier: Tiers[1], Flopped: true,
|
||||
Seats: []Seat{{Name: "Reala"}, {Name: "Bob"}}}
|
||||
var evs []Event
|
||||
|
||||
// Reala wins a 400 pot. The rake on it (5%, 20) is Reala's to have paid.
|
||||
s.payPot(Pot{Amount: 400, Eligible: []int{0}}, []ranked{{seat: 0}}, &evs)
|
||||
if s.Seats[0].Paid != 20 {
|
||||
t.Errorf("the seat that won the pot paid %d in rake, want 20", s.Seats[0].Paid)
|
||||
}
|
||||
if s.Seats[1].Paid != 0 {
|
||||
t.Errorf("the other player was charged %d in rake for a pot they were not in", s.Seats[1].Paid)
|
||||
}
|
||||
if s.Paid != 20 {
|
||||
t.Errorf("the table total is %d, want 20 — it is still the sum for the audit", s.Paid)
|
||||
}
|
||||
|
||||
// Bob wins the next one. His tally moves; Reala's stays where it was.
|
||||
s.payPot(Pot{Amount: 200, Eligible: []int{1}}, []ranked{{seat: 1}}, &evs)
|
||||
if s.Seats[0].Paid != 20 {
|
||||
t.Errorf("Reala's tally moved on a pot Bob won: %d, want 20", s.Seats[0].Paid)
|
||||
}
|
||||
if s.Seats[1].Paid != 10 {
|
||||
t.Errorf("Bob paid %d in rake on a 200 pot he won, want 10", s.Seats[1].Paid)
|
||||
}
|
||||
if s.Paid != 30 {
|
||||
t.Errorf("the table total is %d, want 30", s.Paid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASplitPotSplits(t *testing.T) {
|
||||
s := State{Tier: Tiers[1], Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
|
||||
var evs []Event
|
||||
// Same rank: they chop. The odd chip goes to one of them, not into the air.
|
||||
s.payPot(Pot{Amount: 101, Eligible: []int{0, 1}},
|
||||
[]ranked{{seat: 0, rank: 500}, {seat: 1, rank: 500}}, &evs)
|
||||
|
||||
if got := s.Seats[0].Stack + s.Seats[1].Stack; got != 101 {
|
||||
t.Errorf("paid out %d of a 101 pot", got)
|
||||
}
|
||||
if s.Seats[0].Stack != 51 || s.Seats[1].Stack != 50 {
|
||||
t.Errorf("split %d/%d, want 51/50", s.Seats[0].Stack, s.Seats[1].Stack)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the session -----------------------------------------------------------
|
||||
|
||||
func TestYouCannotWalkOutOfALiveHand(t *testing.T) {
|
||||
s := table(t, Tiers[0], 2, 200)
|
||||
s, _, _ = apply(s, Move{Kind: Deal})
|
||||
if s.Phase != PhaseBetting {
|
||||
t.Skip("the hand ended before the player could act")
|
||||
}
|
||||
if _, _, err := apply(s, Move{Kind: Leave}); err != ErrHandLive {
|
||||
t.Errorf("leaving mid-hand gave %v, want ErrHandLive", err)
|
||||
}
|
||||
if _, _, err := apply(s, Move{Kind: TopUp, Amount: 10}); err != ErrHandLive {
|
||||
t.Errorf("topping up mid-hand gave %v, want ErrHandLive", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeavingTakesTheStackHome(t *testing.T) {
|
||||
s := table(t, Tiers[0], 1, 200)
|
||||
s, _, _ = apply(s, Move{Kind: Deal})
|
||||
s = playOut(t, s)
|
||||
|
||||
stack := s.Seats[You].Stack
|
||||
s, _, err := apply(s, Move{Kind: Leave})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Errorf("phase %q after leaving, want done", s.Phase)
|
||||
}
|
||||
if s.Payout != stack {
|
||||
t.Errorf("payout %d, want the %d that was in front of us", s.Payout, stack)
|
||||
}
|
||||
if _, _, err := apply(s, Move{Kind: Deal}); err != ErrOver {
|
||||
t.Errorf("dealt a hand at a table we got up from: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBustingEndsTheSession(t *testing.T) {
|
||||
s := table(t, Tiers[0], 1, 200)
|
||||
s.Seats[You].Stack = 0
|
||||
var evs []Event
|
||||
s.endHand(&evs)
|
||||
|
||||
if s.Phase != PhaseDone {
|
||||
t.Errorf("phase %q with no chips left, want done", s.Phase)
|
||||
}
|
||||
if s.Payout != 0 {
|
||||
t.Errorf("payout %d for a busted player", s.Payout)
|
||||
}
|
||||
if !has(evs, "bust") {
|
||||
t.Error("no bust event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestATopUpCannotGoOverTheTableMax(t *testing.T) {
|
||||
s := table(t, Tiers[0], 1, 200) // max buy is 200, and we're at it
|
||||
if _, _, err := apply(s, Move{Kind: TopUp, Amount: 1}); err != ErrBadBuyIn {
|
||||
t.Errorf("topped up over the table maximum: %v", err)
|
||||
}
|
||||
|
||||
s = table(t, Tiers[0], 1, 100)
|
||||
s, _, err := apply(s, Move{Kind: TopUp, Amount: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Seats[You].Stack != 150 {
|
||||
t.Errorf("stack is %d, want 150", s.Seats[You].Stack)
|
||||
}
|
||||
if s.BoughtIn != 150 {
|
||||
t.Errorf("bought in for %d, want 150 — the top-up is real money too", s.BoughtIn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABuyInHasToBeInRange(t *testing.T) {
|
||||
tier := Tiers[0]
|
||||
for _, amount := range []int64{0, tier.MinBuy - 1, tier.MaxBuy + 1} {
|
||||
if _, _, err := New(tier, SoloSeats(tier, 1, amount), 5, 1, 2); err != ErrBadBuyIn {
|
||||
t.Errorf("buy-in of %d at a %d–%d table: %v", amount, tier.MinBuy, tier.MaxBuy, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- what the raw script carries ------------------------------------------
|
||||
|
||||
// The engine no longer redacts the event stream, and this pins why: a shared
|
||||
// table has more than one human, so the engine cannot know who a stream is for.
|
||||
// It emits every seat's hole cards, and the *view* layer builds each viewer's
|
||||
// redacted copy — that per-seat redaction is the security boundary now, and it
|
||||
// has its own test in the web package (TestHoldemViewNeverLeaksAnotherSeatsCards).
|
||||
//
|
||||
// So the engine-level contract flipped: the deal must carry a hole event for
|
||||
// every dealt seat, or a viewer would have no cards of their own to be shown.
|
||||
func TestTheDealScriptCarriesEverySeatsHole(t *testing.T) {
|
||||
s := table(t, Tiers[0], 3, 200) // four seats: one human, three bots
|
||||
s, evs, err := apply(s, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
holes := map[int][]cards.Card{}
|
||||
for _, e := range evs {
|
||||
if e.Kind == "hole" {
|
||||
holes[e.Seat] = e.Cards
|
||||
}
|
||||
}
|
||||
for i := range s.Seats {
|
||||
if s.Seats[i].State == Out {
|
||||
continue
|
||||
}
|
||||
got := holes[i]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("seat %d got no hole event; the view has nothing to redact from", i)
|
||||
}
|
||||
if got[0] != s.Seats[i].Hole[0] || got[1] != s.Seats[i].Hole[1] {
|
||||
t.Errorf("seat %d hole event %v disagrees with state %v", i, got, s.Seats[i].Hole)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- hand strength ---------------------------------------------------------
|
||||
|
||||
func TestTheEvaluatorKnowsWhichHandIsBetter(t *testing.T) {
|
||||
board := []cards.Card{
|
||||
{Rank: 10, Suit: cards.Hearts}, {Rank: cards.Jack, Suit: cards.Hearts},
|
||||
{Rank: cards.Queen, Suit: cards.Hearts}, {Rank: 2, Suit: cards.Spades},
|
||||
{Rank: 7, Suit: cards.Clubs},
|
||||
}
|
||||
flush, _ := rankOf([2]cards.Card{{Rank: 3, Suit: cards.Hearts}, {Rank: 5, Suit: cards.Hearts}}, board)
|
||||
straight, _ := rankOf([2]cards.Card{{Rank: cards.King, Suit: cards.Spades}, {Rank: cards.Ace, Suit: cards.Clubs}}, board)
|
||||
royal, _ := rankOf([2]cards.Card{{Rank: cards.King, Suit: cards.Hearts}, {Rank: cards.Ace, Suit: cards.Hearts}}, board)
|
||||
pair, _ := rankOf([2]cards.Card{{Rank: 7, Suit: cards.Spades}, {Rank: 4, Suit: cards.Diamonds}}, board)
|
||||
|
||||
if !(royal < flush && flush < straight && straight < pair) {
|
||||
t.Errorf("hands rank royal=%d flush=%d straight=%d pair=%d — lower must be better, in that order",
|
||||
royal, flush, straight, pair)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquityKnowsAcesAreGood(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(1, 1))
|
||||
aces := equityOf([2]cards.Card{{Rank: cards.Ace, Suit: cards.Spades}, {Rank: cards.Ace, Suit: cards.Hearts}}, nil, 1, 2000, rng)
|
||||
rags := equityOf([2]cards.Card{{Rank: 7, Suit: cards.Spades}, {Rank: 2, Suit: cards.Hearts}}, nil, 1, 2000, rng)
|
||||
|
||||
if aces.Strength() < 0.8 {
|
||||
t.Errorf("pocket aces are worth %.2f heads-up, want about 0.85", aces.Strength())
|
||||
}
|
||||
if rags.Strength() > 0.4 {
|
||||
t.Errorf("seven-deuce is worth %.2f heads-up, want about 0.35", rags.Strength())
|
||||
}
|
||||
if aces.Strength() <= rags.Strength() {
|
||||
t.Error("seven-deuce is not better than pocket aces")
|
||||
}
|
||||
}
|
||||
|
||||
// The policy loads, and every node in it is a probability distribution.
|
||||
func TestThePolicyLoads(t *testing.T) {
|
||||
p := loadPolicy()
|
||||
if len(p) < 1000 {
|
||||
t.Fatalf("the CFR policy has %d nodes in it — it did not load, or it was never trained", len(p))
|
||||
}
|
||||
for key, probs := range p {
|
||||
var sum float64
|
||||
for _, v := range probs {
|
||||
if v < 0 {
|
||||
t.Fatalf("%s: a negative probability (%v)", key, probs)
|
||||
}
|
||||
sum += v
|
||||
}
|
||||
if sum < 0.99 || sum > 1.01 {
|
||||
t.Fatalf("%s: the probabilities sum to %v, not 1", key, sum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheBotsAreActuallyTrained is the test this game most needed and did not
|
||||
// have.
|
||||
//
|
||||
// A bot that cannot find itself in the policy does not fail. It shrugs, plays the
|
||||
// pot-odds rule, and looks exactly like a bot that is working — which is how
|
||||
// gogobee shipped a trained poker AI whose policy was *never read once* for the
|
||||
// entire life of the game. The trainer wrote its keys under IP/OOP and the table
|
||||
// looked them up under BTN/SB/BB, and there was nothing anywhere that would have
|
||||
// said so.
|
||||
//
|
||||
// So: deal real hands, let the bots think, and count how often the thinking lands
|
||||
// in the table. Heads-up is the number that has to hold — that is what the policy
|
||||
// was trained on. A six-handed table is a documented approximation of it and
|
||||
// drops off as seats are added, which is why this only asserts on the duel.
|
||||
func TestTheBotsAreActuallyTrained(t *testing.T) {
|
||||
hits.Store(0)
|
||||
misses.Store(0)
|
||||
|
||||
rng := rand.New(rand.NewPCG(11, 12))
|
||||
for game := 0; game < 40; game++ {
|
||||
tier := Tiers[1]
|
||||
s, _, err := New(tier, SoloSeats(tier, 1, tier.MaxBuy), tier.RakePct, uint64(game), 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for hand := 0; hand < 6 && s.Phase != PhaseDone; hand++ {
|
||||
s, _, err = apply(s, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for s.Phase == PhaseBetting {
|
||||
s, _, err = apply(s, randomMove(s, rng))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h, m := hits.Load(), misses.Load()
|
||||
if h+m < 100 {
|
||||
t.Fatalf("the bots only made %d decisions — this test isn't measuring anything", h+m)
|
||||
}
|
||||
rate := float64(h) / float64(h+m)
|
||||
if rate < 0.6 {
|
||||
t.Fatalf("heads-up, the bots found themselves in the trained policy %.0f%% of the time "+
|
||||
"(%d of %d decisions). They are playing the pot-odds fallback, which means the key the "+
|
||||
"trainer writes and the key the table reads have drifted apart. See infoSet.",
|
||||
rate*100, h, h+m)
|
||||
}
|
||||
t.Logf("heads-up policy hit rate: %.0f%% (%d of %d decisions)", rate*100, h, h+m)
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
func table(t *testing.T, tier Tier, bots int, buyIn int64) State {
|
||||
t.Helper()
|
||||
s, _, err := New(tier, SoloSeats(tier, bots, buyIn), tier.RakePct, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("new table: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// playOut folds every decision until the hand is over.
|
||||
func playOut(t *testing.T, s State) State {
|
||||
t.Helper()
|
||||
for i := 0; s.Phase == PhaseBetting; i++ {
|
||||
if i > 100 {
|
||||
t.Fatal("the hand will not end")
|
||||
}
|
||||
move := Move{Kind: Fold}
|
||||
if s.Owed(You) == 0 {
|
||||
move = Move{Kind: Check}
|
||||
}
|
||||
var err error
|
||||
s, _, err = apply(s, move)
|
||||
if err != nil {
|
||||
t.Fatalf("playing out: %v", err)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func has(evs []Event, kind string) bool {
|
||||
for _, e := range evs {
|
||||
if e.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Where you sit is decided when the button moves, and a fold does not move it.
|
||||
// Position walked the table with nextIn, which steps over folded seats while the
|
||||
// seat count still includes them — so as players mucked, the labels slid round
|
||||
// and a six-handed felt printed CO on three different seats at once. The badge is
|
||||
// the only thing that reads this, which is exactly why nothing caught it.
|
||||
func TestPositionsDoNotMoveWhenSeatsFold(t *testing.T) {
|
||||
s := table(t, Tiers[0], 5, 200) // six-handed
|
||||
s, _, _ = apply(s, Move{Kind: Deal})
|
||||
|
||||
before := make([]string, len(s.Seats))
|
||||
for i := range s.Seats {
|
||||
before[i] = s.Position(i)
|
||||
}
|
||||
|
||||
// Every seat has its own label, and the ones a six-max table prints are these.
|
||||
seen := map[string]int{}
|
||||
for _, p := range before {
|
||||
seen[p]++
|
||||
}
|
||||
for _, want := range []string{"BTN", "SB", "BB", "UTG", "MP", "CO"} {
|
||||
if seen[want] != 1 {
|
||||
t.Errorf("six-handed: %q appears %d times, want exactly once — got %v",
|
||||
want, seen[want], before)
|
||||
}
|
||||
}
|
||||
|
||||
// Now fold seats out of the hand, one at a time. Nobody's position changes by
|
||||
// mucking — folding is done to the state directly because a fold in the engine
|
||||
// belongs to whoever is to act, and what is under test is the label, not the turn.
|
||||
for i := range s.Seats {
|
||||
if i == You || s.Seats[i].State != Active {
|
||||
continue
|
||||
}
|
||||
s.Seats[i].State = Folded
|
||||
for j := range s.Seats {
|
||||
if got := s.Position(j); got != before[j] {
|
||||
t.Fatalf("seat %d was %q and is now %q after seat %d folded — position is "+
|
||||
"where you sit, not who is left", j, before[j], got, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
internal/games/holdem/multiway_test.go
Normal file
112
internal/games/holdem/multiway_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package holdem
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The reshape's own guard: a table with more than one human actually plays, and
|
||||
// the chips still conserve when the person to act is not always seat zero.
|
||||
//
|
||||
// The solo suite proves the engine still behaves as it did; this proves the thing
|
||||
// that changed. Two humans and two bots sit down, and the driver plays whichever
|
||||
// human the action stops on — which is the whole point of the multiway advance:
|
||||
// it runs the bots itself and hands control back at every *human* seat, not just
|
||||
// at seat zero.
|
||||
|
||||
// randomMoveFor picks a legal move for a specific seat, the multiway sibling of
|
||||
// randomMove. It never folds when it can check, so hands actually develop.
|
||||
func randomMoveFor(s State, seat int, rng *rand.Rand) Move {
|
||||
owed := s.Owed(seat)
|
||||
var legal []Move
|
||||
if owed > 0 {
|
||||
legal = append(legal, Move{Kind: Fold}, Move{Kind: Call})
|
||||
} else {
|
||||
legal = append(legal, Move{Kind: Check})
|
||||
}
|
||||
if s.Seats[seat].Stack > owed && s.canBet() {
|
||||
if to := s.MinRaiseTo(seat); to < s.MaxRaiseTo(seat) {
|
||||
legal = append(legal, Move{Kind: Raise, To: to})
|
||||
}
|
||||
}
|
||||
return legal[rng.IntN(len(legal))]
|
||||
}
|
||||
|
||||
func TestMultiwayChipsAreConserved(t *testing.T) {
|
||||
for game := 0; game < 100; game++ {
|
||||
rng := rand.New(rand.NewPCG(uint64(game), 71))
|
||||
tier := Tiers[game%len(Tiers)]
|
||||
|
||||
// Two humans, two bots. The humans sit at 0 and 2 so the action genuinely
|
||||
// lands on a non-zero human seat, which is the case the old engine could not
|
||||
// have reached.
|
||||
seats := []SeatConfig{
|
||||
{Name: "Ana", Stack: tier.MaxBuy},
|
||||
{Name: "Bot A", Bot: true, Stack: tier.MaxBuy},
|
||||
{Name: "Bo", Stack: tier.MaxBuy},
|
||||
{Name: "Bot B", Bot: true, Stack: tier.MaxBuy},
|
||||
}
|
||||
s, _, err := New(tier, seats, tier.RakePct, uint64(game), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("new table: %v", err)
|
||||
}
|
||||
want := chipsAt(s)
|
||||
|
||||
for hand := 0; hand < 8 && s.Phase == PhaseHandOver; hand++ {
|
||||
var evs []Event
|
||||
s, evs, err = ApplyMove(s, 0, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatalf("game %d hand %d: deal: %v", game, hand, err)
|
||||
}
|
||||
want += reloaded(evs)
|
||||
check(t, s, want, game, hand, "deal")
|
||||
|
||||
for step := 0; s.Phase == PhaseBetting; step++ {
|
||||
if step > 400 {
|
||||
t.Fatalf("game %d hand %d: the hand will not end", game, hand)
|
||||
}
|
||||
seat := s.ToAct
|
||||
if s.Seats[seat].Bot {
|
||||
t.Fatalf("game %d: advance stopped on bot seat %d — it should run bots itself", game, seat)
|
||||
}
|
||||
s, _, err = ApplyMove(s, seat, randomMoveFor(s, seat, rng))
|
||||
if err != nil {
|
||||
t.Fatalf("game %d hand %d seat %d: %v", game, hand, seat, err)
|
||||
}
|
||||
check(t, s, want, game, hand, "move")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiwayRejectsOutOfTurnMoves pins that a human cannot act when it is
|
||||
// another human's turn — the betting move is legal only from the seat to act.
|
||||
func TestMultiwayRejectsOutOfTurnMoves(t *testing.T) {
|
||||
tier := Tiers[0]
|
||||
seats := []SeatConfig{
|
||||
{Name: "Ana", Stack: tier.MaxBuy},
|
||||
{Name: "Bo", Stack: tier.MaxBuy},
|
||||
}
|
||||
s, _, err := New(tier, seats, tier.RakePct, 3, 9)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _, err = ApplyMove(s, 0, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseBetting {
|
||||
t.Fatalf("want a live hand, got phase %s", s.Phase)
|
||||
}
|
||||
|
||||
// Whoever is not to act tries to move. It must be refused, and nothing must
|
||||
// change.
|
||||
other := 1 - s.ToAct
|
||||
before := chipsAt(s)
|
||||
if _, _, err := ApplyMove(s, other, Move{Kind: Call}); err != ErrNotYourTurn {
|
||||
t.Fatalf("want ErrNotYourTurn from the seat not to act, got %v", err)
|
||||
}
|
||||
if got := chipsAt(s); got != before {
|
||||
t.Errorf("a refused move moved chips: %d -> %d", before, got)
|
||||
}
|
||||
}
|
||||
BIN
internal/games/holdem/policy.gob
Normal file
BIN
internal/games/holdem/policy.gob
Normal file
Binary file not shown.
431
internal/games/holdem/train.go
Normal file
431
internal/games/holdem/train.go
Normal file
@@ -0,0 +1,431 @@
|
||||
package holdem
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// The trainer.
|
||||
//
|
||||
// This is counterfactual regret minimisation, and what it produces is policy.gob
|
||||
// — the table the bots read at the table. It is not on any request path; it runs
|
||||
// from cmd/holdem-train, for half an hour, and then it is a file.
|
||||
//
|
||||
// The one thing worth understanding about it: **it plays the real game.** Every
|
||||
// move it explores goes through Step, which is the same reducer the felt calls,
|
||||
// so the blinds, the min-raise, the street completion and the money are the ones
|
||||
// a player will actually meet. Its info-set key comes out of State.spot, which is
|
||||
// the same function the bots look themselves up with.
|
||||
//
|
||||
// That is not tidiness, it is the whole lesson of the policy this replaces. That
|
||||
// one was trained against a hand-written model of poker sitting beside the real
|
||||
// engine — a model where a call always ended the street, the big blind had no
|
||||
// option, and the payoff was half the pot no matter who had put what in. Then it
|
||||
// was looked up under a key the trainer never wrote. The result was a 3.4MB file
|
||||
// that had never once been read, and nobody could tell, because a policy miss is
|
||||
// not an error. It just quietly isn't there.
|
||||
//
|
||||
// So: one engine, one key function, and a test that fails if the bots stop
|
||||
// finding themselves in the table.
|
||||
|
||||
// How much of the game tree to explore. Two raises a street keeps the tree small
|
||||
// enough to converge; a third barely changes how anybody plays and multiplies the
|
||||
// nodes.
|
||||
const (
|
||||
maxRaisesPerStreet = 2
|
||||
maxDepth = 40
|
||||
trainMCIters = 60 // noisy, but it is only picking a bucket
|
||||
)
|
||||
|
||||
// regrets is what CFR accumulates: how much better each action would have been.
|
||||
type regrets map[string]*[numActions]float64
|
||||
|
||||
// Trained is the file the bots read.
|
||||
type Trained struct {
|
||||
Strategy map[string][numActions]float64
|
||||
Meta TrainMeta
|
||||
}
|
||||
|
||||
// TrainMeta is what the policy can say about itself. Worth having: a policy is
|
||||
// otherwise an opaque three megabytes and there is no way to tell a good one from
|
||||
// a stale one by looking.
|
||||
type TrainMeta struct {
|
||||
Iterations int
|
||||
Stakes string
|
||||
Depths string
|
||||
Nodes int
|
||||
}
|
||||
|
||||
// ---- preflop, measured once ------------------------------------------------
|
||||
|
||||
var (
|
||||
preflopOnce sync.Once
|
||||
preflopTable [13][13]Equity // [hi][lo] offsuit, [lo][hi] suited, diagonal pairs
|
||||
)
|
||||
|
||||
// preflopEquity is the equity of a starting hand heads-up. There are only 169
|
||||
// hands that differ from each other, so they are measured properly, once, and
|
||||
// then it is a lookup — which matters twice: it takes the noise out of a bucket
|
||||
// boundary, and the trainer visits preflop on every single iteration.
|
||||
func preflopEquity(hole [2]cards.Card) Equity {
|
||||
preflopOnce.Do(func() {
|
||||
rng := cards.NewRNG(20260714, 1)
|
||||
for a := cards.Ace; a <= cards.King; a++ {
|
||||
for b := a; b <= cards.King; b++ {
|
||||
lo, hi := rankIdx(a), rankIdx(b)
|
||||
|
||||
// Suited, and the pairs (which can only be offsuit) on the diagonal.
|
||||
s1 := cards.Card{Rank: a, Suit: cards.Spades}
|
||||
s2 := cards.Card{Rank: b, Suit: cards.Spades}
|
||||
if a == b {
|
||||
s2.Suit = cards.Hearts
|
||||
}
|
||||
preflopTable[lo][hi] = equityOf([2]cards.Card{s1, s2}, nil, 1, 10000, rng)
|
||||
|
||||
if a != b {
|
||||
o2 := cards.Card{Rank: b, Suit: cards.Hearts}
|
||||
preflopTable[hi][lo] = equityOf([2]cards.Card{s1, o2}, nil, 1, 10000, rng)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
lo, hi := rankIdx(hole[0].Rank), rankIdx(hole[1].Rank)
|
||||
if lo > hi {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
if hole[0].Suit == hole[1].Suit {
|
||||
return preflopTable[lo][hi] // suited, and the pairs sit here too
|
||||
}
|
||||
if lo == hi {
|
||||
return preflopTable[lo][hi]
|
||||
}
|
||||
return preflopTable[hi][lo] // offsuit
|
||||
}
|
||||
|
||||
// rankIdx maps a rank to 0–12, with the ace high — which is what it is, before
|
||||
// the flop.
|
||||
func rankIdx(r cards.Rank) int {
|
||||
if r == cards.Ace {
|
||||
return 12
|
||||
}
|
||||
return int(r) - 2
|
||||
}
|
||||
|
||||
// ---- the traversal ---------------------------------------------------------
|
||||
|
||||
// Train runs external-sampling MCCFR for n hands and returns the average
|
||||
// strategy. Each worker keeps its own tables and they are summed at the end,
|
||||
// which is what makes this embarrassingly parallel and is the only reason it
|
||||
// finishes in half an hour.
|
||||
func Train(n, workers int, t Tier, minBB, maxBB int64, seed uint64, progress func(done int)) *Trained {
|
||||
if workers < 1 {
|
||||
workers = 1
|
||||
}
|
||||
|
||||
type table struct {
|
||||
reg regrets
|
||||
avg regrets
|
||||
}
|
||||
out := make([]table, workers)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var done sync.Mutex
|
||||
completed := 0
|
||||
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
tr := &trainer{
|
||||
reg: regrets{},
|
||||
avg: regrets{},
|
||||
tier: t,
|
||||
minBB: minBB,
|
||||
maxBB: maxBB,
|
||||
rng: cards.NewRNG(seed, uint64(w)+1),
|
||||
}
|
||||
|
||||
share := n / workers
|
||||
if w < n%workers {
|
||||
share++
|
||||
}
|
||||
for i := 0; i < share; i++ {
|
||||
tr.iterate(uint64(w)<<40 | uint64(i))
|
||||
// i+1, not i: the check fired on the very first pass and credited two
|
||||
// thousand hands before a single one had been walked, which with thirty
|
||||
// workers made the first ETA sixty thousand hands optimistic.
|
||||
if progress != nil && (i+1)%2000 == 0 {
|
||||
done.Lock()
|
||||
completed += 2000
|
||||
c := completed
|
||||
done.Unlock()
|
||||
progress(c)
|
||||
}
|
||||
}
|
||||
out[w] = table{tr.reg, tr.avg}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Sum the workers' average-strategy tables, then normalise each node into the
|
||||
// probabilities a bot will actually play.
|
||||
total := regrets{}
|
||||
for _, tab := range out {
|
||||
for key, v := range tab.avg {
|
||||
acc, ok := total[key]
|
||||
if !ok {
|
||||
acc = &[numActions]float64{}
|
||||
total[key] = acc
|
||||
}
|
||||
for i, x := range v {
|
||||
acc[i] += x
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
strategy := make(map[string][numActions]float64, len(total))
|
||||
for key, v := range total {
|
||||
var sum float64
|
||||
for _, x := range v {
|
||||
sum += x
|
||||
}
|
||||
var probs [numActions]float64
|
||||
if sum > 0 {
|
||||
for i, x := range v {
|
||||
probs[i] = x / sum
|
||||
}
|
||||
} else {
|
||||
for i := range probs {
|
||||
probs[i] = 1.0 / numActions
|
||||
}
|
||||
}
|
||||
strategy[key] = probs
|
||||
}
|
||||
|
||||
return &Trained{
|
||||
Strategy: strategy,
|
||||
Meta: TrainMeta{
|
||||
Iterations: n,
|
||||
Stakes: fmt.Sprintf("%d/%d", t.SB, t.BB),
|
||||
Depths: fmt.Sprintf("%d–%d BB", minBB, maxBB),
|
||||
Nodes: len(strategy),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type trainer struct {
|
||||
reg regrets
|
||||
avg regrets
|
||||
tier Tier
|
||||
minBB int64
|
||||
maxBB int64
|
||||
rng *rand.Rand
|
||||
|
||||
// A hand's equity on a given street depends on the cards and nothing else —
|
||||
// not on how the betting went to get there. The deck is fixed for the whole
|
||||
// iteration, so the flop is the same flop down every branch, and this is
|
||||
// measured once per seat per street instead of once per node.
|
||||
eq [2][4]Equity
|
||||
have [2][4]bool
|
||||
}
|
||||
|
||||
// iterate deals one hand and walks it once for each player.
|
||||
//
|
||||
// The stack depth is drawn fresh every hand, across the whole range the table
|
||||
// allows. This is the fix for the policy that came before: it was trained at ten
|
||||
// big blinds and nothing else, so four out of five spots in a real cash game fell
|
||||
// outside anything it had ever seen. A hand of poker is a different game at 20
|
||||
// big blinds than at 100 — that is most of what makes it a game — and the bots
|
||||
// have to have played both.
|
||||
func (tr *trainer) iterate(id uint64) {
|
||||
depth := tr.minBB
|
||||
if tr.maxBB > tr.minBB {
|
||||
depth += tr.rng.Int64N(tr.maxBB - tr.minBB + 1)
|
||||
}
|
||||
stack := depth * tr.tier.BB
|
||||
|
||||
// No rake while learning. The bots should learn to play poker, not to beat a
|
||||
// fee, and the fee is the house's business.
|
||||
t := tr.tier
|
||||
t.RakePct = 0
|
||||
|
||||
s, err := open(t, stack, stack, id, tr.rng.Uint64())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
start := [2]int64{s.Seats[0].Stack + s.Seats[0].Bet, s.Seats[1].Stack + s.Seats[1].Bet}
|
||||
|
||||
tr.have = [2][4]bool{} // one deal, one set of boards, one set of equities
|
||||
|
||||
for me := 0; me < 2; me++ {
|
||||
tr.walk(s.clone(), me, start, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// equity is the cached measurement for this seat on this street.
|
||||
func (tr *trainer) equity(s State, seat int) Equity {
|
||||
st := s.Street
|
||||
if st > River {
|
||||
st = River
|
||||
}
|
||||
if !tr.have[seat][st] {
|
||||
tr.eq[seat][st] = s.equityFor(seat, trainMCIters, tr.rng)
|
||||
tr.have[seat][st] = true
|
||||
}
|
||||
return tr.eq[seat][st]
|
||||
}
|
||||
|
||||
// walk returns what the hand is worth to `me`, in chips, from here.
|
||||
func (tr *trainer) walk(s State, me int, start [2]int64, depth int) float64 {
|
||||
if s.Phase != PhaseBetting || depth > maxDepth {
|
||||
// The hand is over (or we have gone far enough to call it over). What it was
|
||||
// worth is simply what the player has now against what they sat down with —
|
||||
// the real number, out of the real engine, side pots and all.
|
||||
return float64(s.Seats[me].Stack - start[me])
|
||||
}
|
||||
|
||||
seat := s.ToAct
|
||||
key := s.spotKey(seat, tr.equity(s, seat))
|
||||
|
||||
mask := s.mask(seat)
|
||||
if raises(s.History) >= maxRaisesPerStreet {
|
||||
mask[actRaiseHalf], mask[actRaisePot] = false, false
|
||||
}
|
||||
|
||||
reg := tr.reg[key]
|
||||
if reg == nil {
|
||||
reg = &[numActions]float64{}
|
||||
tr.reg[key] = reg
|
||||
}
|
||||
strat := match(*reg, mask)
|
||||
|
||||
// The opponent's turn: sample one line and follow it. That is the "external
|
||||
// sampling" part, and it is what keeps a hand from costing 5^12 traversals.
|
||||
if seat != me {
|
||||
avg := tr.avg[key]
|
||||
if avg == nil {
|
||||
avg = &[numActions]float64{}
|
||||
tr.avg[key] = avg
|
||||
}
|
||||
for i, p := range strat {
|
||||
avg[i] += p
|
||||
}
|
||||
return tr.walk(tr.play(s, seat, sample(strat, tr.rng)), me, start, depth+1)
|
||||
}
|
||||
|
||||
// Our turn: try everything, and regret what we didn't do.
|
||||
var values [numActions]float64
|
||||
var node float64
|
||||
for a := 0; a < numActions; a++ {
|
||||
if !mask[a] {
|
||||
continue
|
||||
}
|
||||
values[a] = tr.walk(tr.play(s, seat, a), me, start, depth+1)
|
||||
node += strat[a] * values[a]
|
||||
}
|
||||
for a := 0; a < numActions; a++ {
|
||||
if mask[a] {
|
||||
reg[a] += values[a] - node
|
||||
}
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// play applies one abstract action through the real reducer.
|
||||
func (tr *trainer) play(s State, seat, action int) State {
|
||||
next, _, err := step(s.clone(), s.moveFor(action, seat))
|
||||
if err != nil {
|
||||
// The mask and the rules disagreed, which is a bug in one of them. Fold and
|
||||
// carry on rather than poison the whole run.
|
||||
next, _, err = step(s.clone(), Move{Kind: Fold})
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// match is regret matching: play each action in proportion to how much you wish
|
||||
// you had played it. An action nobody regrets not taking gets played uniformly.
|
||||
func match(reg [numActions]float64, mask [numActions]bool) [numActions]float64 {
|
||||
var strat [numActions]float64
|
||||
var sum float64
|
||||
for i, r := range reg {
|
||||
if mask[i] && r > 0 {
|
||||
sum += r
|
||||
}
|
||||
}
|
||||
if sum > 0 {
|
||||
for i, r := range reg {
|
||||
if mask[i] && r > 0 {
|
||||
strat[i] = r / sum
|
||||
}
|
||||
}
|
||||
return strat
|
||||
}
|
||||
|
||||
n := 0
|
||||
for _, ok := range mask {
|
||||
if ok {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
strat[actCallCheck] = 1
|
||||
return strat
|
||||
}
|
||||
for i, ok := range mask {
|
||||
if ok {
|
||||
strat[i] = 1 / float64(n)
|
||||
}
|
||||
}
|
||||
return strat
|
||||
}
|
||||
|
||||
func sample(strat [numActions]float64, rng *rand.Rand) int {
|
||||
r := rng.Float64()
|
||||
var sum float64
|
||||
for i, p := range strat {
|
||||
sum += p
|
||||
if r < sum {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return actCallCheck
|
||||
}
|
||||
|
||||
// raises counts the bets and raises on this street, which is what the tree is
|
||||
// capped on.
|
||||
func raises(history string) int {
|
||||
n := 0
|
||||
for _, c := range history {
|
||||
if c == 'r' || c == 'R' {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ---- the file --------------------------------------------------------------
|
||||
|
||||
// Save writes a trained policy.
|
||||
func Save(w io.Writer, t *Trained) error { return gob.NewEncoder(w).Encode(t) }
|
||||
|
||||
// Load reads one. It is only used by the tests — the bots read the embedded copy.
|
||||
func Load(r io.Reader) (*Trained, error) {
|
||||
var t Trained
|
||||
if err := gob.NewDecoder(r).Decode(&t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// loadTrained decodes the embedded policy in the new format.
|
||||
func loadTrained(b []byte) (*Trained, error) { return Load(bytes.NewReader(b)) }
|
||||
684
internal/games/klondike/klondike.go
Normal file
684
internal/games/klondike/klondike.go
Normal file
@@ -0,0 +1,684 @@
|
||||
// Package klondike is a pure Klondike solitaire engine, played for chips.
|
||||
//
|
||||
// Same seam as blackjack and hangman: ApplyMove(state, move) (state, events,
|
||||
// error), where an error means the move was illegal and nothing else. The state
|
||||
// is a plain value, so a game survives a redeploy and replays from its seed.
|
||||
//
|
||||
// The casino version is Vegas scoring, which is the only way solitaire has ever
|
||||
// been a gambling game and the only shape that makes sense with money on it.
|
||||
// You do not win or lose the deal. You *buy the deck* for your stake, and every
|
||||
// card you get home to a foundation pays a slice of it back. Fifty-two cards
|
||||
// home pays the tier's full multiple; nothing home pays nothing. You can stop
|
||||
// whenever you like and keep what you have banked, which is what makes a game
|
||||
// that has gone dead a decision rather than a wall.
|
||||
//
|
||||
// There is no undo. The stake is spent the moment the deck is bought, so an undo
|
||||
// would be a way to walk a losing board backwards until it wins.
|
||||
package klondike
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// Errors an illegal move can produce.
|
||||
var (
|
||||
ErrGameOver = errors.New("klondike: the game is already over")
|
||||
ErrUnknownMove = errors.New("klondike: unknown move")
|
||||
ErrBadBet = errors.New("klondike: bet must be positive")
|
||||
ErrUnknownTier = errors.New("klondike: no such tier")
|
||||
ErrBadPile = errors.New("klondike: no such pile")
|
||||
ErrEmptyPile = errors.New("klondike: there is nothing there to move")
|
||||
ErrNotASequence = errors.New("klondike: those cards aren't a run you can lift")
|
||||
ErrWontGo = errors.New("klondike: that card doesn't go there")
|
||||
ErrNoDraw = errors.New("klondike: there is nothing left to turn over")
|
||||
ErrNoPasses = errors.New("klondike: you've used your passes through the stock")
|
||||
ErrNothingHome = errors.New("klondike: nothing can go home right now")
|
||||
)
|
||||
|
||||
// Piles is the number of tableau columns. Foundations is one per suit.
|
||||
const (
|
||||
Piles = 7
|
||||
Foundations = 4
|
||||
FullDeck = 52
|
||||
)
|
||||
|
||||
// Tier is a difficulty, chosen with the bet. The two dials are how many cards
|
||||
// the stock turns over at a time and how many times you may go through it —
|
||||
// which between them are the whole difficulty of Klondike. Turning three at a
|
||||
// time hides two of every three cards behind a card you may never reach; a
|
||||
// single pass means the ones you leave behind are gone for good.
|
||||
//
|
||||
// The multiple pays for that. Cutthroat is the cruellest deal in the room and
|
||||
// pays 3.4×, which means you are ahead from sixteen cards home even though most
|
||||
// of those boards never clear.
|
||||
type Tier struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Draw int `json:"draw"` // cards turned over per pull on the stock
|
||||
Passes int `json:"passes"` // times through the stock; 0 means unlimited
|
||||
Base float64 `json:"base"` // what a full 52 cards home pays, as a multiple of the stake
|
||||
Blurb string `json:"blurb"`
|
||||
}
|
||||
|
||||
// BreakEven is how many cards have to reach the foundations before the player is
|
||||
// square with the house. It's the number the felt actually quotes, because
|
||||
// "1.4×" tells a player nothing about a game where the multiple is paid per card.
|
||||
func (t Tier) BreakEven() int {
|
||||
if t.Base <= 0 {
|
||||
return FullDeck
|
||||
}
|
||||
n := int(math.Ceil(float64(FullDeck) / t.Base))
|
||||
if n > FullDeck {
|
||||
return FullDeck
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Tiers are the three deals.
|
||||
var Tiers = []Tier{
|
||||
{Slug: "patient", Name: "Patient", Draw: 1, Passes: 0, Base: 1.4,
|
||||
Blurb: "One card at a time, through the stock as often as you like."},
|
||||
{Slug: "vegas", Name: "Vegas", Draw: 3, Passes: 3, Base: 2.2,
|
||||
Blurb: "Three at a time, three times round. The house game."},
|
||||
{Slug: "cutthroat", Name: "Cutthroat", Draw: 3, Passes: 1, Base: 3.4,
|
||||
Blurb: "Three at a time, one pass. What you leave behind is gone."},
|
||||
}
|
||||
|
||||
// TierBySlug finds a tier by the name the browser sent.
|
||||
func TierBySlug(slug string) (Tier, error) {
|
||||
for _, t := range Tiers {
|
||||
if t.Slug == slug {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return Tier{}, ErrUnknownTier
|
||||
}
|
||||
|
||||
// Phase is where the game is.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlaying Phase = "playing"
|
||||
PhaseDone Phase = "done"
|
||||
)
|
||||
|
||||
// Outcome is how it ended. Note there is no "lost": a board that goes dead is
|
||||
// cashed, for whatever it made. Solitaire's failure mode is a board you can't
|
||||
// improve, and the honest thing to do with one is pay out what's on it.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeNone Outcome = ""
|
||||
OutcomeCleared Outcome = "cleared" // all 52 home
|
||||
OutcomeCashed Outcome = "cashed" // the player stopped and took the board
|
||||
)
|
||||
|
||||
// Pile is one tableau column: a face-down stack with a face-up run on top of it.
|
||||
// Down is the part the browser never sees.
|
||||
type Pile struct {
|
||||
Down []cards.Card `json:"down"`
|
||||
Up []cards.Card `json:"up"`
|
||||
}
|
||||
|
||||
// State is one game. The stock and every Down card are in here, which is exactly
|
||||
// why this value never leaves the server.
|
||||
type State struct {
|
||||
Tier Tier `json:"tier"`
|
||||
Stock cards.Deck `json:"stock"`
|
||||
Waste []cards.Card `json:"waste"`
|
||||
Table [Piles]Pile `json:"table"`
|
||||
Found [Foundations][]cards.Card `json:"found"` // indexed by suit
|
||||
Recycles int `json:"recycles"` // times the waste has gone back under
|
||||
Moves int `json:"moves"`
|
||||
RakePct float64 `json:"rake_pct"`
|
||||
|
||||
Bet int64 `json:"bet"`
|
||||
Phase Phase `json:"phase"`
|
||||
Outcome Outcome `json:"outcome"`
|
||||
Payout int64 `json:"payout"`
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Event is something the table animates. The engine emits them rather than
|
||||
// leaving the browser to diff two boards and guess what moved — a card that
|
||||
// slides from a column to a foundation and a card that was simply redrawn there
|
||||
// are the same diff and very different things to watch.
|
||||
//
|
||||
// Home and Pays ride on every event, so the meter on the felt is always quoting
|
||||
// a number the engine worked out. The browser never does this arithmetic: it did
|
||||
// once, and the felt advertised a payout the house didn't honour.
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // "deal" | "draw" | "recycle" | "move" | "home" | "flip" | "settle"
|
||||
Cards []cards.Card `json:"cards,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Home int `json:"home"`
|
||||
Pays int64 `json:"pays"`
|
||||
}
|
||||
|
||||
// Move is a player action.
|
||||
//
|
||||
// Home is its own kind rather than a Move To a foundation the player picked,
|
||||
// because there is only ever one foundation a card can go to and asking the
|
||||
// player to name it would be a quiz about suit ordering. The browser sends
|
||||
// "this card, home"; the engine finds the pile.
|
||||
type Move struct {
|
||||
Kind string `json:"kind"` // "draw" | "move" | "home" | "auto" | "concede"
|
||||
From string `json:"from"` // "waste" | "t0".."t6" | "f0".."f3"
|
||||
To string `json:"to"` // "t0".."t6" | "f0".."f3"
|
||||
Count int `json:"count"` // how many cards off the end of a tableau run; 0 means 1
|
||||
}
|
||||
|
||||
// New deals a game.
|
||||
func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
if t.Draw < 1 {
|
||||
return State{}, nil, ErrUnknownTier
|
||||
}
|
||||
d := cards.NewDeck(1)
|
||||
d.Shuffle(rng)
|
||||
return deal(bet, t, d, rakePct)
|
||||
}
|
||||
|
||||
// deal lays the board out. Split out from New so a test can pin the deck
|
||||
// instead of the seed.
|
||||
func deal(bet int64, t Tier, d cards.Deck, rakePct float64) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
if len(d) != FullDeck {
|
||||
return State{}, nil, errors.New("klondike: a solitaire deck is 52 cards")
|
||||
}
|
||||
s := State{Tier: t, Bet: bet, RakePct: rakePct, Phase: PhasePlaying}
|
||||
|
||||
// The classic lay-out: column i gets i+1 cards, the last of them face up.
|
||||
for i := 0; i < Piles; i++ {
|
||||
for j := 0; j <= i; j++ {
|
||||
c, _ := d.Draw()
|
||||
if j == i {
|
||||
s.Table[i].Up = append(s.Table[i].Up, c)
|
||||
} else {
|
||||
s.Table[i].Down = append(s.Table[i].Down, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.Stock = d
|
||||
return s, []Event{s.event("deal", nil, "", "")}, nil
|
||||
}
|
||||
|
||||
// ApplyMove is the engine. A legal move in, the new board and what happened out.
|
||||
// An error means the move was illegal and the caller's state is untouched.
|
||||
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
if s.Phase == PhaseDone {
|
||||
return s, nil, ErrGameOver
|
||||
}
|
||||
// The move is played against a copy, and an illegal one hands the original
|
||||
// back untouched. Nothing below mutates before it has decided the move is
|
||||
// legal — but "nothing below mutates early" is an invariant seven functions
|
||||
// have to keep, and this is one line that doesn't need them to.
|
||||
orig := s
|
||||
s = s.clone()
|
||||
|
||||
var evs []Event
|
||||
var err error
|
||||
switch m.Kind {
|
||||
case "draw":
|
||||
evs, err = s.draw()
|
||||
case "move":
|
||||
evs, err = s.move(m.From, m.To, m.Count)
|
||||
case "home":
|
||||
evs, err = s.home(m.From)
|
||||
case "auto":
|
||||
evs, err = s.auto()
|
||||
case "concede":
|
||||
s.settle(OutcomeCashed, &evs)
|
||||
return s, evs, nil
|
||||
default:
|
||||
return orig, nil, ErrUnknownMove
|
||||
}
|
||||
if err != nil {
|
||||
return orig, nil, err
|
||||
}
|
||||
s.Moves++
|
||||
|
||||
// A cleared board settles itself. Nothing else does: a board with no move left
|
||||
// on it is not something the engine gets to decide, because "no move left" in
|
||||
// Klondike depends on cards nobody has turned over yet.
|
||||
if s.cleared() {
|
||||
s.settle(OutcomeCleared, &evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// ---- the moves -------------------------------------------------------------
|
||||
|
||||
// draw turns cards off the stock, or puts the waste back under it if the stock
|
||||
// is spent and the tier still owes a pass.
|
||||
func (s *State) draw() ([]Event, error) {
|
||||
if len(s.Stock) == 0 {
|
||||
if len(s.Waste) == 0 {
|
||||
return nil, ErrNoDraw
|
||||
}
|
||||
// Passes is how many times you may go *through* the stock, so the number of
|
||||
// times you may turn it back over is one less than that. Zero means unlimited.
|
||||
if s.Tier.Passes > 0 && s.Recycles >= s.Tier.Passes-1 {
|
||||
return nil, ErrNoPasses
|
||||
}
|
||||
// The waste is turned over as a block, not reshuffled — so the card that
|
||||
// comes out first on the next pass is the one that came out first on this
|
||||
// one. Which means no reversal: the waste's *bottom* card is the one your
|
||||
// hand lands on when you flip the pile, and the bottom card is the one that
|
||||
// was drawn first. Reversing here would deal a different game on every pass
|
||||
// and quietly break the seed in the audit log.
|
||||
s.Stock = cards.Deck(s.Waste)
|
||||
s.Waste = nil
|
||||
s.Recycles++
|
||||
return []Event{s.event("recycle", nil, "waste", "stock")}, nil
|
||||
}
|
||||
|
||||
n := s.Tier.Draw
|
||||
if n > len(s.Stock) {
|
||||
n = len(s.Stock)
|
||||
}
|
||||
drawn := make([]cards.Card, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
c, _ := s.Stock.Draw()
|
||||
drawn = append(drawn, c)
|
||||
s.Waste = append(s.Waste, c)
|
||||
}
|
||||
return []Event{s.event("draw", drawn, "stock", "waste")}, nil
|
||||
}
|
||||
|
||||
// move takes cards from one pile and puts them on another.
|
||||
func (s *State) move(from, to string, count int) ([]Event, error) {
|
||||
if count < 1 {
|
||||
count = 1
|
||||
}
|
||||
lifted, err := s.peek(from, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.accepts(to, lifted) {
|
||||
return nil, ErrWontGo
|
||||
}
|
||||
if err := s.take(from, count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.put(to, lifted)
|
||||
|
||||
kind := "move"
|
||||
if isFoundation(to) {
|
||||
kind = "home"
|
||||
}
|
||||
evs := []Event{s.event(kind, lifted, from, to)}
|
||||
return s.withFlip(from, evs), nil
|
||||
}
|
||||
|
||||
// home sends the top card of a pile to the foundation that will take it. There
|
||||
// is only ever one, so the player doesn't have to say which.
|
||||
func (s *State) home(from string) ([]Event, error) {
|
||||
top, err := s.peek(from, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to := "f" + strconv.Itoa(int(top[0].Suit))
|
||||
if !s.accepts(to, top) {
|
||||
return nil, ErrWontGo
|
||||
}
|
||||
return s.move(from, to, 1)
|
||||
}
|
||||
|
||||
// auto sends everything that can go home, home, and keeps doing it until nothing
|
||||
// else can. It is the finish button, and it is also the shortcut for the tail of
|
||||
// a board that is already decided.
|
||||
//
|
||||
// It can cost you: a two you needed on the tableau is a two that has gone home.
|
||||
// That is the player's call to make by pressing it, and it is the same call the
|
||||
// button makes in every other solitaire ever written.
|
||||
func (s *State) auto() ([]Event, error) {
|
||||
var evs []Event
|
||||
for {
|
||||
moved := false
|
||||
for _, from := range sources() {
|
||||
top, err := s.peek(from, 1)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
to := "f" + strconv.Itoa(int(top[0].Suit))
|
||||
if !s.accepts(to, top) {
|
||||
continue
|
||||
}
|
||||
one, err := s.move(from, to, 1)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
evs = append(evs, one...)
|
||||
moved = true
|
||||
}
|
||||
if !moved {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(evs) == 0 {
|
||||
return nil, ErrNothingHome
|
||||
}
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
// sources are the piles auto() will lift a card off, in the order it tries them.
|
||||
func sources() []string {
|
||||
out := make([]string, 0, Piles+1)
|
||||
out = append(out, "waste")
|
||||
for i := 0; i < Piles; i++ {
|
||||
out = append(out, "t"+strconv.Itoa(i))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withFlip turns up the card a tableau column was hiding, if taking from it left
|
||||
// its face-down stack exposed. This is the only thing in the game that reveals a
|
||||
// card the player hadn't earned yet, so it is the only place it can happen.
|
||||
func (s *State) withFlip(from string, evs []Event) []Event {
|
||||
i, ok := tableauIndex(from)
|
||||
if !ok {
|
||||
return evs
|
||||
}
|
||||
p := &s.Table[i]
|
||||
if len(p.Up) > 0 || len(p.Down) == 0 {
|
||||
return evs
|
||||
}
|
||||
c := p.Down[len(p.Down)-1]
|
||||
p.Down = p.Down[:len(p.Down)-1]
|
||||
p.Up = append(p.Up, c)
|
||||
return append(evs, s.event("flip", []cards.Card{c}, from, from))
|
||||
}
|
||||
|
||||
// ---- piles -----------------------------------------------------------------
|
||||
|
||||
// peek returns the top `count` cards of a pile without taking them, and refuses
|
||||
// a run that isn't one you could lift: a tableau run has to descend in rank and
|
||||
// alternate colour all the way down, exactly as it does on the felt.
|
||||
func (s *State) peek(name string, count int) ([]cards.Card, error) {
|
||||
switch {
|
||||
case name == "waste":
|
||||
if count != 1 {
|
||||
return nil, ErrNotASequence // the waste is a pile, not a run: one card, the top one
|
||||
}
|
||||
if len(s.Waste) == 0 {
|
||||
return nil, ErrEmptyPile
|
||||
}
|
||||
return []cards.Card{s.Waste[len(s.Waste)-1]}, nil
|
||||
|
||||
case isFoundation(name):
|
||||
i, ok := foundationIndex(name)
|
||||
if !ok {
|
||||
return nil, ErrBadPile
|
||||
}
|
||||
if count != 1 {
|
||||
return nil, ErrNotASequence
|
||||
}
|
||||
f := s.Found[i]
|
||||
if len(f) == 0 {
|
||||
return nil, ErrEmptyPile
|
||||
}
|
||||
return []cards.Card{f[len(f)-1]}, nil
|
||||
|
||||
default:
|
||||
i, ok := tableauIndex(name)
|
||||
if !ok {
|
||||
return nil, ErrBadPile
|
||||
}
|
||||
up := s.Table[i].Up
|
||||
if len(up) == 0 {
|
||||
return nil, ErrEmptyPile
|
||||
}
|
||||
if count > len(up) {
|
||||
return nil, ErrNotASequence
|
||||
}
|
||||
run := up[len(up)-count:]
|
||||
if !isRun(run) {
|
||||
return nil, ErrNotASequence
|
||||
}
|
||||
return append([]cards.Card(nil), run...), nil
|
||||
}
|
||||
}
|
||||
|
||||
// take removes the top `count` cards. peek has already vetted them.
|
||||
func (s *State) take(name string, count int) error {
|
||||
switch {
|
||||
case name == "waste":
|
||||
s.Waste = s.Waste[:len(s.Waste)-count]
|
||||
return nil
|
||||
case isFoundation(name):
|
||||
i, _ := foundationIndex(name)
|
||||
s.Found[i] = s.Found[i][:len(s.Found[i])-count]
|
||||
return nil
|
||||
default:
|
||||
i, ok := tableauIndex(name)
|
||||
if !ok {
|
||||
return ErrBadPile
|
||||
}
|
||||
s.Table[i].Up = s.Table[i].Up[:len(s.Table[i].Up)-count]
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// put drops cards onto a pile. accepts has already vetted them.
|
||||
func (s *State) put(name string, cs []cards.Card) {
|
||||
if isFoundation(name) {
|
||||
i, _ := foundationIndex(name)
|
||||
s.Found[i] = append(s.Found[i], cs...)
|
||||
return
|
||||
}
|
||||
i, _ := tableauIndex(name)
|
||||
s.Table[i].Up = append(s.Table[i].Up, cs...)
|
||||
}
|
||||
|
||||
// accepts is the rule the whole game is made of: what may be put where.
|
||||
//
|
||||
// A foundation takes its own suit in order from the ace, one card at a time. A
|
||||
// tableau column takes a run that descends by one and alternates colour from its
|
||||
// top card, and an empty column takes a King and nothing else.
|
||||
func (s *State) accepts(name string, cs []cards.Card) bool {
|
||||
if len(cs) == 0 {
|
||||
return false
|
||||
}
|
||||
if isFoundation(name) {
|
||||
i, ok := foundationIndex(name)
|
||||
if !ok || len(cs) != 1 {
|
||||
return false
|
||||
}
|
||||
c := cs[0]
|
||||
return int(c.Suit) == i && int(c.Rank) == len(s.Found[i])+1
|
||||
}
|
||||
|
||||
i, ok := tableauIndex(name)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !isRun(cs) {
|
||||
return false
|
||||
}
|
||||
up := s.Table[i].Up
|
||||
if len(up) == 0 {
|
||||
// An empty column is the most valuable thing on the board, so it costs a
|
||||
// King to take one. A column with cards still face-down under it is not
|
||||
// empty, and Up being empty there can't happen: withFlip turns one over.
|
||||
return cs[0].Rank == cards.King && len(s.Table[i].Down) == 0
|
||||
}
|
||||
top := up[len(up)-1]
|
||||
return int(cs[0].Rank) == int(top.Rank)-1 && cs[0].Red() != top.Red()
|
||||
}
|
||||
|
||||
// isRun reports whether these cards, in this order, are a tableau sequence:
|
||||
// descending by one, alternating colour.
|
||||
func isRun(cs []cards.Card) bool {
|
||||
for i := 1; i < len(cs); i++ {
|
||||
if int(cs[i].Rank) != int(cs[i-1].Rank)-1 || cs[i].Red() == cs[i-1].Red() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isFoundation(name string) bool { return strings.HasPrefix(name, "f") }
|
||||
|
||||
func tableauIndex(name string) (int, bool) { return pileIndex(name, "t", Piles) }
|
||||
|
||||
func foundationIndex(name string) (int, bool) { return pileIndex(name, "f", Foundations) }
|
||||
|
||||
func pileIndex(name, prefix string, n int) (int, bool) {
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
return 0, false
|
||||
}
|
||||
i, err := strconv.Atoi(name[len(prefix):])
|
||||
if err != nil || i < 0 || i >= n {
|
||||
return 0, false
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
|
||||
// ---- the money -------------------------------------------------------------
|
||||
|
||||
// Home is how many cards have reached the foundations. It is the only number in
|
||||
// this game that the payout depends on.
|
||||
func (s State) Home() int {
|
||||
n := 0
|
||||
for _, f := range s.Found {
|
||||
n += len(f)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// PerCard is what one card home is worth, before the rake. The felt quotes this
|
||||
// because "2.2×" tells a player nothing about a game where the multiple is paid
|
||||
// out a fifty-second at a time.
|
||||
func (s State) PerCard() float64 {
|
||||
return float64(s.Bet) * s.Tier.Base / float64(FullDeck)
|
||||
}
|
||||
|
||||
// Earned is the gross: what the cards home have bought back, before the house
|
||||
// takes anything. Computed from the total rather than card by card, so 52 cards
|
||||
// home pays the tier's multiple exactly instead of the multiple less 52 roundings.
|
||||
func (s State) Earned() int64 {
|
||||
return int64(math.Floor(float64(s.Bet) * s.Tier.Base * float64(s.Home()) / float64(FullDeck)))
|
||||
}
|
||||
|
||||
// Pays is what stopping *right now* would actually put back on the player's
|
||||
// stack: the gross, less the house's cut of anything above the stake.
|
||||
//
|
||||
// The felt shows this number while the game is still running and settle() lands
|
||||
// on it, and they are the same function for the reason hangman's are: the moment
|
||||
// they are two sums, the table is quoting a payout it doesn't honour.
|
||||
//
|
||||
// Unlike the other games it can be less than the stake, and can be zero. That is
|
||||
// the game — you bought the deck, and a deck that gives you nothing owes you
|
||||
// nothing.
|
||||
func (s State) Pays() int64 {
|
||||
total := s.Earned()
|
||||
profit := total - s.Bet
|
||||
if profit > 0 {
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake > 0 {
|
||||
total -= rake
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// rakeNow is the house's cut if the board were cashed right now — the other half
|
||||
// of what Pays works out.
|
||||
func (s State) rakeNow() int64 {
|
||||
profit := s.Earned() - s.Bet
|
||||
if profit <= 0 {
|
||||
return 0
|
||||
}
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake < 0 {
|
||||
return 0
|
||||
}
|
||||
return rake
|
||||
}
|
||||
|
||||
// Net is what the game did to the player's stack.
|
||||
func (s State) Net() int64 {
|
||||
if s.Phase != PhaseDone {
|
||||
return 0
|
||||
}
|
||||
return s.Payout - s.Bet
|
||||
}
|
||||
|
||||
// cleared reports whether every card is home.
|
||||
func (s State) cleared() bool { return s.Home() == FullDeck }
|
||||
|
||||
// CanAuto reports whether anything can go home at all — which is what greys the
|
||||
// finish button out rather than letting it be pressed at a board that has nothing
|
||||
// for it.
|
||||
func (s State) CanAuto() bool {
|
||||
for _, from := range sources() {
|
||||
top, err := (&s).peek(from, 1)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if (&s).accepts("f"+strconv.Itoa(int(top[0].Suit)), top) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PassesLeft is how many more times the player may go through the stock,
|
||||
// counting the one they are in. -1 means unlimited.
|
||||
func (s State) PassesLeft() int {
|
||||
if s.Tier.Passes <= 0 {
|
||||
return -1
|
||||
}
|
||||
left := s.Tier.Passes - s.Recycles
|
||||
if left < 0 {
|
||||
return 0
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
// settle closes the game at whatever is on the board. Same rule as everywhere
|
||||
// else in the room: the rake comes out of winnings, never out of the stake.
|
||||
func (s *State) settle(o Outcome, evs *[]Event) {
|
||||
s.Outcome = o
|
||||
s.Phase = PhaseDone
|
||||
s.Payout = s.Pays()
|
||||
s.Rake = s.rakeNow()
|
||||
*evs = append(*evs, s.event("settle", nil, "", string(o)))
|
||||
}
|
||||
|
||||
// event stamps an event with the two numbers the felt's meter reads off it, so
|
||||
// the browser never has to work out what the board is worth.
|
||||
func (s State) event(kind string, cs []cards.Card, from, to string) Event {
|
||||
return Event{
|
||||
Kind: kind, Cards: cs, From: from, To: to,
|
||||
Home: s.Home(), Pays: s.Pays(),
|
||||
}
|
||||
}
|
||||
|
||||
// clone deep-copies everything with a backing array, so a derived state shares
|
||||
// none of it with the one it came from and a board can be replayed freely.
|
||||
func (s State) clone() State {
|
||||
s.Stock = append(cards.Deck(nil), s.Stock...)
|
||||
s.Waste = append([]cards.Card(nil), s.Waste...)
|
||||
for i := range s.Table {
|
||||
s.Table[i].Down = append([]cards.Card(nil), s.Table[i].Down...)
|
||||
s.Table[i].Up = append([]cards.Card(nil), s.Table[i].Up...)
|
||||
}
|
||||
for i := range s.Found {
|
||||
s.Found[i] = append([]cards.Card(nil), s.Found[i]...)
|
||||
}
|
||||
return s
|
||||
}
|
||||
730
internal/games/klondike/klondike_test.go
Normal file
730
internal/games/klondike/klondike_test.go
Normal file
@@ -0,0 +1,730 @@
|
||||
package klondike
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
const rake = 0.05
|
||||
|
||||
func vegas() Tier { t, _ := TierBySlug("vegas"); return t }
|
||||
func patient() Tier { t, _ := TierBySlug("patient"); return t }
|
||||
func cut() Tier { t, _ := TierBySlug("cutthroat"); return t }
|
||||
|
||||
func card(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} }
|
||||
|
||||
// ordered builds the 52 cards in a fixed order — the deck deal() would get if
|
||||
// the shuffle were the identity. Tests that care about the board build their own.
|
||||
func ordered() cards.Deck { return cards.NewDeck(1) }
|
||||
|
||||
func mustDeal(t *testing.T, bet int64, tier Tier, d cards.Deck) State {
|
||||
t.Helper()
|
||||
s, evs, err := deal(bet, tier, d, rake)
|
||||
if err != nil {
|
||||
t.Fatalf("deal: %v", err)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "deal" {
|
||||
t.Fatalf("deal events = %+v, want one deal", evs)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func apply(t *testing.T, s State, m Move) (State, []Event) {
|
||||
t.Helper()
|
||||
next, evs, err := ApplyMove(s, m)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyMove(%+v): %v", m, err)
|
||||
}
|
||||
return next, evs
|
||||
}
|
||||
|
||||
func refuses(t *testing.T, s State, m Move, want error) {
|
||||
t.Helper()
|
||||
next, evs, err := ApplyMove(s, m)
|
||||
if err == nil {
|
||||
t.Fatalf("ApplyMove(%+v) was allowed, want %v", m, want)
|
||||
}
|
||||
if want != nil && err != want {
|
||||
t.Fatalf("ApplyMove(%+v) = %v, want %v", m, err, want)
|
||||
}
|
||||
if evs != nil {
|
||||
t.Errorf("an illegal move emitted events: %+v", evs)
|
||||
}
|
||||
// The board an illegal move hands back must be the one it was given. This is
|
||||
// the whole contract of the reducer, and it's cheap to check by value.
|
||||
if !sameBoard(next, s) {
|
||||
t.Errorf("an illegal move changed the board")
|
||||
}
|
||||
}
|
||||
|
||||
func sameBoard(a, b State) bool {
|
||||
x, _ := json.Marshal(a)
|
||||
y, _ := json.Marshal(b)
|
||||
return string(x) == string(y)
|
||||
}
|
||||
|
||||
// ---- the deal --------------------------------------------------------------
|
||||
|
||||
func TestDealLaysOutTheBoard(t *testing.T) {
|
||||
s := mustDeal(t, 520, vegas(), ordered())
|
||||
|
||||
seen := 0
|
||||
for i := 0; i < Piles; i++ {
|
||||
p := s.Table[i]
|
||||
if len(p.Up) != 1 {
|
||||
t.Errorf("column %d has %d face up, want 1", i, len(p.Up))
|
||||
}
|
||||
if len(p.Down) != i {
|
||||
t.Errorf("column %d has %d face down, want %d", i, len(p.Down), i)
|
||||
}
|
||||
seen += len(p.Up) + len(p.Down)
|
||||
}
|
||||
if seen != 28 {
|
||||
t.Errorf("tableau holds %d cards, want 28", seen)
|
||||
}
|
||||
if len(s.Stock) != 24 {
|
||||
t.Errorf("stock is %d, want 24", len(s.Stock))
|
||||
}
|
||||
if s.Home() != 0 || s.Pays() != 0 {
|
||||
t.Errorf("a fresh board is worth %d from %d home, want nothing", s.Pays(), s.Home())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDealRefusesABadStake(t *testing.T) {
|
||||
if _, _, err := deal(0, vegas(), ordered(), rake); err != ErrBadBet {
|
||||
t.Fatalf("deal(0) = %v, want ErrBadBet", err)
|
||||
}
|
||||
if _, _, err := New(-5, vegas(), rake, cards.NewRNG(1, 2)); err != ErrBadBet {
|
||||
t.Fatalf("New(-5) = %v, want ErrBadBet", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the stock -------------------------------------------------------------
|
||||
|
||||
func TestDrawTurnsTheTiersCount(t *testing.T) {
|
||||
for _, tier := range []Tier{patient(), vegas()} {
|
||||
s := mustDeal(t, 100, tier, ordered())
|
||||
next, evs := apply(t, s, Move{Kind: "draw"})
|
||||
if len(next.Waste) != tier.Draw {
|
||||
t.Errorf("%s: waste is %d after one draw, want %d", tier.Slug, len(next.Waste), tier.Draw)
|
||||
}
|
||||
if len(next.Stock) != 24-tier.Draw {
|
||||
t.Errorf("%s: stock is %d, want %d", tier.Slug, len(next.Stock), 24-tier.Draw)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "draw" || len(evs[0].Cards) != tier.Draw {
|
||||
t.Errorf("%s: draw events = %+v", tier.Slug, evs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The last pull off a short stock turns over what's left rather than refusing.
|
||||
func TestDrawTakesWhatIsLeft(t *testing.T) {
|
||||
s := mustDeal(t, 100, vegas(), ordered()) // 24 in the stock, drawing 3
|
||||
for i := 0; i < 7; i++ {
|
||||
s, _ = apply(t, s, Move{Kind: "draw"}) // 21 drawn, 3 left
|
||||
}
|
||||
s, _ = apply(t, s, Move{Kind: "draw"})
|
||||
if len(s.Stock) != 0 || len(s.Waste) != 24 {
|
||||
t.Fatalf("stock %d waste %d, want 0 and 24", len(s.Stock), len(s.Waste))
|
||||
}
|
||||
refuses(t, drained(t, s), Move{Kind: "draw"}, ErrNoDraw)
|
||||
}
|
||||
|
||||
// drained empties the waste too, so there is genuinely nothing to turn over.
|
||||
func drained(t *testing.T, s State) State {
|
||||
t.Helper()
|
||||
s = s.clone()
|
||||
s.Waste = nil
|
||||
s.Stock = nil
|
||||
return s
|
||||
}
|
||||
|
||||
// The waste goes back under the stock in the order it came out — a recycle is a
|
||||
// pile being turned over, not reshuffled. If this ever reshuffled, the seed in
|
||||
// the audit log would stop replaying the game.
|
||||
func TestRecycleTurnsTheWasteOverInOrder(t *testing.T) {
|
||||
s := mustDeal(t, 100, patient(), ordered())
|
||||
want := append(cards.Deck(nil), s.Stock...)
|
||||
|
||||
for i := 0; i < 24; i++ {
|
||||
s, _ = apply(t, s, Move{Kind: "draw"})
|
||||
}
|
||||
next, evs := apply(t, s, Move{Kind: "draw"})
|
||||
if len(evs) != 1 || evs[0].Kind != "recycle" {
|
||||
t.Fatalf("events = %+v, want a recycle", evs)
|
||||
}
|
||||
if len(next.Waste) != 0 {
|
||||
t.Errorf("waste is %d after a recycle, want empty", len(next.Waste))
|
||||
}
|
||||
for i := range want {
|
||||
if next.Stock[i] != want[i] {
|
||||
t.Fatalf("stock[%d] = %v after recycle, want %v — the pile was reshuffled",
|
||||
i, next.Stock[i], want[i])
|
||||
}
|
||||
}
|
||||
if next.Recycles != 1 {
|
||||
t.Errorf("recycles = %d, want 1", next.Recycles)
|
||||
}
|
||||
}
|
||||
|
||||
// Passes is how many times you may go *through* the stock, so it is one more
|
||||
// than the number of times you may turn it back over.
|
||||
func TestPassesRunOut(t *testing.T) {
|
||||
tests := []struct {
|
||||
tier Tier
|
||||
recycles int // how many turn-overs the tier should allow
|
||||
}{
|
||||
{cut(), 0}, // one pass: you never get to turn it back over
|
||||
{vegas(), 2}, // three passes: two turn-overs
|
||||
{patient(), -1}, // unlimited
|
||||
}
|
||||
for _, tc := range tests {
|
||||
s := mustDeal(t, 100, tc.tier, ordered())
|
||||
if got := s.PassesLeft(); tc.recycles < 0 && got != -1 {
|
||||
t.Errorf("%s: PassesLeft = %d, want -1 (unlimited)", tc.tier.Slug, got)
|
||||
}
|
||||
allowed := 0
|
||||
for i := 0; i < 5; i++ {
|
||||
// Empty the stock, then try to turn it over.
|
||||
for len(s.Stock) > 0 {
|
||||
s, _ = apply(t, s, Move{Kind: "draw"})
|
||||
}
|
||||
next, _, err := ApplyMove(s, Move{Kind: "draw"})
|
||||
if err == ErrNoPasses {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.tier.Slug, err)
|
||||
}
|
||||
s = next
|
||||
allowed++
|
||||
}
|
||||
if tc.recycles < 0 {
|
||||
if allowed != 5 {
|
||||
t.Errorf("%s: only %d recycles allowed, want unlimited", tc.tier.Slug, allowed)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if allowed != tc.recycles {
|
||||
t.Errorf("%s: %d recycles allowed, want %d", tc.tier.Slug, allowed, tc.recycles)
|
||||
}
|
||||
if s.PassesLeft() != 1 {
|
||||
t.Errorf("%s: PassesLeft = %d on the last pass, want 1", tc.tier.Slug, s.PassesLeft())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the rules -------------------------------------------------------------
|
||||
|
||||
// board builds a State directly, so a rule can be tested against the position
|
||||
// that exercises it rather than against whatever a shuffle happened to deal.
|
||||
func board(tier Tier, bet int64) State {
|
||||
return State{Tier: tier, Bet: bet, RakePct: rake, Phase: PhasePlaying}
|
||||
}
|
||||
|
||||
func TestTableauTakesDescendingAlternatingColour(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
s.Table[0].Up = []cards.Card{card(8, cards.Spades)} // black 8
|
||||
s.Table[1].Up = []cards.Card{card(7, cards.Hearts)} // red 7 — goes on the 8
|
||||
s.Table[2].Up = []cards.Card{card(7, cards.Clubs)} // black 7 — does not
|
||||
s.Table[3].Up = []cards.Card{card(6, cards.Hearts)} // red 6 — wrong rank for the 8
|
||||
|
||||
next, evs := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
|
||||
if len(next.Table[0].Up) != 2 || next.Table[0].Up[1] != card(7, cards.Hearts) {
|
||||
t.Fatalf("the red seven didn't land on the black eight: %+v", next.Table[0].Up)
|
||||
}
|
||||
if len(next.Table[1].Up) != 0 {
|
||||
t.Errorf("the seven is still in its old column")
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "move" {
|
||||
t.Errorf("events = %+v, want one move", evs)
|
||||
}
|
||||
|
||||
refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo) // same colour
|
||||
refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrWontGo) // two below
|
||||
}
|
||||
|
||||
func TestOnlyAKingTakesAnEmptyColumn(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
// t0 is empty and has nothing under it.
|
||||
s.Table[1].Up = []cards.Card{card(cards.King, cards.Hearts)}
|
||||
s.Table[2].Up = []cards.Card{card(cards.Queen, cards.Spades)}
|
||||
|
||||
refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo)
|
||||
|
||||
next, _ := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
|
||||
if len(next.Table[0].Up) != 1 || next.Table[0].Up[0].Rank != cards.King {
|
||||
t.Fatalf("the king didn't take the empty column: %+v", next.Table[0].Up)
|
||||
}
|
||||
}
|
||||
|
||||
// A run comes off the tableau as a block, and only if it is a run.
|
||||
func TestLiftingARun(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
s.Table[0].Up = []cards.Card{
|
||||
card(9, cards.Hearts), // red
|
||||
card(8, cards.Spades), // black
|
||||
card(7, cards.Diamonds), // red
|
||||
}
|
||||
s.Table[1].Up = []cards.Card{card(10, cards.Clubs)} // black 10 takes the red 9
|
||||
|
||||
next, _ := apply(t, s, Move{Kind: "move", From: "t0", To: "t1", Count: 3})
|
||||
if len(next.Table[1].Up) != 4 || len(next.Table[0].Up) != 0 {
|
||||
t.Fatalf("the run didn't move as a block: t0=%v t1=%v", next.Table[0].Up, next.Table[1].Up)
|
||||
}
|
||||
|
||||
// Not a run: same colour in the middle of it.
|
||||
bad := board(vegas(), 520)
|
||||
bad.Table[0].Up = []cards.Card{
|
||||
card(9, cards.Hearts),
|
||||
card(8, cards.Diamonds), // red on red
|
||||
}
|
||||
bad.Table[1].Up = []cards.Card{card(10, cards.Clubs)}
|
||||
refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 2}, ErrNotASequence)
|
||||
|
||||
// And you can't lift more cards than the column has.
|
||||
refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 9}, ErrNotASequence)
|
||||
}
|
||||
|
||||
// Taking the last face-up card off a column turns the next one over. This is the
|
||||
// only thing in the game that reveals a card, which is the point of the test.
|
||||
func TestTakingTheLastCardFlipsTheNextOne(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
hidden := card(cards.Queen, cards.Clubs)
|
||||
s.Table[0].Down = []cards.Card{card(2, cards.Spades), hidden}
|
||||
s.Table[0].Up = []cards.Card{card(7, cards.Hearts)}
|
||||
s.Table[1].Up = []cards.Card{card(8, cards.Spades)}
|
||||
|
||||
next, evs := apply(t, s, Move{Kind: "move", From: "t0", To: "t1"})
|
||||
if len(next.Table[0].Up) != 1 || next.Table[0].Up[0] != hidden {
|
||||
t.Fatalf("the hidden card didn't turn over: %+v", next.Table[0].Up)
|
||||
}
|
||||
if len(next.Table[0].Down) != 1 {
|
||||
t.Errorf("face-down stack is %d, want 1", len(next.Table[0].Down))
|
||||
}
|
||||
if len(evs) != 2 || evs[1].Kind != "flip" || evs[1].Cards[0] != hidden {
|
||||
t.Fatalf("events = %+v, want a move then a flip carrying the card", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoundationsBuildUpBySuitFromTheAce(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
|
||||
s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
|
||||
s.Table[2].Up = []cards.Card{card(2, cards.Spades)}
|
||||
s.Table[3].Up = []cards.Card{card(3, cards.Hearts)}
|
||||
|
||||
// A two can't start a foundation.
|
||||
refuses(t, s, Move{Kind: "home", From: "t1"}, ErrWontGo)
|
||||
|
||||
s, evs := apply(t, s, Move{Kind: "home", From: "t0"})
|
||||
if len(s.Found[cards.Hearts]) != 1 {
|
||||
t.Fatalf("the ace didn't go home: %+v", s.Found)
|
||||
}
|
||||
if evs[0].Kind != "home" || evs[0].To != "f"+strconv.Itoa(int(cards.Hearts)) {
|
||||
t.Fatalf("event = %+v, want a home to the hearts pile", evs[0])
|
||||
}
|
||||
if evs[0].Home != 1 {
|
||||
t.Errorf("event carries Home=%d, want 1", evs[0].Home)
|
||||
}
|
||||
|
||||
// The three can't jump the two, and the two of spades can't go on hearts.
|
||||
refuses(t, s, Move{Kind: "home", From: "t3"}, ErrWontGo)
|
||||
refuses(t, s, Move{Kind: "move", From: "t2", To: "f" + strconv.Itoa(int(cards.Hearts))}, ErrWontGo)
|
||||
|
||||
s, _ = apply(t, s, Move{Kind: "home", From: "t1"})
|
||||
if s.Home() != 2 {
|
||||
t.Errorf("Home = %d, want 2", s.Home())
|
||||
}
|
||||
}
|
||||
|
||||
// A card can come back off a foundation — a real rule, and one that matters when
|
||||
// you need a low card to move a column. The payout follows it back down, because
|
||||
// the payout reads the board rather than counting events.
|
||||
func TestACardComesBackOffAFoundation(t *testing.T) {
|
||||
s := board(vegas(), 5200)
|
||||
s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
|
||||
s.Table[0].Up = []cards.Card{card(3, cards.Spades)}
|
||||
|
||||
before := s.Pays()
|
||||
next, _ := apply(t, s, Move{Kind: "move", From: "f" + strconv.Itoa(int(cards.Hearts)), To: "t0"})
|
||||
if len(next.Found[cards.Hearts]) != 1 || len(next.Table[0].Up) != 2 {
|
||||
t.Fatalf("the two didn't come back down: found=%v t0=%v", next.Found[cards.Hearts], next.Table[0].Up)
|
||||
}
|
||||
if next.Home() != 1 {
|
||||
t.Errorf("Home = %d after taking a card back, want 1", next.Home())
|
||||
}
|
||||
if next.Pays() >= before {
|
||||
t.Errorf("Pays = %d after taking a card back, want less than %d", next.Pays(), before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWasteGivesUpItsTopCardOnly(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
s.Waste = []cards.Card{card(5, cards.Spades), card(7, cards.Hearts)}
|
||||
s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
|
||||
|
||||
// The 5 is under the 7 and is not available, however much you'd like it.
|
||||
refuses(t, s, Move{Kind: "move", From: "waste", To: "t0", Count: 2}, ErrNotASequence)
|
||||
|
||||
next, _ := apply(t, s, Move{Kind: "move", From: "waste", To: "t0"})
|
||||
if len(next.Waste) != 1 || next.Waste[0] != card(5, cards.Spades) {
|
||||
t.Fatalf("the wrong card left the waste: %+v", next.Waste)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyPilesAndNonsensePiles(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
|
||||
|
||||
refuses(t, s, Move{Kind: "move", From: "waste", To: "t0"}, ErrEmptyPile)
|
||||
refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrEmptyPile)
|
||||
refuses(t, s, Move{Kind: "move", From: "t9", To: "t0"}, ErrBadPile)
|
||||
refuses(t, s, Move{Kind: "move", From: "t0", To: "t9"}, ErrWontGo)
|
||||
refuses(t, s, Move{Kind: "move", From: "banana", To: "t0"}, ErrBadPile)
|
||||
refuses(t, s, Move{Kind: "sing"}, ErrUnknownMove)
|
||||
}
|
||||
|
||||
// ---- auto ------------------------------------------------------------------
|
||||
|
||||
func TestAutoSendsEverythingItCanHome(t *testing.T) {
|
||||
s := board(vegas(), 5200)
|
||||
// Two aces and the hearts two, sitting on top of three columns.
|
||||
s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
|
||||
s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
|
||||
s.Table[2].Up = []cards.Card{card(cards.Ace, cards.Spades)}
|
||||
s.Table[3].Up = []cards.Card{card(9, cards.Clubs)} // goes nowhere
|
||||
|
||||
next, evs := apply(t, s, Move{Kind: "auto"})
|
||||
if next.Home() != 3 {
|
||||
t.Fatalf("Home = %d after auto, want 3 (two aces and the two)", next.Home())
|
||||
}
|
||||
if len(next.Table[3].Up) != 1 {
|
||||
t.Errorf("the nine went somewhere it couldn't go")
|
||||
}
|
||||
homes := 0
|
||||
for _, e := range evs {
|
||||
if e.Kind == "home" {
|
||||
homes++
|
||||
}
|
||||
}
|
||||
if homes != 3 {
|
||||
t.Errorf("auto emitted %d home events, want 3 — the table has to animate each one", homes)
|
||||
}
|
||||
|
||||
// Nothing left to do: the button says so rather than doing nothing quietly.
|
||||
if next.CanAuto() {
|
||||
t.Errorf("CanAuto is true with only a nine on the board")
|
||||
}
|
||||
refuses(t, next, Move{Kind: "auto"}, ErrNothingHome)
|
||||
}
|
||||
|
||||
// ---- the money -------------------------------------------------------------
|
||||
|
||||
// The number the felt quotes while you play and the number settle() lands on are
|
||||
// the same function. Hangman had these as two sums once and the table advertised
|
||||
// a payout the house didn't honour; this asserts they can't drift here.
|
||||
func TestTheQuoteIsThePayout(t *testing.T) {
|
||||
s := board(vegas(), 1000)
|
||||
for home := 0; home <= FullDeck; home++ {
|
||||
s.Found = [Foundations][]cards.Card{}
|
||||
left := home
|
||||
for suit := 0; suit < Foundations && left > 0; suit++ {
|
||||
n := left
|
||||
if n > 13 {
|
||||
n = 13
|
||||
}
|
||||
for r := 1; r <= n; r++ {
|
||||
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||
}
|
||||
left -= n
|
||||
}
|
||||
if s.Home() != home {
|
||||
t.Fatalf("built a board with %d home, wanted %d", s.Home(), home)
|
||||
}
|
||||
|
||||
quoted := s.Pays()
|
||||
var evs []Event
|
||||
done := s.clone()
|
||||
done.settle(OutcomeCashed, &evs)
|
||||
if done.Payout != quoted {
|
||||
t.Fatalf("%d home: the felt quoted %d and settle paid %d", home, quoted, done.Payout)
|
||||
}
|
||||
if done.Payout+done.Rake != done.Earned() {
|
||||
t.Fatalf("%d home: payout %d + rake %d != earned %d",
|
||||
home, done.Payout, done.Rake, done.Earned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFullBoardPaysTheTiersMultiple(t *testing.T) {
|
||||
for _, tier := range Tiers {
|
||||
s := board(tier, 1000)
|
||||
for suit := 0; suit < Foundations; suit++ {
|
||||
for r := 1; r <= 13; r++ {
|
||||
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||
}
|
||||
}
|
||||
// Gross is the multiple exactly — computed from the total, not summed 52
|
||||
// times, so it doesn't bleed a rounding per card.
|
||||
want := int64(float64(s.Bet) * tier.Base)
|
||||
if s.Earned() != want {
|
||||
t.Errorf("%s: a cleared board earns %d, want %d", tier.Slug, s.Earned(), want)
|
||||
}
|
||||
// And the rake comes out of the winnings, never the stake.
|
||||
profit := want - s.Bet
|
||||
if s.Pays() != want-int64(float64(profit)*rake) {
|
||||
t.Errorf("%s: pays %d, want %d less %v%% of the profit", tier.Slug, s.Pays(), want, rake*100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty board owes nothing, and is not charged a fee for owing nothing.
|
||||
func TestNothingHomePaysNothing(t *testing.T) {
|
||||
s := board(cut(), 500)
|
||||
if s.Pays() != 0 || s.rakeNow() != 0 {
|
||||
t.Fatalf("an empty board pays %d and rakes %d, want nothing either way", s.Pays(), s.rakeNow())
|
||||
}
|
||||
var evs []Event
|
||||
s.settle(OutcomeCashed, &evs)
|
||||
if s.Payout != 0 || s.Net() != -500 {
|
||||
t.Errorf("payout %d net %d, want 0 and -500", s.Payout, s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
// Below break-even the player is down but is not raked: there is no profit to
|
||||
// take a cut of.
|
||||
func TestNoRakeBelowTheStake(t *testing.T) {
|
||||
tier := vegas()
|
||||
s := board(tier, 5200)
|
||||
for i := 0; i < tier.BreakEven()-1; i++ {
|
||||
suit, r := i/13, i%13+1
|
||||
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||
}
|
||||
if s.Earned() > s.Bet {
|
||||
t.Fatalf("break-even is meant to be the first card that gets you square, but %d earns %d on a %d stake",
|
||||
s.Home(), s.Earned(), s.Bet)
|
||||
}
|
||||
if s.rakeNow() != 0 {
|
||||
t.Errorf("raked %d off a losing board", s.rakeNow())
|
||||
}
|
||||
if s.Pays() != s.Earned() {
|
||||
t.Errorf("pays %d, want the full %d — nothing to rake", s.Pays(), s.Earned())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakEvenIsTheCardThatGetsYouSquare(t *testing.T) {
|
||||
for _, tier := range Tiers {
|
||||
s := board(tier, 5200)
|
||||
for i := 0; i < tier.BreakEven(); i++ {
|
||||
suit, r := i/13, i%13+1
|
||||
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||
}
|
||||
if s.Earned() < s.Bet {
|
||||
t.Errorf("%s: %d cards home earns %d on a %d stake — break-even is quoted too low",
|
||||
tier.Slug, s.Home(), s.Earned(), s.Bet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- settling --------------------------------------------------------------
|
||||
|
||||
func TestConcedeCashesTheBoard(t *testing.T) {
|
||||
s := board(vegas(), 5200)
|
||||
s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
|
||||
want := s.Pays()
|
||||
|
||||
next, evs := apply(t, s, Move{Kind: "concede"})
|
||||
if next.Phase != PhaseDone || next.Outcome != OutcomeCashed {
|
||||
t.Fatalf("phase %q outcome %q, want done/cashed", next.Phase, next.Outcome)
|
||||
}
|
||||
if next.Payout != want {
|
||||
t.Errorf("cashed for %d, want the %d the board was quoting", next.Payout, want)
|
||||
}
|
||||
if evs[len(evs)-1].Kind != "settle" {
|
||||
t.Errorf("no settle event: %+v", evs)
|
||||
}
|
||||
refuses(t, next, Move{Kind: "draw"}, ErrGameOver)
|
||||
}
|
||||
|
||||
// The last card home ends the game on its own — the player doesn't have to tell
|
||||
// the table they've won.
|
||||
func TestTheLastCardHomeClearsTheBoard(t *testing.T) {
|
||||
s := board(vegas(), 1000)
|
||||
for suit := 0; suit < Foundations; suit++ {
|
||||
top := 13
|
||||
if suit == int(cards.Clubs) {
|
||||
top = 12 // the king of clubs is the one card still out
|
||||
}
|
||||
for r := 1; r <= top; r++ {
|
||||
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||
}
|
||||
}
|
||||
s.Table[0].Up = []cards.Card{card(cards.King, cards.Clubs)}
|
||||
|
||||
next, evs := apply(t, s, Move{Kind: "home", From: "t0"})
|
||||
if next.Phase != PhaseDone || next.Outcome != OutcomeCleared {
|
||||
t.Fatalf("phase %q outcome %q, want done/cleared", next.Phase, next.Outcome)
|
||||
}
|
||||
if next.Payout != int64(float64(1000)*vegas().Base)-int64(float64(int64(float64(1000)*vegas().Base)-1000)*rake) {
|
||||
t.Errorf("a cleared board paid %d", next.Payout)
|
||||
}
|
||||
if evs[len(evs)-1].Kind != "settle" {
|
||||
t.Errorf("the winning card didn't settle the game: %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the shape of the thing ------------------------------------------------
|
||||
|
||||
// A game survives a redeploy: the whole state, shoe and face-down cards and all,
|
||||
// goes through JSON and comes back the same board.
|
||||
func TestAGameSurvivesJSON(t *testing.T) {
|
||||
s, _, err := New(500, cut(), rake, cards.NewRNG(7, 11))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 6; i++ {
|
||||
s, _, _ = ApplyMove(s, Move{Kind: "draw"})
|
||||
}
|
||||
blob, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back State
|
||||
if err := json.Unmarshal(blob, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameBoard(s, back) {
|
||||
t.Fatal("the board didn't come back the same")
|
||||
}
|
||||
}
|
||||
|
||||
// The same seed deals the same board. This is what lets a disputed game be dealt
|
||||
// again exactly as it fell, and it is why the RNG is threaded rather than global.
|
||||
func TestASeedDealsTheSameBoard(t *testing.T) {
|
||||
a, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameBoard(a, b) {
|
||||
t.Fatal("the same seed dealt two different boards")
|
||||
}
|
||||
|
||||
c, _, _ := New(100, vegas(), rake, cards.NewRNG(43, 99))
|
||||
if sameBoard(a, c) {
|
||||
t.Fatal("two seeds dealt the same board")
|
||||
}
|
||||
}
|
||||
|
||||
// Every card is on the board exactly once, whatever you do to it. A move that
|
||||
// duplicated a card would be a move that printed money.
|
||||
func TestNoCardIsEverLostOrDuplicated(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(3, 5))
|
||||
s, _, err := New(1000, patient(), rake, rng)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
countDeck(t, s, "the deal")
|
||||
|
||||
// Play a long random game: whatever the fuzzer stumbles into, the deck holds.
|
||||
for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
|
||||
m := randomMove(rng)
|
||||
next, _, err := ApplyMove(s, m)
|
||||
if err != nil {
|
||||
continue // an illegal move is a fine thing for a fuzzer to find
|
||||
}
|
||||
s = next
|
||||
countDeck(t, s, "after "+m.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func randomMove(rng *rand.Rand) Move {
|
||||
pile := func() string {
|
||||
switch rng.IntN(3) {
|
||||
case 0:
|
||||
return "waste"
|
||||
case 1:
|
||||
return "t" + strconv.Itoa(rng.IntN(Piles))
|
||||
default:
|
||||
return "f" + strconv.Itoa(rng.IntN(Foundations))
|
||||
}
|
||||
}
|
||||
switch rng.IntN(10) {
|
||||
case 0, 1, 2, 3:
|
||||
return Move{Kind: "draw"}
|
||||
case 4:
|
||||
return Move{Kind: "home", From: pile()}
|
||||
case 5:
|
||||
return Move{Kind: "auto"}
|
||||
default:
|
||||
return Move{Kind: "move", From: pile(), To: pile(), Count: 1 + rng.IntN(4)}
|
||||
}
|
||||
}
|
||||
|
||||
func countDeck(t *testing.T, s State, when string) {
|
||||
t.Helper()
|
||||
seen := map[cards.Card]int{}
|
||||
add := func(cs []cards.Card) {
|
||||
for _, c := range cs {
|
||||
seen[c]++
|
||||
}
|
||||
}
|
||||
add(s.Stock)
|
||||
add(s.Waste)
|
||||
for _, p := range s.Table {
|
||||
add(p.Down)
|
||||
add(p.Up)
|
||||
}
|
||||
for _, f := range s.Found {
|
||||
add(f)
|
||||
}
|
||||
if len(seen) != FullDeck {
|
||||
t.Fatalf("%s: %d distinct cards on the board, want 52", when, len(seen))
|
||||
}
|
||||
for c, n := range seen {
|
||||
if n != 1 {
|
||||
t.Fatalf("%s: %v appears %d times", when, c, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The face-up run in every tableau column is always a legal run, and a column
|
||||
// with cards face-up never has an unturned card left under it. Both are things
|
||||
// the *rules* keep true, so a fuzzer that breaks them has found a real bug.
|
||||
func TestTheBoardStaysWellFormed(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(11, 13))
|
||||
s, _, err := New(1000, vegas(), rake, rng)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
|
||||
next, _, err := ApplyMove(s, randomMove(rng))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s = next
|
||||
for j, p := range s.Table {
|
||||
if !isRun(p.Up) {
|
||||
t.Fatalf("column %d holds a run that isn't one: %v", j, p.Up)
|
||||
}
|
||||
if len(p.Up) == 0 && len(p.Down) > 0 {
|
||||
t.Fatalf("column %d has %d cards face down and nothing turned over", j, len(p.Down))
|
||||
}
|
||||
}
|
||||
for suit, f := range s.Found {
|
||||
for r, c := range f {
|
||||
if int(c.Suit) != suit || int(c.Rank) != r+1 {
|
||||
t.Fatalf("foundation %d holds %v at position %d", suit, c, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
375
internal/games/trivia/trivia.go
Normal file
375
internal/games/trivia/trivia.go
Normal file
@@ -0,0 +1,375 @@
|
||||
// Package trivia is a pure trivia-ladder engine, played for chips.
|
||||
//
|
||||
// Same seam as blackjack and hangman: ApplyMove(state, move, now) (state,
|
||||
// events, error), where an error means the move was illegal and nothing else.
|
||||
// The one difference is that clock: trivia is the only game in the room where
|
||||
// *when* you move changes what it pays, and a pure reducer cannot own a timer.
|
||||
// So the time is an argument. The engine stays a value in, value out, and the
|
||||
// only thing that knows what o'clock it is remains the caller.
|
||||
//
|
||||
// The shape is a ladder. You stake once, and then answer a run of questions:
|
||||
// every right answer multiplies what the stake is worth, a wrong one loses the
|
||||
// lot, and you may walk with what you've built at any point after the first.
|
||||
// It is the oldest quiz-show bet there is — the tension is entirely in whether
|
||||
// you take the money.
|
||||
//
|
||||
// The reason for the clock is less pretty: trivia answers are googlable, and a
|
||||
// game that paid the same for a slow right answer as a fast one would be a game
|
||||
// about typing into another tab. So the multiple a question is worth decays
|
||||
// from Fast to Buzzer across the tier's time limit, and running out of time
|
||||
// loses exactly as much as being wrong. The countdown in the browser is
|
||||
// decoration; this is the clock that counts.
|
||||
package trivia
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Errors an illegal move can produce.
|
||||
var (
|
||||
ErrGameOver = errors.New("trivia: the game is already over")
|
||||
ErrUnknownMove = errors.New("trivia: unknown move")
|
||||
ErrBadBet = errors.New("trivia: bet must be positive")
|
||||
ErrUnknownTier = errors.New("trivia: no such tier")
|
||||
ErrShortLadder = errors.New("trivia: not enough questions to build a ladder")
|
||||
ErrNothingBanked = errors.New("trivia: answer one before you walk")
|
||||
)
|
||||
|
||||
// Rungs is how long the ladder is. Clearing it is a win in itself: the run ends
|
||||
// and banks, because a ladder with no top is just a slot machine you can't stop
|
||||
// playing, and eventually every player loses everything to one bad question.
|
||||
const Rungs = 12
|
||||
|
||||
// Tier is a difficulty, chosen before the bet. It sets three things that move
|
||||
// together: how hard the questions are, how long you get, and what a right
|
||||
// answer is worth. Hard questions pay more and give you less time to look them
|
||||
// up, which is the whole bargain.
|
||||
type Tier struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Difficulty string `json:"difficulty"` // what OpenTDB calls it: easy | medium | hard
|
||||
Fast float64 `json:"fast"` // what a right answer multiplies by, answered instantly
|
||||
Buzzer float64 `json:"buzzer"` // ...and what it's worth answered on the last tick
|
||||
Limit int `json:"limit"` // seconds on the clock, per question
|
||||
Blurb string `json:"blurb"`
|
||||
}
|
||||
|
||||
// Tiers are the three tables.
|
||||
var Tiers = []Tier{
|
||||
{Slug: "easy", Name: "Easy", Difficulty: "easy", Fast: 1.30, Buzzer: 1.10, Limit: 20,
|
||||
Blurb: "Things you know. The clock is the only thing in your way."},
|
||||
{Slug: "medium", Name: "Medium", Difficulty: "medium", Fast: 1.55, Buzzer: 1.20, Limit: 18,
|
||||
Blurb: "Things you nearly know."},
|
||||
{Slug: "hard", Name: "Hard", Difficulty: "hard", Fast: 1.90, Buzzer: 1.30, Limit: 15,
|
||||
Blurb: "Things you don't. Fifteen seconds is not enough to find out."},
|
||||
}
|
||||
|
||||
// TierBySlug finds a tier by the name the browser sent.
|
||||
func TierBySlug(slug string) (Tier, error) {
|
||||
for _, t := range Tiers {
|
||||
if t.Slug == slug {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return Tier{}, ErrUnknownTier
|
||||
}
|
||||
|
||||
// Step is what a right answer multiplies the running total by, given how long
|
||||
// it took. Fast at nought seconds, Buzzer at the limit, straight line between.
|
||||
//
|
||||
// Answering at the buzzer still pays *something* — the decay is a reason to be
|
||||
// quick, not a punishment for thinking. The punishment for thinking too long is
|
||||
// the timeout, and that one takes everything.
|
||||
func (t Tier) Step(elapsed time.Duration) float64 {
|
||||
limit := t.Clock()
|
||||
switch {
|
||||
case elapsed <= 0:
|
||||
return t.Fast
|
||||
case elapsed >= limit:
|
||||
return t.Buzzer
|
||||
}
|
||||
speed := 1 - float64(elapsed)/float64(limit) // 1 answering instantly, 0 at the buzzer
|
||||
return t.Buzzer + (t.Fast-t.Buzzer)*speed
|
||||
}
|
||||
|
||||
// Clock is the tier's time limit as a duration.
|
||||
func (t Tier) Clock() time.Duration { return time.Duration(t.Limit) * time.Second }
|
||||
|
||||
// Question is one rung. It carries its own correct index, which is exactly why
|
||||
// a State never crosses the wire — the browser is sent the answers and not
|
||||
// which of them is right.
|
||||
type Question struct {
|
||||
Category string `json:"category"`
|
||||
Text string `json:"text"`
|
||||
Answers []string `json:"answers"` // already shuffled: the right one is not always first
|
||||
Correct int `json:"correct"` // index into Answers
|
||||
}
|
||||
|
||||
// Phase is where the game is.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlaying Phase = "playing"
|
||||
PhaseDone Phase = "done"
|
||||
)
|
||||
|
||||
// Outcome is how it ended.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeNone Outcome = ""
|
||||
OutcomeWalked Outcome = "walked" // took the money
|
||||
OutcomeCleared Outcome = "cleared" // answered all twelve
|
||||
OutcomeWrong Outcome = "wrong" // picked the wrong one
|
||||
OutcomeTimeout Outcome = "timeout" // ran out of clock
|
||||
)
|
||||
|
||||
// Won reports whether this outcome pays.
|
||||
func (o Outcome) Won() bool { return o == OutcomeWalked || o == OutcomeCleared }
|
||||
|
||||
// State is one game. The ladder — every question, and every right answer — is
|
||||
// in here, which is why this value stays on the server. The browser gets a view
|
||||
// of the current rung and nothing about the ones ahead of it.
|
||||
type State struct {
|
||||
Tier Tier `json:"tier"`
|
||||
Ladder []Question `json:"ladder"` // the whole run, drawn up front
|
||||
Rung int `json:"rung"` // how many answered right; also the index of the live question
|
||||
|
||||
// AskedAt is when the current question was *put to the player*, by the
|
||||
// server's clock. It is the only clock in the game. A reload does not reset
|
||||
// it: you cannot stop time by refreshing.
|
||||
AskedAt time.Time `json:"asked_at"`
|
||||
|
||||
Multiple float64 `json:"multiple"` // 1.0 at the start; the product of every step earned
|
||||
RakePct float64 `json:"rake_pct"`
|
||||
|
||||
Bet int64 `json:"bet"`
|
||||
Phase Phase `json:"phase"`
|
||||
Outcome Outcome `json:"outcome"`
|
||||
Payout int64 `json:"payout"`
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Event is something the table animates.
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // "ask" | "right" | "wrong" | "timeout" | "settle"
|
||||
Choice int `json:"choice"` // what the player picked (-1 when they didn't)
|
||||
Correct int `json:"correct"` // which one was right — sent only once it's decided
|
||||
Step float64 `json:"step,omitempty"` // what this answer multiplied by
|
||||
Multiple float64 `json:"multiple,omitempty"` // the running total, after
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// Move is a player action: pick an answer, or take the money.
|
||||
type Move struct {
|
||||
Choice int `json:"choice"` // index into the live question's answers
|
||||
Walk bool `json:"walk"`
|
||||
}
|
||||
|
||||
// New starts a game on a ladder of questions the caller has already drawn. The
|
||||
// engine does not reach for a database any more than blackjack reaches for a
|
||||
// deck: the questions arrive as a value, so a game is reproducible and a test
|
||||
// can pin every rung.
|
||||
//
|
||||
// The answers are shuffled here, with the caller's seeded rng, because a bank
|
||||
// that always stores the right answer first would otherwise be a game about
|
||||
// clicking first.
|
||||
func New(bet int64, t Tier, rakePct float64, qs []Question, now time.Time, rng *rand.Rand) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
if len(qs) < Rungs {
|
||||
return State{}, nil, ErrShortLadder
|
||||
}
|
||||
ladder := make([]Question, Rungs)
|
||||
for i := range ladder {
|
||||
ladder[i] = shuffleAnswers(qs[i], rng)
|
||||
}
|
||||
s := State{
|
||||
Tier: t, Ladder: ladder, RakePct: rakePct,
|
||||
Multiple: 1,
|
||||
AskedAt: now,
|
||||
Bet: bet, Phase: PhasePlaying,
|
||||
}
|
||||
return s, []Event{{Kind: "ask", Choice: -1, Correct: -1}}, nil
|
||||
}
|
||||
|
||||
// shuffleAnswers moves the right answer somewhere the player can't guess from
|
||||
// position, and keeps track of where it went.
|
||||
func shuffleAnswers(q Question, rng *rand.Rand) Question {
|
||||
answers := append([]string(nil), q.Answers...)
|
||||
correct := q.Answers[q.Correct]
|
||||
rng.Shuffle(len(answers), func(i, j int) { answers[i], answers[j] = answers[j], answers[i] })
|
||||
out := q
|
||||
out.Answers = answers
|
||||
for i, a := range answers {
|
||||
if a == correct {
|
||||
out.Correct = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Live is the question the player is looking at.
|
||||
func (s State) Live() Question {
|
||||
if s.Rung < 0 || s.Rung >= len(s.Ladder) {
|
||||
return Question{}
|
||||
}
|
||||
return s.Ladder[s.Rung]
|
||||
}
|
||||
|
||||
// Left is how much clock the live question has, at the given moment. It goes to
|
||||
// the browser so its countdown starts where the server's does — but the browser
|
||||
// is never asked what it says.
|
||||
func (s State) Left(now time.Time) time.Duration {
|
||||
d := s.Tier.Clock() - now.Sub(s.AskedAt)
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ApplyMove is the engine. now is the server's clock, and the only one that
|
||||
// counts toward the answer.
|
||||
func ApplyMove(s State, m Move, now time.Time) (State, []Event, error) {
|
||||
if s.Phase == PhaseDone {
|
||||
return s, nil, ErrGameOver
|
||||
}
|
||||
s = s.clone()
|
||||
|
||||
q := s.Live()
|
||||
if len(q.Answers) == 0 {
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
|
||||
elapsed := now.Sub(s.AskedAt)
|
||||
|
||||
// Out of time. This is a loss, and it has to be — a timeout that merely cost
|
||||
// you the speed bonus would make "leave it open in another tab and go and
|
||||
// look it up" the strongest way to play.
|
||||
//
|
||||
// It is checked before *everything*, walking included. A dead clock that you
|
||||
// could still walk away from would be no clock at all: you would sit on every
|
||||
// question for as long as you liked, answer the ones you found and walk off
|
||||
// the ones you didn't, and never once lose the ladder. The timeout has to be
|
||||
// the first thing that happens to a move, or it is not a deadline.
|
||||
if elapsed > s.Tier.Clock() {
|
||||
evs := []Event{{Kind: "timeout", Choice: -1, Correct: q.Correct}}
|
||||
s.settle(OutcomeTimeout, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
if m.Walk {
|
||||
// You cannot walk off a rung you haven't climbed. If you could, seeing the
|
||||
// first question and walking away would be a free look: stake, peek, walk,
|
||||
// stake again, and keep reshuffling until the question is one you know.
|
||||
// The first question is therefore the price of sitting down.
|
||||
if s.Rung == 0 {
|
||||
return s, nil, ErrNothingBanked
|
||||
}
|
||||
evs := []Event{}
|
||||
s.settle(OutcomeWalked, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
if m.Choice < 0 || m.Choice >= len(q.Answers) {
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
|
||||
if m.Choice != q.Correct {
|
||||
evs := []Event{{Kind: "wrong", Choice: m.Choice, Correct: q.Correct}}
|
||||
s.settle(OutcomeWrong, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// Right, and quick enough to be worth something.
|
||||
step := s.Tier.Step(elapsed)
|
||||
s.Multiple *= step
|
||||
s.Rung++
|
||||
evs := []Event{{
|
||||
Kind: "right", Choice: m.Choice, Correct: q.Correct,
|
||||
Step: step, Multiple: s.Multiple,
|
||||
}}
|
||||
|
||||
if s.Rung >= Rungs {
|
||||
s.settle(OutcomeCleared, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// The next question goes up, and its clock starts now.
|
||||
s.AskedAt = now
|
||||
evs = append(evs, Event{Kind: "ask", Choice: -1, Correct: -1})
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// Pays is what banking *right now* would put back on the player's stack: the
|
||||
// stake, plus the winnings, less the house's cut of the winnings.
|
||||
//
|
||||
// It exists for the same reason hangman's does. The felt quotes this number
|
||||
// while the game is still running — it is the "take the money" button's label —
|
||||
// and settle() calls it rather than doing the sum a second time, so the table
|
||||
// can never advertise a payout the house doesn't hand over.
|
||||
func (s State) Pays() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Multiple))
|
||||
if total < s.Bet {
|
||||
total = s.Bet // banking never hands back less than the stake
|
||||
}
|
||||
profit := total - s.Bet
|
||||
if profit > 0 {
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake > 0 {
|
||||
profit -= rake
|
||||
}
|
||||
}
|
||||
return s.Bet + profit
|
||||
}
|
||||
|
||||
// rakeNow is the other half of what Pays works out: the house's cut of a win
|
||||
// banked at this moment. Never taken from the stake, so a player who walks
|
||||
// having answered nothing — which they can't — and one who loses, pay nothing.
|
||||
func (s State) rakeNow() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Multiple))
|
||||
if total <= s.Bet {
|
||||
return 0
|
||||
}
|
||||
rake := int64(math.Floor(float64(total-s.Bet) * s.RakePct))
|
||||
if rake < 0 {
|
||||
return 0
|
||||
}
|
||||
return rake
|
||||
}
|
||||
|
||||
// settle decides the payout. Same rule as every other table in the room: the
|
||||
// rake comes out of winnings, never out of the stake, and a loss is never
|
||||
// charged a fee.
|
||||
func (s *State) settle(o Outcome, evs *[]Event) {
|
||||
s.Outcome = o
|
||||
s.Phase = PhaseDone
|
||||
|
||||
if o.Won() {
|
||||
s.Payout = s.Pays()
|
||||
s.Rake = s.rakeNow()
|
||||
} else {
|
||||
s.Payout = 0
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "settle", Choice: -1, Correct: -1, Text: string(o)})
|
||||
}
|
||||
|
||||
// Net is what the game did to the player's stack.
|
||||
func (s State) Net() int64 {
|
||||
if s.Phase != PhaseDone {
|
||||
return 0
|
||||
}
|
||||
return s.Payout - s.Bet
|
||||
}
|
||||
|
||||
// clone deep-copies the ladder, so a derived state shares no backing array with
|
||||
// the one it came from and a game can be replayed freely.
|
||||
func (s State) clone() State {
|
||||
s.Ladder = append([]Question(nil), s.Ladder...)
|
||||
return s
|
||||
}
|
||||
352
internal/games/trivia/trivia_test.go
Normal file
352
internal/games/trivia/trivia_test.go
Normal file
@@ -0,0 +1,352 @@
|
||||
package trivia
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func rng() *rand.Rand { return rand.New(rand.NewPCG(1, 2)) }
|
||||
|
||||
var epoch = time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// bank builds n questions whose right answer is always "right", so a test can
|
||||
// find it after the shuffle without caring where it landed.
|
||||
func bank(n int) []Question {
|
||||
qs := make([]Question, n)
|
||||
for i := range qs {
|
||||
qs[i] = Question{
|
||||
Category: "General",
|
||||
Text: "question?",
|
||||
Answers: []string{"right", "wrong1", "wrong2", "wrong3"},
|
||||
Correct: 0,
|
||||
}
|
||||
}
|
||||
return qs
|
||||
}
|
||||
|
||||
func tier(slug string) Tier {
|
||||
t, err := TierBySlug(slug)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func newGame(t *testing.T, bet int64, slug string) State {
|
||||
t.Helper()
|
||||
s, evs, err := New(bet, tier(slug), 0.05, bank(Rungs), epoch, rng())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "ask" {
|
||||
t.Fatalf("New should open with one ask, got %+v", evs)
|
||||
}
|
||||
if s.Multiple != 1 {
|
||||
t.Fatalf("a fresh ladder is worth the stake, got multiple %v", s.Multiple)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// answerRight plays the live question correctly, after `took` on the clock.
|
||||
func answerRight(t *testing.T, s State, took time.Duration) (State, []Event) {
|
||||
t.Helper()
|
||||
q := s.Live()
|
||||
next, evs, err := ApplyMove(s, Move{Choice: q.Correct}, s.AskedAt.Add(took))
|
||||
if err != nil {
|
||||
t.Fatalf("right answer refused: %v", err)
|
||||
}
|
||||
return next, evs
|
||||
}
|
||||
|
||||
func TestNewShufflesButKeepsTheAnswer(t *testing.T) {
|
||||
s := newGame(t, 100, "medium")
|
||||
moved := 0
|
||||
for _, q := range s.Ladder {
|
||||
if q.Answers[q.Correct] != "right" {
|
||||
t.Fatalf("Correct points at %q, not the right answer", q.Answers[q.Correct])
|
||||
}
|
||||
if q.Correct != 0 {
|
||||
moved++
|
||||
}
|
||||
}
|
||||
// All twelve landing on index 0 would mean the shuffle isn't running, and the
|
||||
// game would be "always click the first one".
|
||||
if moved == 0 {
|
||||
t.Fatal("the right answer is first in every question — the shuffle did nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortBankIsRefused(t *testing.T) {
|
||||
if _, _, err := New(100, tier("easy"), 0.05, bank(Rungs-1), epoch, rng()); err != ErrShortLadder {
|
||||
t.Fatalf("a ladder with a missing rung should be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The one that matters most: the number the felt quotes is the number the
|
||||
// player is actually paid, at every rung, exactly as in hangman.
|
||||
func TestTheQuoteIsThePayout(t *testing.T) {
|
||||
s := newGame(t, 200, "hard")
|
||||
for rung := 1; rung < Rungs; rung++ {
|
||||
s, _ = answerRight(t, s, 3*time.Second)
|
||||
|
||||
quoted := s.Pays() // what the "take the money" button says it's worth
|
||||
|
||||
banked, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("rung %d: walk refused: %v", rung, err)
|
||||
}
|
||||
if banked.Payout != quoted {
|
||||
t.Fatalf("rung %d: the felt quoted %d and the house paid %d", rung, quoted, banked.Payout)
|
||||
}
|
||||
if banked.Phase != PhaseDone || banked.Outcome != OutcomeWalked {
|
||||
t.Fatalf("rung %d: walking should end the game, got %s/%s", rung, banked.Phase, banked.Outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Walking before answering anything would be a free look at the first question:
|
||||
// stake, peek, walk, restake until the question is one you happen to know.
|
||||
func TestYouCannotWalkOffTheFirstRung(t *testing.T) {
|
||||
s := newGame(t, 100, "easy")
|
||||
if _, _, err := ApplyMove(s, Move{Walk: true}, epoch); err != ErrNothingBanked {
|
||||
t.Fatalf("walking on rung 0 should be refused, got %v", err)
|
||||
}
|
||||
|
||||
// One right answer, and now you may.
|
||||
s, _ = answerRight(t, s, time.Second)
|
||||
if _, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt); err != nil {
|
||||
t.Fatalf("walking after a right answer should be allowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWrongAnswerLosesTheLot(t *testing.T) {
|
||||
s := newGame(t, 300, "medium")
|
||||
// Build a decent ladder first, so there is something real to lose.
|
||||
for i := 0; i < 4; i++ {
|
||||
s, _ = answerRight(t, s, time.Second)
|
||||
}
|
||||
if s.Pays() <= 300 {
|
||||
t.Fatalf("four right answers should be worth more than the stake, got %d", s.Pays())
|
||||
}
|
||||
|
||||
q := s.Live()
|
||||
wrong := (q.Correct + 1) % len(q.Answers)
|
||||
out, evs, err := ApplyMove(s, Move{Choice: wrong}, s.AskedAt.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("a wrong answer is a legal move: %v", err)
|
||||
}
|
||||
if out.Outcome != OutcomeWrong || out.Payout != 0 {
|
||||
t.Fatalf("a wrong answer should pay nothing, got %s/%d", out.Outcome, out.Payout)
|
||||
}
|
||||
if out.Rake != 0 {
|
||||
t.Fatalf("a loss must never be charged a rake, got %d", out.Rake)
|
||||
}
|
||||
if out.Net() != -300 {
|
||||
t.Fatalf("a wrong answer costs the stake and nothing more, got %d", out.Net())
|
||||
}
|
||||
// The player is told which one it was.
|
||||
if evs[0].Kind != "wrong" || evs[0].Correct != q.Correct {
|
||||
t.Fatalf("a wrong answer should reveal the right one, got %+v", evs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// The clock is the whole anti-google mechanism: running out of it has to cost
|
||||
// as much as being wrong, or leaving the tab open and looking it up wins.
|
||||
func TestTheClockTakesEverything(t *testing.T) {
|
||||
s := newGame(t, 250, "hard")
|
||||
for i := 0; i < 3; i++ {
|
||||
s, _ = answerRight(t, s, time.Second)
|
||||
}
|
||||
banked := s.Pays()
|
||||
|
||||
q := s.Live()
|
||||
late := s.AskedAt.Add(s.Tier.Clock() + time.Millisecond)
|
||||
out, evs, err := ApplyMove(s, Move{Choice: q.Correct}, late) // the *right* answer, too late
|
||||
if err != nil {
|
||||
t.Fatalf("a late answer is a legal move: %v", err)
|
||||
}
|
||||
if out.Outcome != OutcomeTimeout {
|
||||
t.Fatalf("answering past the limit should time out, got %s", out.Outcome)
|
||||
}
|
||||
if out.Payout != 0 {
|
||||
t.Fatalf("a timeout pays nothing — it was worth %d a moment ago, and paid %d", banked, out.Payout)
|
||||
}
|
||||
if evs[0].Kind != "timeout" {
|
||||
t.Fatalf("expected a timeout event, got %+v", evs[0])
|
||||
}
|
||||
|
||||
// And answering on the final tick still counts.
|
||||
onTime := s.AskedAt.Add(s.Tier.Clock())
|
||||
if out, _, err = ApplyMove(s, Move{Choice: q.Correct}, onTime); err != nil {
|
||||
t.Fatalf("an answer on the buzzer is legal: %v", err)
|
||||
}
|
||||
if out.Rung != s.Rung+1 {
|
||||
t.Fatal("an answer on the final tick should still count")
|
||||
}
|
||||
}
|
||||
|
||||
// Speed is the only thing separating a slow right answer from a fast one.
|
||||
func TestFasterPaysMore(t *testing.T) {
|
||||
base := newGame(t, 1000, "hard")
|
||||
|
||||
quick, _ := answerRight(t, base, time.Second)
|
||||
slow, _ := answerRight(t, base, 14*time.Second)
|
||||
|
||||
if quick.Multiple <= slow.Multiple {
|
||||
t.Fatalf("a quick answer should be worth more: quick %v, slow %v", quick.Multiple, slow.Multiple)
|
||||
}
|
||||
if quick.Pays() <= slow.Pays() {
|
||||
t.Fatalf("a quick answer should pay more: quick %d, slow %d", quick.Pays(), slow.Pays())
|
||||
}
|
||||
|
||||
// The ends of the scale are the tier's own numbers, and nothing is outside them.
|
||||
instant, _ := answerRight(t, base, 0)
|
||||
buzzer, _ := answerRight(t, base, base.Tier.Clock())
|
||||
if instant.Multiple != base.Tier.Fast {
|
||||
t.Fatalf("an instant answer is worth Fast (%v), got %v", base.Tier.Fast, instant.Multiple)
|
||||
}
|
||||
if buzzer.Multiple != base.Tier.Buzzer {
|
||||
t.Fatalf("an answer on the buzzer is worth Buzzer (%v), got %v", base.Tier.Buzzer, buzzer.Multiple)
|
||||
}
|
||||
if quick.Multiple > base.Tier.Fast || slow.Multiple < base.Tier.Buzzer {
|
||||
t.Fatal("a step escaped the tier's range")
|
||||
}
|
||||
}
|
||||
|
||||
// Clearing the ladder ends the run and banks it, rather than leaving the player
|
||||
// on a rung that doesn't exist.
|
||||
func TestClearingTheLadderBanks(t *testing.T) {
|
||||
s := newGame(t, 100, "easy")
|
||||
for i := 0; i < Rungs; i++ {
|
||||
if s.Phase != PhasePlaying {
|
||||
t.Fatalf("the game ended early, on rung %d", i)
|
||||
}
|
||||
s, _ = answerRight(t, s, time.Second)
|
||||
}
|
||||
if s.Outcome != OutcomeCleared {
|
||||
t.Fatalf("twelve right answers should clear the ladder, got %s", s.Outcome)
|
||||
}
|
||||
if s.Rung != Rungs {
|
||||
t.Fatalf("expected to be on rung %d, got %d", Rungs, s.Rung)
|
||||
}
|
||||
if s.Payout != s.Pays() || s.Payout <= s.Bet {
|
||||
t.Fatalf("clearing should bank a win, got payout %d on a %d stake", s.Payout, s.Bet)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Move{Choice: 0}, s.AskedAt); err != ErrGameOver {
|
||||
t.Fatalf("a cleared ladder takes no more moves, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The rake comes out of winnings, never out of the stake.
|
||||
func TestRakeOnlyBitesWinnings(t *testing.T) {
|
||||
s := newGame(t, 1000, "medium")
|
||||
s, _ = answerRight(t, s, 0) // instant: multiple is exactly Fast, so the sum is checkable by hand
|
||||
|
||||
banked, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
|
||||
total := int64(float64(1000) * s.Tier.Fast) // 1550
|
||||
profit := total - 1000 // 550
|
||||
rake := int64(float64(profit) * 0.05) // 27
|
||||
want := 1000 + profit - rake // 1523
|
||||
|
||||
if banked.Payout != want {
|
||||
t.Fatalf("payout should be stake + winnings - 5%% of winnings = %d, got %d", want, banked.Payout)
|
||||
}
|
||||
if banked.Rake != rake {
|
||||
t.Fatalf("rake should be %d, got %d", rake, banked.Rake)
|
||||
}
|
||||
if banked.Payout < banked.Bet {
|
||||
t.Fatal("a win handed back less than the stake")
|
||||
}
|
||||
}
|
||||
|
||||
// A move must not scribble on the state it came from — a game has to replay.
|
||||
func TestApplyMoveDoesNotMutateItsInput(t *testing.T) {
|
||||
s := newGame(t, 100, "easy")
|
||||
before := s.Live()
|
||||
|
||||
next, _, err := ApplyMove(s, Move{Choice: before.Correct}, s.AskedAt.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("move: %v", err)
|
||||
}
|
||||
if s.Rung != 0 || s.Multiple != 1 || s.Phase != PhasePlaying {
|
||||
t.Fatalf("the original state moved underneath us: rung %d multiple %v", s.Rung, s.Multiple)
|
||||
}
|
||||
if next.Rung != 1 {
|
||||
t.Fatalf("the derived state should have climbed a rung, got %d", next.Rung)
|
||||
}
|
||||
// The same move replays to the same place.
|
||||
again, _, err := ApplyMove(s, Move{Choice: before.Correct}, s.AskedAt.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
if again.Multiple != next.Multiple || again.Rung != next.Rung {
|
||||
t.Fatal("the same move from the same state landed somewhere else")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeftCountsDown(t *testing.T) {
|
||||
s := newGame(t, 100, "hard") // 15s
|
||||
if got := s.Left(epoch); got != 15*time.Second {
|
||||
t.Fatalf("a fresh question has the whole clock, got %v", got)
|
||||
}
|
||||
if got := s.Left(epoch.Add(10 * time.Second)); got != 5*time.Second {
|
||||
t.Fatalf("expected 5s left, got %v", got)
|
||||
}
|
||||
// It floors at nought rather than going negative, so a browser can render it.
|
||||
if got := s.Left(epoch.Add(time.Hour)); got != 0 {
|
||||
t.Fatalf("the clock should stop at zero, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGarbageMovesAreRefused(t *testing.T) {
|
||||
s := newGame(t, 100, "easy")
|
||||
for _, choice := range []int{-1, 4, 99} {
|
||||
if _, _, err := ApplyMove(s, Move{Choice: choice}, s.AskedAt); err != ErrUnknownMove {
|
||||
t.Fatalf("choice %d should be refused, got %v", choice, err)
|
||||
}
|
||||
}
|
||||
if s.Phase != PhasePlaying {
|
||||
t.Fatal("a refused move should leave the game alone")
|
||||
}
|
||||
}
|
||||
|
||||
// The clock has to beat the walk button, or it is not a deadline.
|
||||
//
|
||||
// If a dead clock could still be walked away from, the ladder would carry no
|
||||
// risk at all: sit on every question for as long as you like, answer the ones
|
||||
// you can look up, and walk off the ones you can't. The timeout has to be the
|
||||
// first thing that happens to a move.
|
||||
func TestWalkingOffADeadClockIsATimeout(t *testing.T) {
|
||||
s := newGame(t, 500, "hard")
|
||||
s, _ = answerRight(t, s, time.Second) // one rung banked, so a walk is otherwise legal
|
||||
|
||||
late := s.AskedAt.Add(s.Tier.Clock() + time.Second)
|
||||
out, evs, err := ApplyMove(s, Move{Walk: true}, late)
|
||||
if err != nil {
|
||||
t.Fatalf("walking after the clock died should resolve, not error: %v", err)
|
||||
}
|
||||
if out.Outcome != OutcomeTimeout {
|
||||
t.Fatalf("a walk after the clock ran out is a timeout, got %q", out.Outcome)
|
||||
}
|
||||
if out.Payout != 0 {
|
||||
t.Fatalf("a timeout pays nothing, got %d", out.Payout)
|
||||
}
|
||||
if len(evs) == 0 || evs[0].Kind != "timeout" {
|
||||
t.Fatalf("expected the timeout event first, got %+v", evs)
|
||||
}
|
||||
|
||||
// And the same walk, one tick inside the limit, still banks.
|
||||
intime := s.AskedAt.Add(s.Tier.Clock() - time.Millisecond)
|
||||
banked, _, err := ApplyMove(s, Move{Walk: true}, intime)
|
||||
if err != nil {
|
||||
t.Fatalf("walk with the clock still running: %v", err)
|
||||
}
|
||||
if banked.Outcome != OutcomeWalked || banked.Payout <= 0 {
|
||||
t.Fatalf("a walk inside the clock banks, got %q paying %d", banked.Outcome, banked.Payout)
|
||||
}
|
||||
}
|
||||
182
internal/games/uno/bot.go
Normal file
182
internal/games/uno/bot.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package uno
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// The bots.
|
||||
//
|
||||
// Lifted from the ones gogobee's UNO plays in Matrix, which are genuinely decent
|
||||
// company — they hold their wild draw fours back until you're close to going
|
||||
// out, they follow the colour in play when they can, and they get out of the way
|
||||
// of their own hand. Two things changed on the way over:
|
||||
//
|
||||
// The RNG is threaded. gogobee's bots reach for the package global, which is why
|
||||
// its own tests can only assert that a bot played *something* legal. These take
|
||||
// the game's generator, so a bot's choice is part of what a seed replays.
|
||||
//
|
||||
// They are not the same bot at every table. A single deterministic policy is a
|
||||
// puzzle: play round it once and it never surprises you again. So the bot takes
|
||||
// the best card it sees most of the time, and now and then takes the second best
|
||||
// — enough that you cannot count what it is holding by what it plays.
|
||||
|
||||
// botSlip is how often a bot takes its second choice instead of its first. Low
|
||||
// enough that it still plays well, high enough that it isn't a lookup table.
|
||||
const botSlip = 6 // one turn in six
|
||||
|
||||
// botPick chooses a card to play, or reports -1 when the bot has nothing legal.
|
||||
func botPick(hand []Card, top Card, topColor Color, minOpponent int, rng *rand.Rand) (Card, int) {
|
||||
var playable []int
|
||||
for i, c := range hand {
|
||||
if c.CanPlayOn(top, topColor) {
|
||||
playable = append(playable, i)
|
||||
}
|
||||
}
|
||||
if len(playable) == 0 {
|
||||
return Card{}, -1
|
||||
}
|
||||
|
||||
order := botRank(hand, topColor, playable, minOpponent)
|
||||
pick := order[0]
|
||||
if len(order) > 1 && rng.IntN(botSlip) == 0 {
|
||||
pick = order[1]
|
||||
}
|
||||
return hand[pick], pick
|
||||
}
|
||||
|
||||
// botRank sorts the playable cards best-first, by the bot's own priorities.
|
||||
//
|
||||
// The shape of it: hurt the leader if there is one, otherwise get rid of
|
||||
// something useful and keep the wilds back. A wild draw four spent early is a
|
||||
// wild draw four you don't have when somebody is sitting on one card.
|
||||
func botRank(hand []Card, topColor Color, playable []int, minOpponent int) []int {
|
||||
var wd4, wilds, actions, numbers []int
|
||||
for _, i := range playable {
|
||||
switch c := hand[i]; {
|
||||
case c.Value == WildDrawFour:
|
||||
wd4 = append(wd4, i)
|
||||
case c.Value == WildCard:
|
||||
wilds = append(wilds, i)
|
||||
case c.Value.Action():
|
||||
actions = append(actions, i)
|
||||
default:
|
||||
numbers = append(numbers, i)
|
||||
}
|
||||
}
|
||||
|
||||
// Following the colour in play is worth more than not, because it keeps the
|
||||
// bot's hand flexible — so within each group, the cards already in colour go
|
||||
// first.
|
||||
inColorFirst := func(idx []int) []int {
|
||||
var same, other []int
|
||||
for _, i := range idx {
|
||||
if hand[i].Color == topColor {
|
||||
same = append(same, i)
|
||||
} else {
|
||||
other = append(other, i)
|
||||
}
|
||||
}
|
||||
return append(same, other...)
|
||||
}
|
||||
|
||||
var out []int
|
||||
if minOpponent >= 0 && minOpponent <= 2 {
|
||||
// Somebody is about to go out. This is what the +4 was being saved for.
|
||||
out = append(out, wd4...)
|
||||
out = append(out, inColorFirst(actions)...)
|
||||
out = append(out, wilds...)
|
||||
out = append(out, inColorFirst(numbers)...)
|
||||
return out
|
||||
}
|
||||
out = append(out, inColorFirst(actions)...)
|
||||
out = append(out, inColorFirst(numbers)...)
|
||||
out = append(out, wilds...)
|
||||
out = append(out, wd4...)
|
||||
return out
|
||||
}
|
||||
|
||||
// botStack answers a stack, or reports -1 when the bot has nothing to answer it
|
||||
// with and has to eat the lot.
|
||||
//
|
||||
// It plays the *smallest* draw card it holds. The bill is passed on either way —
|
||||
// what it is passing on is the stack plus whatever it added — so the cheap card
|
||||
// does the same job as the expensive one, and keeps the +10 in hand for a turn
|
||||
// when the bot is the one choosing to hurt somebody rather than the one dodging.
|
||||
//
|
||||
// The slip is here too: one time in six it reaches for the second-smallest, so a
|
||||
// player can't read the stack it just passed as a complete inventory of what the
|
||||
// bot doesn't have.
|
||||
func botStack(hand []Card, topColor Color, rng *rand.Rand) (Card, int) {
|
||||
var can []int
|
||||
for i, c := range hand {
|
||||
if c.CanStackOn(topColor) {
|
||||
can = append(can, i)
|
||||
}
|
||||
}
|
||||
if len(can) == 0 {
|
||||
return Card{}, -1
|
||||
}
|
||||
// Smallest draw first. A stable insertion sort: there are never many.
|
||||
for i := 1; i < len(can); i++ {
|
||||
for j := i; j > 0 && hand[can[j]].Value.Draw() < hand[can[j-1]].Value.Draw(); j-- {
|
||||
can[j], can[j-1] = can[j-1], can[j]
|
||||
}
|
||||
}
|
||||
pick := can[0]
|
||||
if len(can) > 1 && rng.IntN(botSlip) == 0 {
|
||||
pick = can[1]
|
||||
}
|
||||
return hand[pick], pick
|
||||
}
|
||||
|
||||
// botRouletteColor names the colour for a roulette: whichever the bot holds
|
||||
// *least* of. The victim flips until that colour turns up, so the rarer the
|
||||
// colour, the longer they flip and the more they keep. Naming the colour you're
|
||||
// long in is naming the one that ends the flipping soonest, which is mercy — and
|
||||
// this is not that game.
|
||||
func botRouletteColor(hand []Card, rng *rand.Rand) Color {
|
||||
counts := [5]int{}
|
||||
for _, c := range hand {
|
||||
if c.Color.Playable() {
|
||||
counts[c.Color]++
|
||||
}
|
||||
}
|
||||
best, bestN := Wild, 1<<30
|
||||
for col := Red; col <= Green; col++ {
|
||||
if counts[col] < bestN {
|
||||
best, bestN = col, counts[col]
|
||||
}
|
||||
}
|
||||
if best == Wild {
|
||||
return Red + Color(rng.IntN(4))
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// botColor names a colour for a wild: whichever the bot holds most of, so the
|
||||
// card it plays next is one it already has. A hand of nothing but wilds picks
|
||||
// at random rather than always saying red, which would be a tell.
|
||||
func botColor(hand []Card, rng *rand.Rand) Color {
|
||||
counts := [5]int{}
|
||||
for _, c := range hand {
|
||||
if c.Color.Playable() {
|
||||
counts[c.Color]++
|
||||
}
|
||||
}
|
||||
best, bestN := Wild, 0
|
||||
for col := Red; col <= Green; col++ {
|
||||
if counts[col] > bestN {
|
||||
best, bestN = col, counts[col]
|
||||
}
|
||||
}
|
||||
if bestN == 0 {
|
||||
return Red + Color(rng.IntN(4))
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// botPool are the regulars a bot seat is named from. Flavour, and load-bearing
|
||||
// flavour: "Kiwi played a +4" is a table, "Bot 2 played a +4" is a test fixture.
|
||||
// More names than a full table, so no two chairs ever share one.
|
||||
var botPool = []string{
|
||||
"Kiwi", "Mochi", "Bramble", "Pixel", "Gus", "Nori", "Waffle", "Marzipan",
|
||||
"Tuck", "Bebop", "Olive", "Rascal", "Peaches", "Dot", "Sable", "Clementine",
|
||||
}
|
||||
203
internal/games/uno/call.go
Normal file
203
internal/games/uno/call.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package uno
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// Calling UNO, and catching the seat that didn't.
|
||||
//
|
||||
// This was the one rule on the box the table wasn't playing. A hand going down to
|
||||
// one card used to emit the "uno" event by itself, which made the call a thing
|
||||
// that *happened to you* rather than a thing you did — and a rule nobody can fail
|
||||
// is not a rule, it's an announcement.
|
||||
//
|
||||
// So now: play your second-to-last card and you owe the table a word. Say it
|
||||
// (Move.Uno) and you're safe. Stay quiet and the bots get one look at you, right
|
||||
// then, before any of them plays — because a bot that has moved on is a bot that
|
||||
// has stopped watching your hand. Miss it and you take two.
|
||||
//
|
||||
// It runs the other way too, and that half is the fun one. A bot forgets often
|
||||
// enough to be worth watching for, and when it does it says *nothing* — there is
|
||||
// no event, no badge, no tell on the felt except the count beside its fan reading
|
||||
// "1 card" with no UNO on it. Catch it (MoveCatch) and it takes two. Call a seat
|
||||
// that had nothing to hide and you take two yourself, which is what stops the
|
||||
// catch button from being a thing you simply mash every turn.
|
||||
//
|
||||
// The whole rule turns on one asymmetry, and it's deliberate: the bots get a
|
||||
// *roll* to notice you, and you get to actually look. Attention is the edge here,
|
||||
// and it's the player's to take.
|
||||
|
||||
const (
|
||||
// CatchPenalty is what silence costs, both ways. Two, as printed on the box.
|
||||
CatchPenalty = 2
|
||||
|
||||
// botForget is how often a bot goes down to one card without saying so. High
|
||||
// enough that watching the counts pays — at a full table you'll get a catch to
|
||||
// make every few games — and low enough that a quiet seat is still a thing you
|
||||
// have to notice rather than assume.
|
||||
botForget = 0.30
|
||||
|
||||
// botAlert is the chance a *single* bot notices that you didn't call. They each
|
||||
// get a look, so forgetting is punished about 75% of the time heads-up and 98%
|
||||
// at a full table: the more seats there are watching you, the less you get away
|
||||
// with, which is the right shape for it.
|
||||
botAlert = 0.75
|
||||
)
|
||||
|
||||
// botForgets rolls for whether a bot muffs its call.
|
||||
func botForgets(rng *rand.Rand) bool { return rng.Float64() < botForget }
|
||||
|
||||
// declare records whether a seat that is now on one card said so, and — if it did
|
||||
// — announces it. A seat on any other number of cards owes nothing and this does
|
||||
// nothing, which is what makes it safe to call after every play.
|
||||
func (s *State) declare(seat int, called bool, evs *[]Event) {
|
||||
if !s.playing() || len(s.Hands[seat]) != 1 {
|
||||
return
|
||||
}
|
||||
s.ensureCalled()
|
||||
s.Called[seat] = called
|
||||
if called {
|
||||
*evs = append(*evs, Event{Kind: EvUno, Seat: seat, Left: 1})
|
||||
}
|
||||
}
|
||||
|
||||
// botsCatch is the window. The seat that just played is holding one card and
|
||||
// didn't say the word — so every bot still in the hand gets one look, in seat
|
||||
// order, and the first to see it takes them for two.
|
||||
//
|
||||
// It runs before runBots on purpose. The catch has to land while the table is
|
||||
// still looking at the card that was played, not three turns later. Only the bots
|
||||
// catch on a roll here; a human polices the table with the catch button, which is
|
||||
// the asymmetry the whole rule turns on — attention is the player's edge.
|
||||
func (s *State) botsCatch(actor int, evs *[]Event, rng *rand.Rand) {
|
||||
if !s.playing() || len(s.Hands[actor]) != 1 || s.called(actor) {
|
||||
return
|
||||
}
|
||||
for _, seat := range s.alive() {
|
||||
if seat == actor || !s.Seats[seat].Bot {
|
||||
continue // a human catches with the button, not on a roll
|
||||
}
|
||||
if rng.Float64() >= botAlert {
|
||||
continue // this one wasn't looking
|
||||
}
|
||||
s.penalise(actor, seat, EvCaught, evs, rng)
|
||||
return // caught once is caught. They don't queue up to take turns at you
|
||||
}
|
||||
}
|
||||
|
||||
// seatCatches calls out a seat the caller thinks is holding one card in silence.
|
||||
//
|
||||
// It is not a turn: right or wrong, the turn stays where it was. What it costs is
|
||||
// the risk — a seat that did call, or isn't on one card at all, is a seat the
|
||||
// caller has accused of nothing, and that is two cards to them.
|
||||
func (s *State) seatCatches(caller int, m Move, rng *rand.Rand) ([]Event, error) {
|
||||
seat := m.Seat
|
||||
if seat == caller || seat < 0 || seat >= len(s.Hands) || !s.live(seat) {
|
||||
return nil, ErrNoCatch
|
||||
}
|
||||
var evs []Event
|
||||
if len(s.Hands[seat]) == 1 && !s.called(seat) {
|
||||
s.penalise(seat, caller, EvCaught, &evs, rng) // got them
|
||||
} else {
|
||||
s.penalise(caller, seat, EvMiscall, &evs, rng) // they were clean, and you weren't looking
|
||||
}
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
// penalise makes a seat take the price of the call: cards off the deck, quietly —
|
||||
// no draw event, because what the table is being told about is the catch, and a
|
||||
// draw event alongside it would animate the same two cards twice.
|
||||
//
|
||||
// In No Mercy those two cards can be the two that bury them, which is a fine way
|
||||
// to go: caught on one card, dead on twenty-five.
|
||||
func (s *State) penalise(victim, by int, kind string, evs *[]Event, rng *rand.Rand) {
|
||||
got := s.drawCards(victim, CatchPenalty, evs, rng)
|
||||
if len(got) == 0 {
|
||||
return // the table has nothing left to punish anybody with
|
||||
}
|
||||
s.ensureCalled()
|
||||
s.Called[victim] = false
|
||||
*evs = append(*evs, s.mine(Event{
|
||||
Kind: kind, Seat: victim, By: by, N: len(got), Left: len(s.Hands[victim]),
|
||||
}))
|
||||
s.mercy(victim, evs, rng)
|
||||
}
|
||||
|
||||
// UnoAt is which cards in your hand would leave you holding exactly one, if you
|
||||
// played them. It is the table's cue to ask you for the call, and it comes from
|
||||
// here rather than from the browser counting your cards because No Mercy's
|
||||
// "discard all" doesn't take one card out of your hand — it takes every card of
|
||||
// its colour, and the browser guessing at that is the browser getting it wrong.
|
||||
//
|
||||
// It answers for every card, legal or not. The table only ever asks about a card
|
||||
// you were allowed to play anyway.
|
||||
func (s State) UnoAt(seat int) []int {
|
||||
if !s.playing() || s.Turn != seat {
|
||||
return nil
|
||||
}
|
||||
hand := s.Hands[seat]
|
||||
var out []int
|
||||
for i, c := range hand {
|
||||
left := len(hand) - 1
|
||||
if c.Value == DiscardAll {
|
||||
for j, other := range hand {
|
||||
if j != i && other.Color == c.Color && !other.IsWild() {
|
||||
left--
|
||||
}
|
||||
}
|
||||
}
|
||||
if left == 1 {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Catchable is which seats are, right now, sitting on one card they never
|
||||
// announced. It is what the browser puts a button on.
|
||||
//
|
||||
// This leaks nothing. The card counts are already on the felt and the UNO badge
|
||||
// already isn't — this is the same two facts, subtracted, and doing that
|
||||
// subtraction on the server is only so that the rule for what counts as catchable
|
||||
// lives in one place instead of two.
|
||||
func (s State) Catchable(viewer int) []int {
|
||||
if !s.playing() || s.Turn != viewer {
|
||||
return nil // you can only catch a seat on your own turn
|
||||
}
|
||||
var out []int
|
||||
for _, seat := range s.alive() {
|
||||
if seat != viewer && len(s.Hands[seat]) == 1 && !s.called(seat) {
|
||||
out = append(out, seat)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// called reports whether a seat holding one card announced it. Callers outside
|
||||
// the package read the Called slice, which is on the state they already hold.
|
||||
func (s State) called(seat int) bool {
|
||||
return seat < len(s.Called) && s.Called[seat]
|
||||
}
|
||||
|
||||
// ensureCalled grows the slice to fit the table. A game dealt before this rule
|
||||
// existed has no Called at all, and the seats in it are all — correctly —
|
||||
// uncalled: nobody in that game ever said the word, because there was no way to.
|
||||
func (s *State) ensureCalled() {
|
||||
for len(s.Called) < len(s.Hands) {
|
||||
s.Called = append(s.Called, false)
|
||||
}
|
||||
}
|
||||
|
||||
// tidyCalls forgets the calls that no longer mean anything. A seat's call is only
|
||||
// ever about the one card it is holding — draw into two and the word you said is
|
||||
// spent, and saying it again is a new thing you have to do.
|
||||
//
|
||||
// Without this, a seat that called on one card, was made to draw, and worked its
|
||||
// way back down to one would still be wearing the old call, and could never be
|
||||
// caught again for the rest of the game.
|
||||
func (s *State) tidyCalls() {
|
||||
s.ensureCalled()
|
||||
for seat := range s.Called {
|
||||
if len(s.Hands[seat]) != 1 {
|
||||
s.Called[seat] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
323
internal/games/uno/call_test.go
Normal file
323
internal/games/uno/call_test.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package uno
|
||||
|
||||
import "testing"
|
||||
|
||||
// The UNO call, and the catch on the other side of it. See call.go.
|
||||
|
||||
// oneCardAway sets you up holding two cards, both of which go on the pile, so
|
||||
// playing either one takes you to UNO.
|
||||
//
|
||||
// The bot is given a hand that can't touch a red pile and a deck that can't help
|
||||
// it, so whatever it does on its turn, it does not make you draw. That matters:
|
||||
// a hand that grows spends the call (see tidyCalls), which is correct and would
|
||||
// otherwise make these tests flap on the seeds where the bot happens to turn up
|
||||
// a +2.
|
||||
func oneCardAway(t *testing.T, seed uint64) State {
|
||||
t.Helper()
|
||||
s := deal(t, duel(), seed)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
|
||||
s.Hands[1] = []Card{{Blue, Three}, {Green, Four}, {Yellow, Six}}
|
||||
s.Deck = make([]Card, 24)
|
||||
for i := range s.Deck {
|
||||
s.Deck[i] = Card{Blue, Nine} // nothing here plays on a red one, and nothing bites
|
||||
}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
return s
|
||||
}
|
||||
|
||||
// TestCallingUnoKeepsYouSafe — say the word and the table has nothing on you.
|
||||
// Across a spread of seeds, not one bot ever gets a catch.
|
||||
func TestCallingUnoKeepsYouSafe(t *testing.T) {
|
||||
for seed := uint64(0); seed < 200; seed++ {
|
||||
s := oneCardAway(t, seed)
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Uno: true})
|
||||
if err != nil {
|
||||
t.Fatalf("seed %d: play: %v", seed, err)
|
||||
}
|
||||
if hasKind(evs, EvCaught) {
|
||||
t.Fatalf("seed %d: caught after calling UNO", seed)
|
||||
}
|
||||
if !hasKind(evs, EvUno) {
|
||||
t.Fatalf("seed %d: called UNO and the table never said so", seed)
|
||||
}
|
||||
if !next.Called[You] {
|
||||
t.Fatalf("seed %d: the call wasn't recorded", seed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestForgettingUnoGetsYouCaught — stay quiet on one card and the bot takes you
|
||||
// for two. It gets one look, so it misses sometimes; over 400 games it should
|
||||
// land near botAlert, and the two cards should actually arrive.
|
||||
func TestForgettingUnoGetsYouCaught(t *testing.T) {
|
||||
caught, games := 0, 400
|
||||
for seed := uint64(0); seed < uint64(games); seed++ {
|
||||
s := oneCardAway(t, seed)
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("seed %d: play: %v", seed, err)
|
||||
}
|
||||
if !hasKind(evs, EvCaught) {
|
||||
continue
|
||||
}
|
||||
caught++
|
||||
// One card left after the play, plus the two the catch cost.
|
||||
if n := len(next.Hands[You]); n != 3 {
|
||||
t.Fatalf("seed %d: caught and holding %d, want 3", seed, n)
|
||||
}
|
||||
if hasKind(evs, EvUno) {
|
||||
t.Fatalf("seed %d: an UNO was announced by a seat that never called", seed)
|
||||
}
|
||||
}
|
||||
rate := float64(caught) / float64(games)
|
||||
if rate < botAlert-0.08 || rate > botAlert+0.08 {
|
||||
t.Errorf("one bot caught you %.0f%% of the time, want about %.0f%% (botAlert)",
|
||||
rate*100, botAlert*100)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoreBotsMeansLessGettingAwayWithIt — every seat gets its own look, so
|
||||
// forgetting at a full table is very nearly always punished.
|
||||
func TestMoreBotsMeansLessGettingAwayWithIt(t *testing.T) {
|
||||
away := func(tier Tier, seed uint64) bool {
|
||||
s := deal(t, tier, seed)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
_, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play: %v", err)
|
||||
}
|
||||
return !hasKind(evs, EvCaught)
|
||||
}
|
||||
var got [2]float64
|
||||
for i, tier := range []Tier{duel(), table()} {
|
||||
escapes := 0
|
||||
for seed := uint64(0); seed < 400; seed++ {
|
||||
if away(tier, seed) {
|
||||
escapes++
|
||||
}
|
||||
}
|
||||
got[i] = float64(escapes) / 400
|
||||
}
|
||||
if got[1] >= got[0] {
|
||||
t.Errorf("you got away with it %.0f%% of the time against one bot and %.0f%% against two; "+
|
||||
"two pairs of eyes should catch you more often, not less", got[0]*100, got[1]*100)
|
||||
}
|
||||
}
|
||||
|
||||
// quietBot puts a bot on one card it never called, with the turn back on you.
|
||||
func quietBot(t *testing.T, called bool) State {
|
||||
t.Helper()
|
||||
s := deal(t, duel(), 21)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, One}, {Blue, Two}, {Green, Three}}
|
||||
s.Hands[1] = []Card{{Yellow, Nine}}
|
||||
s.Called = []bool{false, called}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
return s
|
||||
}
|
||||
|
||||
// TestCatchingAQuietBot — it's on one card and it never said so. Two cards to it,
|
||||
// and the turn is still yours: catching is not a move you spend a turn on.
|
||||
func TestCatchingAQuietBot(t *testing.T) {
|
||||
s := quietBot(t, false)
|
||||
before := total(census(s))
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveCatch, Seat: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("catch: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvCaught) {
|
||||
t.Fatal("no catch event")
|
||||
}
|
||||
if n := len(next.Hands[1]); n != 3 {
|
||||
t.Errorf("the bot holds %d, want 3: one card, plus the two it just took", n)
|
||||
}
|
||||
if n := len(next.Hands[You]); n != 3 {
|
||||
t.Errorf("your hand is %d, want 3: a catch costs you nothing", n)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Errorf("the turn went to seat %d: a catch is not a turn", next.Turn)
|
||||
}
|
||||
if total(census(next)) != before {
|
||||
t.Error("the catch lost a card")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCatchingACleanBotCostsYou — it called, or it isn't on one card at all.
|
||||
// Either way you've accused it of nothing, and that is two cards to you.
|
||||
func TestCatchingACleanBotCostsYou(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
state State
|
||||
}{
|
||||
{"it called", quietBot(t, true)},
|
||||
{"it isn't even close", func() State {
|
||||
s := quietBot(t, false)
|
||||
s.Hands[1] = []Card{{Yellow, Nine}, {Yellow, Eight}}
|
||||
return s
|
||||
}()},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
next, evs, err := ApplyMove(tc.state, You, Move{Kind: MoveCatch, Seat: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("catch: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvMiscall) {
|
||||
t.Fatal("no miscall event")
|
||||
}
|
||||
if n := len(next.Hands[You]); n != 5 {
|
||||
t.Errorf("your hand is %d, want 5: three, plus the two a bad call cost", n)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Errorf("the turn went to seat %d: even a bad catch isn't a turn", next.Turn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestYouCannotCatchYourself, or a seat that isn't at the table.
|
||||
func TestYouCannotCatchYourself(t *testing.T) {
|
||||
s := quietBot(t, false)
|
||||
for _, seat := range []int{You, -1, 9} {
|
||||
if _, _, err := ApplyMove(s, You, Move{Kind: MoveCatch, Seat: seat}); err != ErrNoCatch {
|
||||
t.Errorf("catching seat %d: got %v, want ErrNoCatch", seat, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestACallIsSpentWhenTheHandGrows. Call on one card, get made to draw, and work
|
||||
// your way back down to one: that is a new call you owe, not the old one still
|
||||
// standing. Without this a seat could be caught out once and never again.
|
||||
func TestACallIsSpentWhenTheHandGrows(t *testing.T) {
|
||||
s := deal(t, duel(), 5)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, One}}
|
||||
s.Called = []bool{true, false}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
// Draw, and the hand is two: the word you said was about a card you no longer
|
||||
// hold on its own.
|
||||
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if next.Called[You] {
|
||||
t.Error("the call survived the hand growing; it should be spent")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCatchableIsWhatTheTableCanSee — a quiet bot on one card, and nobody else.
|
||||
func TestCatchable(t *testing.T) {
|
||||
s := quietBot(t, false)
|
||||
if got := s.Catchable(You); len(got) != 1 || got[0] != 1 {
|
||||
t.Errorf("Catchable() = %v, want [1]", got)
|
||||
}
|
||||
clean := quietBot(t, true)
|
||||
if got := clean.Catchable(You); len(got) != 0 {
|
||||
t.Errorf("Catchable() = %v on a bot that called, want none", got)
|
||||
}
|
||||
// And not on somebody else's turn: you can only call it out when it's on you.
|
||||
off := quietBot(t, false)
|
||||
off.Turn = 1
|
||||
if got := off.Catchable(You); len(got) != 0 {
|
||||
t.Errorf("Catchable() = %v off-turn, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnoAtSeesThroughDiscardAll — the whole reason the table asks the engine
|
||||
// which cards take you to one, rather than counting your hand itself. "Discard
|
||||
// all" takes every card of its colour with it, so a six-card hand can land on
|
||||
// one, and a browser subtracting one from six gets a player caught.
|
||||
func TestUnoAtSeesThroughDiscardAll(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 3)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Red, Seven}, {Blue, Two}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
// Index 0 dumps itself and the three other reds: five cards become one.
|
||||
// Index 4 is an ordinary play: five become four.
|
||||
got := s.UnoAt(You)
|
||||
if len(got) != 1 || got[0] != 0 {
|
||||
t.Errorf("UnoAt() = %v, want [0]: only the discard-all lands you on one card", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnoAtIsTheOrdinaryCaseToo — two cards in hand, and either of them is a call.
|
||||
func TestUnoAtIsTheOrdinaryCaseToo(t *testing.T) {
|
||||
s := oneCardAway(t, 1)
|
||||
got := s.UnoAt(You)
|
||||
if len(got) != 2 {
|
||||
t.Errorf("UnoAt() = %v, want both cards: either one leaves you holding one", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoingOutNeedsNoCall — your last card is not one card, it's none. Nobody
|
||||
// owes the table a word for winning.
|
||||
func TestGoingOutNeedsNoCall(t *testing.T) {
|
||||
s := deal(t, duel(), 9)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, One}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play the last card: %v", err)
|
||||
}
|
||||
if next.Winner != You {
|
||||
t.Fatalf("winner %d outcome %q, want a win for you", next.Winner, next.Outcome)
|
||||
}
|
||||
if hasKind(evs, EvCaught) {
|
||||
t.Error("caught for not calling UNO on the card that won the game")
|
||||
}
|
||||
}
|
||||
|
||||
// TestABotThatForgetsSaysNothing — the tell is the absence of the badge, and the
|
||||
// count beside the fan. If a quiet bot emitted anything at all there'd be nothing
|
||||
// to spot.
|
||||
func TestABotThatForgetsSaysNothing(t *testing.T) {
|
||||
quiet := 0
|
||||
for seed := uint64(0); seed < 300 && quiet < 1; seed++ {
|
||||
s := deal(t, duel(), seed)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Blue, Two}, {Blue, Three}, {Blue, Four}}
|
||||
s.Hands[1] = []Card{{Red, One}, {Red, Nine}}
|
||||
s.Turn = 1
|
||||
s.Phase = PhasePlay
|
||||
s.Turn = You // the bot plays on the back of your move
|
||||
|
||||
// Draw, handing the turn over: the bot plays a red and lands on one card.
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if len(next.Hands[1]) != 1 || next.Called[1] {
|
||||
continue // it either didn't get down to one, or it remembered
|
||||
}
|
||||
quiet++
|
||||
if hasKind(evs, EvUno) {
|
||||
t.Error("a bot that forgot to call still announced it")
|
||||
}
|
||||
if len(next.Catchable(You)) != 1 {
|
||||
t.Error("a quiet bot on one card isn't catchable")
|
||||
}
|
||||
}
|
||||
if quiet == 0 {
|
||||
t.Skip("no bot forgot in 300 games; botForget may have been turned down")
|
||||
}
|
||||
}
|
||||
237
internal/games/uno/nomercy.go
Normal file
237
internal/games/uno/nomercy.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package uno
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// No Mercy.
|
||||
//
|
||||
// A rules dial, not a fourth table. The table size is still the tier — a duel is
|
||||
// a duel — and No Mercy is a switch you throw across all three of them. What it
|
||||
// changes is the game they play:
|
||||
//
|
||||
// - A 168-card deck, with faces the normal one doesn't print: a coloured +4, a
|
||||
// +6, a +10, a skip-everyone, a discard-all, a reverse-and-draw-four, and a
|
||||
// colour roulette.
|
||||
// - Draw cards stack. A +2 pointed at you can be answered with any draw card
|
||||
// you hold, and the bill goes to the next seat with the two added on. Whoever
|
||||
// runs out of draw cards eats the lot.
|
||||
// - You draw until you can play. There is no drawing one card and shrugging.
|
||||
// - And twenty-five cards in your hand kills you. That is the whole point of
|
||||
// the deck: it is built to bury somebody, and the mercy rule is what happens
|
||||
// when it does.
|
||||
//
|
||||
// Everything here is reached from uno.go behind `s.Tier.NoMercy`. A normal game
|
||||
// never runs a line of it.
|
||||
|
||||
// MercyLimit is the hand that ends you. Reach it and you are out of the game —
|
||||
// your cards go back in the deck and the table plays on without you.
|
||||
const MercyLimit = 25
|
||||
|
||||
// NewNoMercyDeck builds the 168.
|
||||
//
|
||||
// Per colour: two of each number, three skips, two skip-everyones, four
|
||||
// reverses, two +2s, two coloured +4s and three discard-alls — thirty-six cards,
|
||||
// times four colours. Then the wilds: eight reverse-draw-fours, four +6s, four
|
||||
// +10s and eight roulettes. Unshuffled, same as NewDeck, because New shuffles and
|
||||
// a test wants the order it was built in.
|
||||
func NewNoMercyDeck() []Card {
|
||||
d := make([]Card, 0, 168)
|
||||
for _, col := range []Color{Red, Blue, Yellow, Green} {
|
||||
for v := Zero; v <= Nine; v++ {
|
||||
d = append(d, Card{col, v}, Card{col, v})
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
d = append(d, Card{col, Skip})
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
d = append(d, Card{col, SkipAll})
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
d = append(d, Card{col, Reverse})
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
d = append(d, Card{col, DrawTwo})
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
d = append(d, Card{col, DrawFour})
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
d = append(d, Card{col, DiscardAll})
|
||||
}
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
d = append(d, Card{Wild, WildRevFour})
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
d = append(d, Card{Wild, WildDrawSix})
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
d = append(d, Card{Wild, WildDrawTen})
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
d = append(d, Card{Wild, WildRoulette})
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// CanStackOn reports whether a card can be thrown onto a stack that is already
|
||||
// building. Any draw card answers any other — there is no escalation rule, so a
|
||||
// +2 is a legal reply to a +10 — but a *coloured* draw card still has to follow
|
||||
// the colour in play. The wild draws always go.
|
||||
//
|
||||
// This is why the pending count is not a cap: what you are matching is the fact
|
||||
// of a draw card, not its size.
|
||||
func (c Card) CanStackOn(topColor Color) bool {
|
||||
if c.Value.Draw() == 0 {
|
||||
return false
|
||||
}
|
||||
if c.IsWild() {
|
||||
return true
|
||||
}
|
||||
return c.Color == topColor
|
||||
}
|
||||
|
||||
// canStack reports whether a seat holds anything at all it could answer with.
|
||||
func (s State) canStack(seat int) bool {
|
||||
for _, c := range s.Hands[seat] {
|
||||
if c.CanStackOn(s.Color) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// absorb is what happens when the stack stops with you: you take every card in
|
||||
// it, and you lose your turn. The pending count is cleared *before* the cards
|
||||
// land, because a mercy kill inside the draw ends the seat and there must be no
|
||||
// bill left standing against a seat that is no longer at the table.
|
||||
func (s *State) absorb(seat int, evs *[]Event, rng *rand.Rand) {
|
||||
n := s.Pending
|
||||
s.Pending = 0
|
||||
s.deal(seat, n, true, evs, rng)
|
||||
|
||||
// The seat can die paying the bill, and a mercy kill can end the hand — the last
|
||||
// seat but one dying leaves somebody alone to take the pot. So the phase is only
|
||||
// reset if there is still a hand to have a phase.
|
||||
if s.mercy(seat, evs, rng) && !s.playing() {
|
||||
return
|
||||
}
|
||||
if !s.live(seat) {
|
||||
s.Phase = PhasePlay
|
||||
s.advance(1)
|
||||
return // it died, but the table plays on. Don't skip a seat that isn't there.
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: EvSkip, Seat: seat, Left: len(s.Hands[seat])})
|
||||
s.Phase = PhasePlay
|
||||
s.advance(1) // the turn is on the seat that just paid, so it moves one on
|
||||
}
|
||||
|
||||
// roulette is the colour roulette: the next seat turns cards over until the
|
||||
// named colour comes up, and keeps every card it turned. Then it loses its turn.
|
||||
//
|
||||
// The deck can run out mid-flip (the discard is reshuffled back under as usual,
|
||||
// and even that can be dry), so this is bounded by what there is to draw, not by
|
||||
// the colour ever actually appearing. A wild is not a colour and never ends it.
|
||||
func (s *State) roulette(victim int, color Color, evs *[]Event, rng *rand.Rand) {
|
||||
got := 0
|
||||
for {
|
||||
if len(s.Deck) == 0 && !s.reshuffle(evs, rng) {
|
||||
break
|
||||
}
|
||||
c, ok := s.pop()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
s.Hands[victim] = append(s.Hands[victim], c)
|
||||
got++
|
||||
if c.Color == color {
|
||||
break
|
||||
}
|
||||
if len(s.Hands[victim]) >= MercyLimit {
|
||||
break // they are dead already; stop dealing cards to a corpse
|
||||
}
|
||||
}
|
||||
if got > 0 {
|
||||
e := Event{Kind: EvRoulette, Seat: victim, N: got, Color: color, Left: len(s.Hands[victim])}
|
||||
*evs = append(*evs, s.mine(e))
|
||||
}
|
||||
if s.mercy(victim, evs, rng) {
|
||||
return
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim, Left: len(s.Hands[victim])})
|
||||
s.advance(2)
|
||||
}
|
||||
|
||||
// discardAll dumps every remaining card of a colour out of a hand and buries it
|
||||
// under the card that was just played. The pile keeps its top: the played card
|
||||
// stays the card in play, and the rest go beneath it, where they are still in the
|
||||
// game (a reshuffle brings them back) and still count in a census.
|
||||
func (s *State) discardAll(seat int, color Color, evs *[]Event) int {
|
||||
hand := s.Hands[seat]
|
||||
kept := make([]Card, 0, len(hand))
|
||||
var dumped []Card
|
||||
for _, c := range hand {
|
||||
if c.Color == color && !c.IsWild() {
|
||||
dumped = append(dumped, c)
|
||||
} else {
|
||||
kept = append(kept, c)
|
||||
}
|
||||
}
|
||||
s.Hands[seat] = kept
|
||||
if len(dumped) > 0 {
|
||||
top := s.Discard[len(s.Discard)-1]
|
||||
s.Discard = append(s.Discard[:len(s.Discard)-1], dumped...)
|
||||
s.Discard = append(s.Discard, top)
|
||||
*evs = append(*evs, s.mine(Event{Kind: EvDiscardAll, Seat: seat, N: len(dumped),
|
||||
Color: color, Left: len(kept)}))
|
||||
}
|
||||
return len(dumped)
|
||||
}
|
||||
|
||||
// mercy checks a seat against the limit and, if it has crossed it, takes it out
|
||||
// of the hand: its cards go back into the deck and it plays no more this hand. It
|
||||
// reports whether the seat died.
|
||||
//
|
||||
// A mercy kill is no longer game over for anyone — it is one seat out of the
|
||||
// current hand, human or bot alike, and the hand plays on among the living. When
|
||||
// it leaves exactly one seat alive, that seat has outlived the table and takes the
|
||||
// pot; whoever it is, they win it the same way going out first would.
|
||||
func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool {
|
||||
if !s.Tier.NoMercy || !s.live(seat) || len(s.Hands[seat]) < MercyLimit {
|
||||
return false
|
||||
}
|
||||
n := len(s.Hands[seat])
|
||||
s.Deck = append(s.Deck, s.Hands[seat]...)
|
||||
rng.Shuffle(len(s.Deck), func(i, j int) { s.Deck[i], s.Deck[j] = s.Deck[j], s.Deck[i] })
|
||||
s.Hands[seat] = nil
|
||||
s.Out[seat] = true
|
||||
s.Pending = 0 // a dead seat pays no bill, and leaves none behind
|
||||
*evs = append(*evs, s.mine(Event{Kind: EvMercy, Seat: seat, N: n, Left: 0}))
|
||||
|
||||
if alive := s.alive(); len(alive) == 1 {
|
||||
s.settle(alive[0], OutcomeWon, evs) // the last one standing takes the pot
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Live reports whether a seat is still in the game. The felt needs it: a seat the
|
||||
// mercy rule has buried holds no cards, and a seat holding no cards is otherwise
|
||||
// indistinguishable from the one that just went out and won.
|
||||
func (s State) Live(seat int) bool { return s.live(seat) }
|
||||
|
||||
// alive lists the seats still in the game.
|
||||
func (s State) alive() []int {
|
||||
var out []int
|
||||
for i := range s.Hands {
|
||||
if s.live(i) {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// live reports whether a seat is still in the current hand. Out is empty between
|
||||
// hands and in any game saved before No Mercy existed, so a missing entry is a
|
||||
// living seat.
|
||||
func (s State) live(seat int) bool {
|
||||
return seat >= len(s.Out) || !s.Out[seat]
|
||||
}
|
||||
347
internal/games/uno/nomercy_test.go
Normal file
347
internal/games/uno/nomercy_test.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func nmDuel() Tier { t, _ := TierBySlug("nm-duel"); return t }
|
||||
func nmTable() Tier { t, _ := TierBySlug("nm-table"); return t }
|
||||
func nmFull() Tier { t, _ := TierBySlug("nm-full"); return t }
|
||||
|
||||
func TestNoMercyDeckIsADeck(t *testing.T) {
|
||||
m := census(State{Deck: NewNoMercyDeck()})
|
||||
if got := total(m); got != 168 {
|
||||
t.Fatalf("deck has %d cards, want 168", got)
|
||||
}
|
||||
want := map[Card]int{
|
||||
{Red, Zero}: 2, // two of every number, unlike the normal deck's single zero
|
||||
{Blue, Seven}: 2,
|
||||
{Green, Skip}: 3,
|
||||
{Yellow, SkipAll}: 2,
|
||||
{Red, Reverse}: 4,
|
||||
{Blue, DrawTwo}: 2,
|
||||
{Green, DrawFour}: 2, // the *coloured* +4
|
||||
{Yellow, DiscardAll}: 3,
|
||||
{Wild, WildRevFour}: 8,
|
||||
{Wild, WildDrawSix}: 4,
|
||||
{Wild, WildDrawTen}: 4,
|
||||
{Wild, WildRoulette}: 8,
|
||||
}
|
||||
for c, n := range want {
|
||||
if m[c] != n {
|
||||
t.Errorf("%v %v: got %d, want %d", c.Color, c.Value, m[c], n)
|
||||
}
|
||||
}
|
||||
if m[Card{Wild, WildCard}] != 0 || m[Card{Wild, WildDrawFour}] != 0 {
|
||||
t.Error("the No Mercy deck should print none of the normal wilds")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoMercyCensus is the load-bearing one: 168 cards, each in exactly one place,
|
||||
// checked after every move of a hundred hands played to the end.
|
||||
func TestNoMercyCensus(t *testing.T) {
|
||||
for _, tier := range []Tier{nmDuel(), nmTable(), nmFull()} {
|
||||
for seed := uint64(0); seed < 100; seed++ {
|
||||
s := deal(t, tier, seed)
|
||||
if got := total(census(s)); got != 168 {
|
||||
t.Fatalf("%s seed %d: dealt %d cards, want 168", tier.Slug, seed, got)
|
||||
}
|
||||
rng := rand.New(rand.NewPCG(seed, 99))
|
||||
for moves := 0; s.playing() && moves < 800; moves++ {
|
||||
next, _, err := ApplyMove(s, You, naive(s, rng))
|
||||
if err != nil {
|
||||
t.Fatalf("%s seed %d: %v (phase %s)", tier.Slug, seed, err, s.Phase)
|
||||
}
|
||||
s = next
|
||||
if got := census(s); total(got) != 168 {
|
||||
t.Fatalf("%s seed %d: %d cards after a move, want 168",
|
||||
tier.Slug, seed, total(got))
|
||||
}
|
||||
}
|
||||
if s.playing() {
|
||||
t.Fatalf("%s seed %d: hand never ended", tier.Slug, seed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// naive is a bad-but-real strategy: play the first legal card, take a stack you
|
||||
// can't answer, and draw when you have nothing. Always played from the human seat.
|
||||
func naive(s State, rng *rand.Rand) Move {
|
||||
if s.Phase == PhaseStack {
|
||||
if p := s.Playable(You); len(p) > 0 {
|
||||
return playMove(s, p[0], rng)
|
||||
}
|
||||
return Move{Kind: MoveTake}
|
||||
}
|
||||
if p := s.Playable(You); len(p) > 0 {
|
||||
return playMove(s, p[0], rng)
|
||||
}
|
||||
return Move{Kind: MoveDraw}
|
||||
}
|
||||
|
||||
// stack loads a seat's hand up to n cards by taking them off the deck, so the
|
||||
// table still holds 168 of them.
|
||||
func stack(s *State, seat, n int) {
|
||||
s.Deck = append(s.Deck, s.Hands[seat]...)
|
||||
s.Hands[seat] = nil
|
||||
s.Color = s.top().Color
|
||||
|
||||
kept := make([]Card, 0, len(s.Deck))
|
||||
for _, c := range s.Deck {
|
||||
if len(s.Hands[seat]) < n {
|
||||
s.Hands[seat] = append(s.Hands[seat], c)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, c)
|
||||
}
|
||||
s.Deck = kept
|
||||
}
|
||||
|
||||
func playMove(s State, idx int, rng *rand.Rand) Move {
|
||||
m := Move{Kind: MovePlay, Index: idx}
|
||||
if s.Hands[You][idx].IsWild() {
|
||||
m.Color = Red + Color(rng.IntN(4))
|
||||
}
|
||||
for _, at := range s.UnoAt(You) {
|
||||
if at == idx {
|
||||
m.Uno = true
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TestAStackIsPassedOnAndPaid walks the rule the whole mode turns on: a draw card
|
||||
// opens a bill, and the seat that can't answer pays the whole thing.
|
||||
func TestAStackIsPassedOnAndPaid(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 7)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
|
||||
s.Hands[1] = []Card{{Red, DrawTwo}, {Blue, Nine}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play +2: %v", err)
|
||||
}
|
||||
if next.Phase != PhaseStack {
|
||||
t.Fatalf("phase is %s, want stack: a +2 in No Mercy opens a stack", next.Phase)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Fatalf("the stack came back to seat %d, want you", next.Turn)
|
||||
}
|
||||
if next.Pending != 4 {
|
||||
t.Fatalf("the bill is %d, want 4 (your two, plus the bot's two)", next.Pending)
|
||||
}
|
||||
if !hasKind(evs, EvStack) {
|
||||
t.Error("no stack event: the felt has nothing to show the player")
|
||||
}
|
||||
if _, _, err := ApplyMove(next, You, Move{Kind: MoveDraw}); err != ErrMustStack {
|
||||
t.Errorf("drawing out of a stack: %v, want ErrMustStack", err)
|
||||
}
|
||||
if _, _, err := ApplyMove(next, You, Move{Kind: MovePlay, Index: 0}); err != ErrMustStack {
|
||||
t.Errorf("playing a plain card under a stack: %v, want ErrMustStack", err)
|
||||
}
|
||||
|
||||
before := len(next.Hands[You])
|
||||
paid, evs, err := ApplyMove(next, You, Move{Kind: MoveTake})
|
||||
if err != nil {
|
||||
t.Fatalf("take: %v", err)
|
||||
}
|
||||
var forced int
|
||||
for _, e := range evs {
|
||||
if e.Kind == EvForced && e.Seat == You {
|
||||
forced = e.N
|
||||
}
|
||||
}
|
||||
if forced != 4 {
|
||||
t.Errorf("the stack made you take %d cards, want 4", forced)
|
||||
}
|
||||
if len(paid.Hands[You]) < before+4 {
|
||||
t.Errorf("hand went %d → %d, want at least four more", before, len(paid.Hands[You]))
|
||||
}
|
||||
if paid.Pending == 4 && paid.Phase == PhaseStack {
|
||||
t.Error("the bill you just paid is still standing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMercyKillsThePlayerButNotTheSession: the mercy limit takes the human out of
|
||||
// the *hand* — the pot goes to whoever is left, and the table plays on. It is no
|
||||
// longer game over.
|
||||
func TestMercyKillsThePlayerButNotTheSession(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 3)
|
||||
stack(&s, You, 24)
|
||||
s.Turn = You
|
||||
s.Phase = PhaseStack
|
||||
s.Pending = 10
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveTake})
|
||||
if err != nil {
|
||||
t.Fatalf("take: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvMercy) {
|
||||
t.Fatal("no mercy event: twenty-five cards should have killed the seat")
|
||||
}
|
||||
if next.playing() {
|
||||
t.Fatalf("the hand should be over once one seat is left: phase %s", next.Phase)
|
||||
}
|
||||
if next.live(You) || len(next.Hands[You]) != 0 {
|
||||
t.Error("a dead seat should hold no cards and be out of the hand")
|
||||
}
|
||||
// Heads-up, killing the human leaves the bot alone: it takes the pot.
|
||||
if next.Winner != 1 {
|
||||
t.Errorf("winner is seat %d, want the surviving bot", next.Winner)
|
||||
}
|
||||
if got := total(census(next)); got != 168 {
|
||||
t.Errorf("%d cards after a mercy kill, want 168 — the hand goes back in the deck", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutlivingTheTableWins: the deck buries bots too, and a table with every bot
|
||||
// dead is a pot you have taken.
|
||||
func TestOutlivingTheTableWins(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 11)
|
||||
pot := s.Pot
|
||||
before := s.Seats[You].Stack
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
|
||||
s.Hands[1] = make([]Card, 0, 24)
|
||||
for i := 0; i < 24; i++ {
|
||||
s.Hands[1] = append(s.Hands[1], Card{Blue, Nine}) // nothing it can answer with
|
||||
}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play +2: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvMercy) {
|
||||
t.Fatal("the bot should have died taking the stack")
|
||||
}
|
||||
if next.playing() || next.Winner != You {
|
||||
t.Fatalf("phase %s winner %d, want a finished hand won by you", next.Phase, next.Winner)
|
||||
}
|
||||
profit := pot - s.Tier.Ante
|
||||
wantWon := pot - int64(float64(profit)*rake)
|
||||
if next.Seats[You].Stack != before+wantWon {
|
||||
t.Errorf("your stack is %d, want %d", next.Seats[You].Stack, before+wantWon)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouDrawUntilYouCanPlay(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 5)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Blue, One}} // nothing playable
|
||||
s.Deck = []Card{{Green, Two}, {Yellow, Three}, {Red, Nine}, {Blue, Four}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if len(next.Hands[You]) != 4 {
|
||||
t.Fatalf("hand is %d, want 4: you draw until something plays", len(next.Hands[You]))
|
||||
}
|
||||
if next.Phase != PhaseDrawn {
|
||||
t.Fatalf("phase %s, want drawn: the card you stopped on is one you must play", next.Phase)
|
||||
}
|
||||
if _, _, err := ApplyMove(next, You, Move{Kind: MovePass}); err != ErrMustPlayNow {
|
||||
t.Errorf("passing in No Mercy: %v, want ErrMustPlayNow", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipAllComesBackToYou(t *testing.T) {
|
||||
s := deal(t, nmFull(), 13)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, SkipAll}, {Blue, One}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play skip-all: %v", err)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Errorf("turn went to seat %d, want you: skip-all skips everyone else", next.Turn)
|
||||
}
|
||||
if !hasKind(evs, EvSkipAll) {
|
||||
t.Error("no skipall event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 17)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Blue, Two}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
before := total(census(s))
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Uno: true})
|
||||
if err != nil {
|
||||
t.Fatalf("play discard-all: %v", err)
|
||||
}
|
||||
if len(next.Hands[You]) != 1 {
|
||||
t.Fatalf("hand is %d, want 1: every red should have gone with it", len(next.Hands[You]))
|
||||
}
|
||||
if next.Hands[You][0] != (Card{Blue, Two}) {
|
||||
t.Errorf("kept %v, want the blue two", next.Hands[You][0])
|
||||
}
|
||||
// The discard-all was played, so it is on the pile — the bot may have played over
|
||||
// it since, which is why this looks for it rather than at the very top.
|
||||
found := false
|
||||
for _, c := range next.Discard {
|
||||
if c.Value == DiscardAll {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the discard-all isn't on the pile")
|
||||
}
|
||||
if !hasKind(evs, EvDiscardAll) {
|
||||
t.Error("no discard event")
|
||||
}
|
||||
if got := total(census(next)); got != before {
|
||||
t.Errorf("%d cards, want %d: a dumped colour is buried, not destroyed", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 19)
|
||||
s.Color = Blue
|
||||
s.Discard = []Card{{Blue, Five}}
|
||||
s.Hands[You] = []Card{{Wild, WildRoulette}, {Blue, One}}
|
||||
s.Hands[1] = []Card{{Green, Three}}
|
||||
s.Deck = []Card{{Blue, Two}, {Green, Four}, {Yellow, Six}, {Red, Seven}, {Blue, Eight}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Red})
|
||||
if err != nil {
|
||||
t.Fatalf("play roulette: %v", err)
|
||||
}
|
||||
var got int
|
||||
for _, e := range evs {
|
||||
if e.Kind == EvRoulette {
|
||||
got = e.N
|
||||
}
|
||||
}
|
||||
if got != 4 {
|
||||
t.Errorf("flipped %d, want 4 — up to and including the first red", got)
|
||||
}
|
||||
if n := len(next.Hands[1]); n != 5 {
|
||||
t.Errorf("the bot holds %d, want 5", n)
|
||||
}
|
||||
if total(census(next)) != total(census(s)) {
|
||||
t.Error("the roulette lost a card")
|
||||
}
|
||||
}
|
||||
1320
internal/games/uno/uno.go
Normal file
1320
internal/games/uno/uno.go
Normal file
File diff suppressed because it is too large
Load Diff
807
internal/games/uno/uno_test.go
Normal file
807
internal/games/uno/uno_test.go
Normal file
@@ -0,0 +1,807 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const rake = 0.05
|
||||
|
||||
// You is the human's seat in the solo tests, a test-local alias for what the
|
||||
// engine no longer hardcodes: a table is a list of seats, and seat zero being the
|
||||
// human is a convention only these fixtures keep.
|
||||
const You = 0
|
||||
|
||||
func duel() Tier { t, _ := TierBySlug("duel"); return t }
|
||||
func full() Tier { t, _ := TierBySlug("full"); return t }
|
||||
func table() Tier { t, _ := TierBySlug("table"); return t }
|
||||
|
||||
// openSolo opens a solo table (one human, the tier's bots) without dealing.
|
||||
func openSolo(t *testing.T, tier Tier, seed uint64) State {
|
||||
t.Helper()
|
||||
s, _, err := New(tier, SoloSeats(tier, tier.Bots, 1000), rake, seed, 0xC0FFEE)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// deal opens a solo table and deals the first hand, so the human is to act.
|
||||
func deal(t *testing.T, tier Tier, seed uint64) State {
|
||||
t.Helper()
|
||||
s := openSolo(t, tier, seed)
|
||||
s, evs, err := ApplyMove(s, You, Move{Kind: MoveDeal})
|
||||
if err != nil {
|
||||
t.Fatalf("deal: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvDeal) {
|
||||
t.Fatalf("the deal emitted no deal event: %+v", evs)
|
||||
}
|
||||
if s.Turn != You {
|
||||
t.Fatalf("the human should act first at a solo table, turn is %d", s.Turn)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// census counts every card in the game, wherever it is. It is the invariant the
|
||||
// whole engine has to hold: 108 cards, each of them in exactly one place.
|
||||
func census(s State) map[Card]int {
|
||||
m := map[Card]int{}
|
||||
for _, h := range s.Hands {
|
||||
for _, c := range h {
|
||||
m[c]++
|
||||
}
|
||||
}
|
||||
for _, c := range s.Deck {
|
||||
m[c]++
|
||||
}
|
||||
for _, c := range s.Discard {
|
||||
if c.Value.Wild() {
|
||||
c.Color = Wild
|
||||
}
|
||||
m[c]++
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func total(m map[Card]int) int {
|
||||
n := 0
|
||||
for _, v := range m {
|
||||
n += v
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestNewDeckIsADeck(t *testing.T) {
|
||||
m := census(State{Deck: NewDeck()})
|
||||
if got := total(m); got != 108 {
|
||||
t.Fatalf("deck has %d cards, want 108", got)
|
||||
}
|
||||
if m[Card{Red, Zero}] != 1 {
|
||||
t.Errorf("want one red zero, got %d", m[Card{Red, Zero}])
|
||||
}
|
||||
if m[Card{Blue, Seven}] != 2 {
|
||||
t.Errorf("want two blue sevens, got %d", m[Card{Blue, Seven}])
|
||||
}
|
||||
if m[Card{Wild, WildDrawFour}] != 4 {
|
||||
t.Errorf("want four +4s, got %d", m[Card{Wild, WildDrawFour}])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeals(t *testing.T) {
|
||||
s := deal(t, full(), 7)
|
||||
if len(s.Hands) != 4 {
|
||||
t.Fatalf("full house is four seats, got %d", len(s.Hands))
|
||||
}
|
||||
for i, h := range s.Hands {
|
||||
if len(h) != HandSize {
|
||||
t.Errorf("seat %d holds %d cards, want %d", i, len(h), HandSize)
|
||||
}
|
||||
}
|
||||
bots := 0
|
||||
for _, seat := range s.Seats {
|
||||
if seat.Bot {
|
||||
bots++
|
||||
if seat.Name == "" {
|
||||
t.Error("a bot seat has no name")
|
||||
}
|
||||
}
|
||||
}
|
||||
if bots != 3 {
|
||||
t.Fatalf("want three bots, got %d", bots)
|
||||
}
|
||||
if got := total(census(s)); got != 108 {
|
||||
t.Fatalf("the deal lost cards: %d of 108", got)
|
||||
}
|
||||
// Every seat anted, so the pot is one ante per seat.
|
||||
if want := s.Tier.Ante * int64(len(s.Seats)); s.Pot != want {
|
||||
t.Errorf("pot is %d, want %d (one ante each)", s.Pot, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The card turned over to start is never an action card — see dealHand.
|
||||
func TestOpeningCardIsANumber(t *testing.T) {
|
||||
for seed := uint64(0); seed < 300; seed++ {
|
||||
s := deal(t, table(), seed)
|
||||
if s.Top().Value.Action() {
|
||||
t.Fatalf("seed %d opened on %v", seed, s.Top())
|
||||
}
|
||||
if s.Color != s.Top().Color {
|
||||
t.Fatalf("seed %d: colour in play is %v, top card is %v", seed, s.Color, s.Top())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the rules ------------------------------------------------------------
|
||||
|
||||
// rig builds a live hand by hand, so a rule can be tested without hunting a seed
|
||||
// that happens to deal it. Every seat has anted, so the pot is set and a win
|
||||
// settles for real.
|
||||
func rig(hands [][]Card, top Card, color Color) State {
|
||||
left := map[Card]int{}
|
||||
for _, c := range NewDeck() {
|
||||
left[c]++
|
||||
}
|
||||
take := func(c Card) {
|
||||
if c.IsWild() {
|
||||
c.Color = Wild
|
||||
}
|
||||
left[c]--
|
||||
}
|
||||
for _, h := range hands {
|
||||
for _, c := range h {
|
||||
take(c)
|
||||
}
|
||||
}
|
||||
take(top)
|
||||
|
||||
var deck []Card
|
||||
for _, c := range NewDeck() {
|
||||
if left[c] > 0 {
|
||||
left[c]--
|
||||
deck = append(deck, c)
|
||||
}
|
||||
}
|
||||
ante := full().Ante
|
||||
seats := make([]Seat, len(hands))
|
||||
for i := range seats {
|
||||
// The stack is what is left after this seat anted: a real deal moves the ante
|
||||
// out of the stack and into the pot, so a refund or a win returns it here.
|
||||
seats[i] = Seat{Name: botPool[i], Bot: i != You, Stack: 1000 - ante, Ante: ante}
|
||||
}
|
||||
seats[You].Name = "You"
|
||||
return State{
|
||||
Tier: full(), Seats: seats, Hands: hands, Discard: []Card{top}, Color: color,
|
||||
Deck: deck, Dir: 1, Turn: You, Dealer: len(hands) - 1, Phase: PhasePlay,
|
||||
Pot: ante * int64(len(hands)), Winner: -1,
|
||||
Out: make([]bool, len(hands)), Called: make([]bool, len(hands)),
|
||||
RakePct: rake, Seed1: 1, Seed2: 2,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayMustMatch(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Red, Five}}}, Card{Red, Nine}, Red)
|
||||
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}); err != ErrCantPlay {
|
||||
t.Fatalf("a blue 3 on a red 9 should be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayMatchesFaceOrColor(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Nine}, {Red, Two}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("a blue 9 on a red 9 is legal: %v", err)
|
||||
}
|
||||
if next.Color != Blue {
|
||||
t.Errorf("colour in play should follow the card: %v", next.Color)
|
||||
}
|
||||
if evs[0].Kind != EvPlay || evs[0].Seat != You {
|
||||
t.Errorf("first event should be your play, got %+v", evs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWildNeedsAColor(t *testing.T) {
|
||||
s := rig([][]Card{{{Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor {
|
||||
t.Fatalf("a wild with no colour should be refused, got %v", err)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Wild}); err != ErrNeedColor {
|
||||
t.Fatalf("naming 'wild' is not naming a colour, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWildNamesTheColor(t *testing.T) {
|
||||
s := rig([][]Card{{{Wild, WildCard}, {Green, One}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Green})
|
||||
if err != nil {
|
||||
t.Fatalf("play wild: %v", err)
|
||||
}
|
||||
top := next.Discard
|
||||
if len(top) < 2 {
|
||||
t.Fatalf("expected the wild and the bot's card on the pile: %v", top)
|
||||
}
|
||||
if top[1] != (Card{Green, WildCard}) {
|
||||
t.Errorf("the wild should sit on the pile as green, got %v", top[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawTwoHitsTheNextSeat(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, DrawTwo}, {Red, One}}, {{Blue, Five}, {Blue, Six}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play +2: %v", err)
|
||||
}
|
||||
if len(next.Hands[1]) != 4 {
|
||||
t.Errorf("the bot should hold 2 + 2 = 4 cards, got %d", len(next.Hands[1]))
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Errorf("the bot was skipped, so it should be your turn: %d", next.Turn)
|
||||
}
|
||||
if !hasKind(evs, EvForced) || !hasKind(evs, EvSkip) {
|
||||
t.Errorf("a +2 is a forced draw and a skip: %+v", evs)
|
||||
}
|
||||
if got := total(census(next)); got != 108 {
|
||||
t.Fatalf("the +2 lost cards: %d of 108", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseIsASkipHeadsUp(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Reverse}, {Red, One}}, {{Blue, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play reverse: %v", err)
|
||||
}
|
||||
if next.Dir != 1 {
|
||||
t.Errorf("with two at the table a reverse doesn't turn the table around: dir %d", next.Dir)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Errorf("the bot should have been skipped, turn is %d", next.Turn)
|
||||
}
|
||||
if !hasKind(evs, EvSkip) || hasKind(evs, EvReverse) {
|
||||
t.Errorf("heads up, a reverse reads as a skip: %+v", evs)
|
||||
}
|
||||
if len(next.Hands[1]) != 1 {
|
||||
t.Errorf("the bot never played, so it still holds one card: %d", len(next.Hands[1]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseTurnsTheTableAround(t *testing.T) {
|
||||
s := rig([][]Card{
|
||||
{{Red, Reverse}, {Red, One}},
|
||||
{{Red, Five}, {Blue, Six}},
|
||||
{{Red, Six}, {Green, Six}},
|
||||
{{Red, Seven}, {Yellow, Six}},
|
||||
}, Card{Red, Nine}, Red)
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play reverse: %v", err)
|
||||
}
|
||||
if next.Dir != -1 {
|
||||
t.Errorf("four at the table: a reverse turns it around, dir %d", next.Dir)
|
||||
}
|
||||
if !hasKind(evs, EvReverse) {
|
||||
t.Errorf("want a reverse event: %+v", evs)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Errorf("the bots should have played round to you, turn is %d", next.Turn)
|
||||
}
|
||||
var order []int
|
||||
for _, e := range evs {
|
||||
if e.Kind == EvPlay && e.Seat != You {
|
||||
order = append(order, e.Seat)
|
||||
}
|
||||
}
|
||||
if len(order) != 3 || order[0] != 3 || order[1] != 2 || order[2] != 1 {
|
||||
t.Errorf("the bots played in the order %v, want [3 2 1]", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipSkips(t *testing.T) {
|
||||
s := rig([][]Card{
|
||||
{{Red, Skip}, {Red, One}},
|
||||
{{Red, Five}, {Blue, Six}},
|
||||
{{Red, Six}, {Green, Six}},
|
||||
}, Card{Red, Nine}, Red)
|
||||
s.Tier = table()
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play skip: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvSkip) {
|
||||
t.Errorf("want a skip event: %+v", evs)
|
||||
}
|
||||
for _, e := range evs {
|
||||
if e.Kind == EvPlay && e.Seat == 1 {
|
||||
t.Errorf("seat 1 was skipped and should not have played: %+v", e)
|
||||
}
|
||||
}
|
||||
if len(next.Hands[1]) != 2 {
|
||||
t.Errorf("seat 1 was skipped and should still hold two: %d", len(next.Hands[1]))
|
||||
}
|
||||
if len(next.Hands[2]) != 1 {
|
||||
t.Errorf("seat 2 was not skipped and should have played: %d", len(next.Hands[2]))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- drawing --------------------------------------------------------------
|
||||
|
||||
func TestDrawnPlayableWaitsForYou(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Deck = []Card{{Red, Four}} // exactly what you'll draw, and it plays
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if next.Phase != PhaseDrawn {
|
||||
t.Fatalf("a playable draw should stop and ask, phase is %v", next.Phase)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Fatalf("the turn should still be yours: %d", next.Turn)
|
||||
}
|
||||
if evs[0].Kind != EvDraw || evs[0].Card == nil || *evs[0].Card != (Card{Red, Four}) {
|
||||
t.Fatalf("your own drawn card comes face up: %+v", evs[0])
|
||||
}
|
||||
if got := next.Playable(You); len(got) != 1 || got[0] != 1 {
|
||||
t.Errorf("the drawn card, and only it, is playable: %v", got)
|
||||
}
|
||||
if _, _, err := ApplyMove(next, You, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow {
|
||||
t.Fatalf("only the drawn card may be played, got %v", err)
|
||||
}
|
||||
if _, _, err := ApplyMove(next, You, Move{Kind: MoveDraw}); err != ErrMustPlayNow {
|
||||
t.Fatalf("you can't draw twice, got %v", err)
|
||||
}
|
||||
after, _, err := ApplyMove(next, You, Move{Kind: MovePass})
|
||||
if err != nil {
|
||||
t.Fatalf("pass: %v", err)
|
||||
}
|
||||
if after.Phase != PhasePlay || after.Turn != You {
|
||||
t.Fatalf("after passing the bot plays and it comes back to you: phase %v turn %d", after.Phase, after.Turn)
|
||||
}
|
||||
if len(after.Hands[You]) != 2 {
|
||||
t.Errorf("you kept the card you drew: %d", len(after.Hands[You]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnplayableDrawPassesTheTurn(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Deck = []Card{{Blue, Four}, {Red, Two}} // draw a blue 4: it doesn't go on a red 9
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if next.Phase != PhasePlay {
|
||||
t.Errorf("nothing to decide, so no pause: %v", next.Phase)
|
||||
}
|
||||
if !hasKind(evs, EvPass) {
|
||||
t.Errorf("the turn passed, and the table should be told: %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassOnlyAfterADraw(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
if _, _, err := ApplyMove(s, You, Move{Kind: MovePass}); err != ErrCantPass {
|
||||
t.Fatalf("you can't pass a turn you haven't drawn on, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// dead is a table nobody can move at: the deck is spent, the discard is one card
|
||||
// deep, and not a seat holds a card that goes on the pile.
|
||||
func dead(hands [][]Card) State {
|
||||
s := rig(hands, Card{Red, Nine}, Red)
|
||||
s.Deck = nil
|
||||
return s
|
||||
}
|
||||
|
||||
// A dead table ends the hand rather than passing the turn round forever. It no
|
||||
// longer ends the *session* — a shared table plays another hand — so it lands on
|
||||
// PhaseHandOver, not PhaseDone.
|
||||
func TestDeadTableEnds(t *testing.T) {
|
||||
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}}) // level: one card each
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if next.playing() {
|
||||
t.Fatalf("nobody can move and there is nothing to draw: the hand is over, not %q", next.Phase)
|
||||
}
|
||||
if next.Phase != PhaseHandOver {
|
||||
t.Fatalf("a dead hand at a shared table returns to handover, not %q", next.Phase)
|
||||
}
|
||||
if next.Outcome != OutcomeTie {
|
||||
t.Errorf("level on the shortest hand is a tie, got %q", next.Outcome)
|
||||
}
|
||||
// A tie hands the antes back: every seat is whole again.
|
||||
for i := range next.Seats {
|
||||
if next.Seats[i].Stack != 1000 {
|
||||
t.Errorf("seat %d wasn't refunded: stack %d, want 1000", i, next.Seats[i].Stack)
|
||||
}
|
||||
}
|
||||
if !hasKind(evs, EvSettle) {
|
||||
t.Errorf("the table has to be told the hand is over: %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
// And the shortest hand takes the pot, which is the one way a stuck table pays.
|
||||
func TestDeadTablePaysTheShortestHand(t *testing.T) {
|
||||
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}, {Green, Six}}})
|
||||
pot := s.Pot
|
||||
before := s.Seats[You].Stack
|
||||
|
||||
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if next.Outcome != OutcomeStuck || next.Winner != You {
|
||||
t.Fatalf("one card against two is a win for you: outcome %q winner %d", next.Outcome, next.Winner)
|
||||
}
|
||||
profit := pot - s.Tier.Ante
|
||||
wantRake := int64(float64(profit) * rake)
|
||||
wantWon := pot - wantRake
|
||||
if next.Seats[You].Won != wantWon {
|
||||
t.Errorf("you took %d, want %d (pot %d less rake %d)", next.Seats[You].Won, wantWon, pot, wantRake)
|
||||
}
|
||||
if next.Seats[You].Stack != before+wantWon {
|
||||
t.Errorf("your stack is %d, want %d", next.Seats[You].Stack, before+wantWon)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReshuffleRebuildsTheDeck(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Deck = nil
|
||||
s.Discard = []Card{{Green, WildCard}, {Red, Two}, {Red, Nine}}
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw on an empty deck: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvReshuffle) {
|
||||
t.Fatalf("want a reshuffle: %+v", evs)
|
||||
}
|
||||
if len(next.Discard) == 0 || next.Discard[0] != (Card{Red, Nine}) {
|
||||
t.Errorf("the card in play stays on the pile: %v", next.Discard)
|
||||
}
|
||||
for _, c := range next.Deck {
|
||||
if c.Value == WildCard && c.Color != Wild {
|
||||
t.Errorf("a wild went back into the deck stamped %v", c.Color)
|
||||
}
|
||||
}
|
||||
for _, h := range next.Hands {
|
||||
for _, c := range h {
|
||||
if c.Value == WildCard && c.Color != Wild {
|
||||
t.Errorf("a wild was dealt out stamped %v", c.Color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the pot --------------------------------------------------------------
|
||||
|
||||
// The winner takes the pot, and the house's rake comes out of the winnings, never
|
||||
// out of a seat's own ante.
|
||||
func TestWinnerTakesThePotLessRake(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}, {{Blue, One}, {Blue, Two}}}, Card{Red, Nine}, Red)
|
||||
pot := s.Pot
|
||||
before := s.Seats[You].Stack
|
||||
|
||||
next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) // your last card
|
||||
if err != nil {
|
||||
t.Fatalf("go out: %v", err)
|
||||
}
|
||||
if next.Outcome != OutcomeWon || next.Winner != You {
|
||||
t.Fatalf("playing your last card wins: outcome %q winner %d", next.Outcome, next.Winner)
|
||||
}
|
||||
profit := pot - s.Tier.Ante
|
||||
wantRake := int64(float64(profit) * rake)
|
||||
wantWon := pot - wantRake
|
||||
if next.Rake != wantRake {
|
||||
t.Errorf("rake %d, want %d — and never a penny of an ante", next.Rake, wantRake)
|
||||
}
|
||||
if next.LastPot != pot {
|
||||
t.Errorf("last pot %d, want %d", next.LastPot, pot)
|
||||
}
|
||||
if next.Seats[You].Stack != before+wantWon {
|
||||
t.Errorf("your stack is %d, want %d (+%d)", next.Seats[You].Stack, before+wantWon, wantWon)
|
||||
}
|
||||
if next.Paid != wantRake {
|
||||
t.Errorf("the session rake tally is %d, want %d", next.Paid, wantRake)
|
||||
}
|
||||
}
|
||||
|
||||
// A bot winning rakes nothing: the house already keeps the whole pot when its own
|
||||
// seat takes it, so there is nothing to charge.
|
||||
func TestABotWinningRakesNothing(t *testing.T) {
|
||||
// The bot at seat 1 holds one card that plays; it goes out the moment the turn
|
||||
// reaches it.
|
||||
s := rig([][]Card{{{Red, Three}, {Red, Four}}, {{Red, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
pot := s.Pot
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play: %v", err)
|
||||
}
|
||||
if next.Outcome != OutcomeWon || next.Winner != 1 {
|
||||
t.Fatalf("the bot went out: outcome %q winner %d", next.Outcome, next.Winner)
|
||||
}
|
||||
if next.Rake != 0 {
|
||||
t.Errorf("a bot winning rakes nothing, got %d", next.Rake)
|
||||
}
|
||||
if next.Seats[1].Won != pot {
|
||||
t.Errorf("the bot took %d, want the whole pot %d", next.Seats[1].Won, pot)
|
||||
}
|
||||
// You anted and lost it: your stack is down exactly one ante.
|
||||
if next.Seats[You].Stack != 1000-s.Tier.Ante {
|
||||
t.Errorf("your stack is %d, want %d (one ante gone)", next.Seats[You].Stack, 1000-s.Tier.Ante)
|
||||
}
|
||||
last := evs[len(evs)-1]
|
||||
if last.Kind != EvSettle || last.Seat != 1 {
|
||||
t.Errorf("the settle should name the winner: %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// A hand ending returns the table to handover, ready to deal again — it does not
|
||||
// take a hand move.
|
||||
func TestNoHandMoveBetweenHands(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
over, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("go out: %v", err)
|
||||
}
|
||||
if over.Phase != PhaseHandOver {
|
||||
t.Fatalf("a finished hand returns to handover, got %q", over.Phase)
|
||||
}
|
||||
if _, _, err := ApplyMove(over, You, Move{Kind: MoveDraw}); err != ErrNoHand {
|
||||
t.Fatalf("no hand is in progress between hands, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// You can deal the next hand, ante again, and play on — the session shape.
|
||||
func TestDealTheNextHand(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
over, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("go out: %v", err)
|
||||
}
|
||||
again, evs, err := ApplyMove(over, You, Move{Kind: MoveDeal})
|
||||
if err != nil {
|
||||
t.Fatalf("deal the next hand: %v", err)
|
||||
}
|
||||
if again.HandNo != over.HandNo+1 {
|
||||
t.Errorf("hand number didn't advance: %d then %d", over.HandNo, again.HandNo)
|
||||
}
|
||||
if !again.playing() {
|
||||
t.Fatalf("the next hand should be live, phase %q", again.Phase)
|
||||
}
|
||||
if !hasKind(evs, EvAnte) || !hasKind(evs, EvDeal) {
|
||||
t.Errorf("the deal antes and turns a card: %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadBuyIn(t *testing.T) {
|
||||
if _, _, err := New(duel(), SoloSeats(duel(), 1, 10), rake, 1, 2); err != ErrBadBuyIn {
|
||||
t.Fatalf("a buy-in under the minimum should be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the whole game -------------------------------------------------------
|
||||
|
||||
// playOut plays one hand to its end with a simple strategy: play the first legal
|
||||
// card, take a stack you can't answer, otherwise draw, otherwise pass.
|
||||
func playOut(t *testing.T, s State, maxTurns int) State {
|
||||
t.Helper()
|
||||
for turn := 0; s.playing(); turn++ {
|
||||
if turn > maxTurns {
|
||||
t.Fatalf("the hand never ended in %d turns", maxTurns)
|
||||
}
|
||||
if s.Turn != You {
|
||||
t.Fatalf("ApplyMove left the turn with seat %d — the bots should always run out", s.Turn)
|
||||
}
|
||||
|
||||
var m Move
|
||||
if p := s.Playable(You); len(p) > 0 {
|
||||
m = Move{Kind: MovePlay, Index: p[0]}
|
||||
if s.Hands[You][p[0]].IsWild() {
|
||||
m.Color = Green
|
||||
}
|
||||
} else if s.Phase == PhaseStack {
|
||||
m = Move{Kind: MoveTake}
|
||||
} else if s.Phase == PhaseDrawn {
|
||||
m = Move{Kind: MovePass}
|
||||
} else {
|
||||
m = Move{Kind: MoveDraw}
|
||||
}
|
||||
|
||||
next, evs, err := ApplyMove(s, You, m)
|
||||
if err != nil {
|
||||
t.Fatalf("turn %d: %v (move %+v, phase %v)", turn, err, m, s.Phase)
|
||||
}
|
||||
if len(evs) == 0 {
|
||||
t.Fatalf("turn %d: a move that happened emitted nothing", turn)
|
||||
}
|
||||
if got := total(census(next)); got != 108 {
|
||||
t.Fatalf("turn %d: %d cards of 108 — a card was lost or minted", turn, got)
|
||||
}
|
||||
for c, n := range census(next) {
|
||||
if want := deckCount(c); n != want {
|
||||
t.Fatalf("turn %d: %d of %v, want %d — a card was duplicated", turn, n, c, want)
|
||||
}
|
||||
}
|
||||
s = next
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// deckCount is how many of a given card a 108 deck holds.
|
||||
func deckCount(c Card) int {
|
||||
switch {
|
||||
case c.Color == Wild:
|
||||
return 4
|
||||
case c.Value == Zero:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// A hundred hands, played out, with the invariants checked at every step.
|
||||
func TestGamesPlayOut(t *testing.T) {
|
||||
yous, others, ties := 0, 0, 0
|
||||
for seed := uint64(0); seed < 100; seed++ {
|
||||
tier := Tiers[seed%3]
|
||||
end := playOut(t, deal(t, tier, seed), 500)
|
||||
if end.Phase != PhaseHandOver {
|
||||
t.Fatalf("seed %d ended in phase %q", seed, end.Phase)
|
||||
}
|
||||
switch {
|
||||
case end.Winner == You:
|
||||
yous++
|
||||
case end.Winner >= 0:
|
||||
others++
|
||||
case end.Outcome == OutcomeTie:
|
||||
ties++
|
||||
default:
|
||||
t.Fatalf("seed %d ended with winner %d outcome %q", seed, end.Winner, end.Outcome)
|
||||
}
|
||||
if end.Winner >= 0 && end.Outcome == OutcomeWon && len(end.Hands[end.Winner]) != 0 {
|
||||
t.Fatalf("seed %d: the winner is still holding cards", seed)
|
||||
}
|
||||
}
|
||||
if yous == 0 || others == 0 {
|
||||
t.Fatalf("100 hands gave %d to you, %d to others, %d tied — one side never happens", yous, others, ties)
|
||||
}
|
||||
t.Logf("100 hands: %d to you, %d to others, %d tied", yous, others, ties)
|
||||
}
|
||||
|
||||
// The same seed deals the same hand and the bots make the same choices.
|
||||
func TestReplaysFromTheSeed(t *testing.T) {
|
||||
a := playOut(t, deal(t, full(), 42), 500)
|
||||
b := playOut(t, deal(t, full(), 42), 500)
|
||||
|
||||
ja, _ := json.Marshal(a)
|
||||
jb, _ := json.Marshal(b)
|
||||
if string(ja) != string(jb) {
|
||||
t.Fatal("the same seed played the same way gave two different games")
|
||||
}
|
||||
if a.Winner < 0 && a.Outcome != OutcomeTie {
|
||||
t.Fatal("the replay didn't finish")
|
||||
}
|
||||
}
|
||||
|
||||
// A game in progress survives a redeploy: it round-trips through its JSON.
|
||||
func TestStateSurvivesStorage(t *testing.T) {
|
||||
s := deal(t, table(), 9)
|
||||
s, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
|
||||
blob, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var back State
|
||||
if err := json.Unmarshal(blob, &back); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
again, _ := json.Marshal(back)
|
||||
if string(again) != string(blob) {
|
||||
t.Fatal("a stored game came back different")
|
||||
}
|
||||
playOut(t, back, 500)
|
||||
}
|
||||
|
||||
// A move the engine refuses leaves the caller's state exactly as it was.
|
||||
func TestARefusedMoveChangesNothing(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}, {Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
before, _ := json.Marshal(s)
|
||||
|
||||
for _, m := range []Move{
|
||||
{Kind: MovePlay, Index: 0}, // doesn't match
|
||||
{Kind: MovePlay, Index: 1}, // wild with no colour
|
||||
{Kind: MovePlay, Index: 9}, // no such card
|
||||
{Kind: MovePass}, // nothing drawn
|
||||
{Kind: "shuffle-the-deck-in-my-favour"}, // no
|
||||
} {
|
||||
if _, _, err := ApplyMove(s, You, m); err == nil {
|
||||
t.Fatalf("%+v should have been refused", m)
|
||||
}
|
||||
}
|
||||
after, _ := json.Marshal(s)
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("a refused move touched the state")
|
||||
}
|
||||
}
|
||||
|
||||
// The bots choose. Two different seeds should not play the same game.
|
||||
func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) {
|
||||
same := 0
|
||||
for seed := uint64(0); seed < 20; seed++ {
|
||||
a := playOut(t, deal(t, duel(), seed), 500)
|
||||
b := playOut(t, deal(t, duel(), seed+1000), 500)
|
||||
if len(a.Discard) == len(b.Discard) {
|
||||
same++
|
||||
}
|
||||
}
|
||||
if same == 20 {
|
||||
t.Fatal("every seed played out to the same length — the bots aren't choosing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotSavesTheDrawFour(t *testing.T) {
|
||||
hand := []Card{{Wild, WildDrawFour}, {Red, Five}}
|
||||
top, color := Card{Red, Nine}, Red
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
|
||||
held := 0
|
||||
for i := 0; i < 50; i++ {
|
||||
if _, idx := botPick(hand, top, color, 5, rng); idx == 1 {
|
||||
held++
|
||||
}
|
||||
}
|
||||
if held < 30 {
|
||||
t.Errorf("with the table comfortable the bot should mostly play the red 5, held %d/50", held)
|
||||
}
|
||||
|
||||
reached := 0
|
||||
for i := 0; i < 50; i++ {
|
||||
if _, idx := botPick(hand, top, color, 1, rng); idx == 0 {
|
||||
reached++
|
||||
}
|
||||
}
|
||||
if reached < 30 {
|
||||
t.Errorf("with a player on one card the bot should mostly play the +4, reached %d/50", reached)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotPicksItsBestColor(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(3, 4))
|
||||
hand := []Card{{Blue, One}, {Blue, Two}, {Green, Three}, {Wild, WildCard}}
|
||||
if got := botColor(hand, rng); got != Blue {
|
||||
t.Errorf("the bot holds two blues: it should call blue, got %v", got)
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
if got := botColor([]Card{{Wild, WildCard}}, rng); !got.Playable() {
|
||||
t.Fatalf("botColor named %v, which is not a colour", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotHasNothingToPlay(t *testing.T) {
|
||||
if _, idx := botPick([]Card{{Blue, Three}}, Card{Red, Nine}, Red, 3, rand.New(rand.NewPCG(1, 1))); idx != -1 {
|
||||
t.Errorf("a hand with nothing legal should report -1, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func hasKind(evs []Event, kind string) bool {
|
||||
for _, e := range evs {
|
||||
if e.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -4,13 +4,23 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"pete/internal/safehttp"
|
||||
)
|
||||
|
||||
// articleBodyMax bounds how much of an article response goquery will
|
||||
// ever buffer. ~5 MiB comfortably fits any real news article while
|
||||
// preventing a hostile origin from OOMing the process by streaming an
|
||||
// endless body within the request timeout window.
|
||||
const articleBodyMax = 5 << 20
|
||||
|
||||
// resolveURL turns a possibly-relative URL into an absolute one using
|
||||
// the base URL. Returns the raw input on parse failure.
|
||||
func resolveURL(base, ref string) string {
|
||||
@@ -55,11 +65,17 @@ const googlebotUA = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.
|
||||
// ArticleMeta is what we can learn from fetching an article page directly.
|
||||
type ArticleMeta struct {
|
||||
ImageURL string // og:image or twitter:image, absolute URL
|
||||
BodyText string // extracted visible article body text (capped at MaxBodyChars)
|
||||
BodyChars int // length of extracted visible body text
|
||||
Fetched bool // true if we got an HTTP 200 with HTML
|
||||
FetchError bool // true if the fetch failed at the network/HTTP layer
|
||||
Paywalled bool // true if the page explicitly declares gated access
|
||||
Status int // last HTTP status seen (0 on transport error)
|
||||
// SubscriberOnly is true when the source publishes the article in a
|
||||
// form that genuinely has no public version — no archive snapshot, no
|
||||
// bypass UA, nothing. Callers should drop these from view entirely
|
||||
// rather than stamp them as paywalled.
|
||||
SubscriberOnly bool
|
||||
Status int // last HTTP status seen (0 on transport error)
|
||||
}
|
||||
|
||||
// Gated reports whether the response carries a strong gating signal: an
|
||||
@@ -73,7 +89,7 @@ func (m ArticleMeta) Gated() bool {
|
||||
return m.Status == http.StatusPaymentRequired || m.Status == http.StatusForbidden
|
||||
}
|
||||
|
||||
var articleClient = &http.Client{Timeout: 12 * time.Second}
|
||||
var articleClient = safehttp.NewClient(12 * time.Second)
|
||||
|
||||
// FetchArticleMeta fetches an article URL with the default UA. If the result
|
||||
// looks gated (explicit paywall signal, HTTP 402/403, or body too short) it
|
||||
@@ -98,6 +114,9 @@ func shouldRetryAsBot(m ArticleMeta) bool {
|
||||
if m.FetchError {
|
||||
return false // transport failure won't be fixed by a different UA
|
||||
}
|
||||
if m.SubscriberOnly {
|
||||
return false // genuinely subscriber-only; no UA changes that
|
||||
}
|
||||
if m.Gated() {
|
||||
return true
|
||||
}
|
||||
@@ -152,26 +171,94 @@ func fetchArticleMetaOnce(articleURL, ua, referer string) ArticleMeta {
|
||||
|
||||
meta := ArticleMeta{Status: resp.StatusCode}
|
||||
// 402 is unambiguous gating; 403 often is too (e.g. NYT-style hard wall).
|
||||
// We still try to parse the body for whatever signals it contains.
|
||||
// But many sites 403 scrapers via Cloudflare while serving humans fine —
|
||||
// peek at the body and treat a Cloudflare challenge as a transport failure
|
||||
// rather than a paywall, so we don't stamp readable articles.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusServiceUnavailable {
|
||||
if isCloudflareChallenge(resp) {
|
||||
return ArticleMeta{FetchError: true}
|
||||
}
|
||||
}
|
||||
if resp.StatusCode == http.StatusPaymentRequired {
|
||||
meta.Paywalled = true
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
doc, err := goquery.NewDocumentFromReader(safehttp.LimitedBody(resp.Body, articleBodyMax))
|
||||
if err != nil {
|
||||
return meta
|
||||
}
|
||||
|
||||
meta.Fetched = true
|
||||
meta.ImageURL = extractOGImage(doc, articleURL)
|
||||
meta.BodyChars = extractBodyChars(doc)
|
||||
// Paywall detection reads <script type="application/ld+json"> and the LWN
|
||||
// marker, so run it before we strip non-content nodes below.
|
||||
meta.Paywalled = detectPaywall(doc)
|
||||
meta.SubscriberOnly = detectSubscriberOnly(articleURL, doc)
|
||||
// Strip <script>/<style>/etc. before pulling body text — goquery's .Text()
|
||||
// includes the source of inline scripts (e.g. Datawrapper embed resizers),
|
||||
// which would otherwise leak verbatim into reader mode.
|
||||
stripNonContent(doc)
|
||||
// One body extraction serves both purposes: the text feeds reader mode and
|
||||
// its length is the paywall/thin-body signal.
|
||||
meta.BodyText = extractBodyText(doc)
|
||||
meta.BodyChars = len(meta.BodyText)
|
||||
return meta
|
||||
}
|
||||
|
||||
// detectSubscriberOnly returns true for sources known to publish articles
|
||||
// with no public version reachable by any workaround we have (archive
|
||||
// snapshots, bypass UAs, etc). Currently only LWN's subscriber-only
|
||||
// articles, which display a fixed marker in the article body.
|
||||
func detectSubscriberOnly(articleURL string, doc *goquery.Document) bool {
|
||||
u, err := url.Parse(articleURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if host != "lwn.net" && !strings.HasSuffix(host, ".lwn.net") {
|
||||
return false
|
||||
}
|
||||
body := strings.ToLower(doc.Find("body").Text())
|
||||
return strings.Contains(body, "available only to lwn subscribers")
|
||||
}
|
||||
|
||||
// isCloudflareChallenge reports whether a non-200 response is Cloudflare's
|
||||
// bot-block or JS-challenge page rather than a real publisher gate. The
|
||||
// signatures are stable: cf-* response headers, the cf-error-details body
|
||||
// markup, and a handful of recognizable titles. We read at most 8 KiB so a
|
||||
// huge body can't stall the poller.
|
||||
func isCloudflareChallenge(resp *http.Response) bool {
|
||||
if resp == nil {
|
||||
return false
|
||||
}
|
||||
if resp.Header.Get("Server") == "cloudflare" || resp.Header.Get("CF-RAY") != "" || resp.Header.Get("cf-mitigated") != "" {
|
||||
// Header alone isn't enough — plenty of real publishers sit behind
|
||||
// Cloudflare. Confirm with a body marker below.
|
||||
}
|
||||
buf, err := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
s := strings.ToLower(string(buf))
|
||||
markers := []string{
|
||||
"/cdn-cgi/styles/cf",
|
||||
"cf-error-details",
|
||||
"attention required! | cloudflare",
|
||||
"<title>just a moment",
|
||||
"sorry, you have been blocked",
|
||||
"cloudflare ray id",
|
||||
}
|
||||
for _, m := range markers {
|
||||
if strings.Contains(s, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectPaywall checks the page for explicit gating signals that publishers
|
||||
// expose for Google News and crawlers. We treat the article as paywalled if:
|
||||
// - <meta name="article:content_tier" content="metered|locked"> (Conde Nast,
|
||||
@@ -310,18 +397,71 @@ func FetchArticleBody(articleURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
doc, err := goquery.NewDocumentFromReader(safehttp.LimitedBody(resp.Body, articleBodyMax))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
stripNonContent(doc)
|
||||
return extractBodyText(doc)
|
||||
}
|
||||
|
||||
// stripNonContent removes nodes whose text content is never article prose but
|
||||
// which goquery's .Text() would otherwise concatenate into the extracted body:
|
||||
// inline scripts (Datawrapper/embed resizers, analytics), CSS, and the fallback
|
||||
// markup inside <noscript>/<template>. Must run after any logic that inspects
|
||||
// these nodes (e.g. JSON-LD paywall detection).
|
||||
func stripNonContent(doc *goquery.Document) {
|
||||
doc.Find("script, style, noscript, template").Remove()
|
||||
}
|
||||
|
||||
func extractBodyText(doc *goquery.Document) string {
|
||||
sel := doc.Find("article p, main p")
|
||||
if sel.Length() == 0 {
|
||||
sel = doc.Find("p")
|
||||
out := joinParagraphText(doc.Find("article p, main p"))
|
||||
if len(out) < PaywallBodyThreshold {
|
||||
if alt := joinParagraphText(doc.Find("p")); len(alt) > len(out) {
|
||||
out = alt
|
||||
}
|
||||
}
|
||||
// Sites like Phoronix use <br>-separated text inside <article> rather
|
||||
// than <p> tags, and sites like Anime News Network skip semantic
|
||||
// article/main tags entirely and put the body in a custom div. Fall
|
||||
// back to the raw text of the longest known container.
|
||||
if len(out) < PaywallBodyThreshold {
|
||||
if alt := bestContainerText(doc); len(alt) > len(out) {
|
||||
out = alt
|
||||
}
|
||||
}
|
||||
return truncateUTF8(out, MaxBodyChars)
|
||||
}
|
||||
|
||||
// bodyContainerSelectors is the list of selectors we try when <article>/<main>
|
||||
// aren't present or are too thin. Ordered loosely from semantic to generic;
|
||||
// we take the longest match across all of them.
|
||||
var bodyContainerSelectors = []string{
|
||||
"article",
|
||||
"main",
|
||||
`[itemprop="articleBody"]`,
|
||||
`[role="main"]`,
|
||||
".article-body",
|
||||
".article-content",
|
||||
".entry-content",
|
||||
".post-content",
|
||||
".story-body",
|
||||
".KonaBody", // Anime News Network
|
||||
"#maincontent",
|
||||
"#content",
|
||||
}
|
||||
|
||||
func bestContainerText(doc *goquery.Document) string {
|
||||
var best string
|
||||
for _, sel := range bodyContainerSelectors {
|
||||
if t := containerText(doc.Find(sel).First()); len(t) > len(best) {
|
||||
best = t
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func joinParagraphText(sel *goquery.Selection) string {
|
||||
var b strings.Builder
|
||||
sel.Each(func(_ int, s *goquery.Selection) {
|
||||
t := strings.TrimSpace(s.Text())
|
||||
@@ -332,15 +472,17 @@ func extractBodyText(doc *goquery.Document) string {
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(t)
|
||||
if b.Len() >= MaxBodyChars {
|
||||
return
|
||||
}
|
||||
})
|
||||
out := strings.TrimSpace(b.String())
|
||||
if len(out) > MaxBodyChars {
|
||||
out = out[:MaxBodyChars]
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func containerText(sel *goquery.Selection) string {
|
||||
if sel.Length() == 0 {
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
// Collapse whitespace runs so <br>-laden markup doesn't produce a wall
|
||||
// of blank lines, but preserve enough structure to look like prose.
|
||||
return strings.Join(strings.Fields(sel.Text()), " ")
|
||||
}
|
||||
|
||||
func extractOGImage(doc *goquery.Document, base string) string {
|
||||
@@ -356,26 +498,99 @@ func extractOGImage(doc *goquery.Document, base string) string {
|
||||
return resolveURL(base, strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
// Sites like Hardcore Gaming 101 publish no og:image — fall back to the
|
||||
// WordPress featured image (.wp-post-image), then the first reasonably-
|
||||
// sized <img> inside <article>/<main>.
|
||||
if v, ok := doc.Find(`img.wp-post-image`).First().Attr("src"); ok {
|
||||
if url := pickImageSrc(doc.Find(`img.wp-post-image`).First(), v); url != "" {
|
||||
return resolveURL(base, url)
|
||||
}
|
||||
}
|
||||
var found string
|
||||
doc.Find(`article img, main img`).EachWithBreak(func(_ int, s *goquery.Selection) bool {
|
||||
raw, _ := s.Attr("src")
|
||||
url := pickImageSrc(s, raw)
|
||||
if url == "" || !looksLikeContentImage(s, url) {
|
||||
return true
|
||||
}
|
||||
found = url
|
||||
return false
|
||||
})
|
||||
if found != "" {
|
||||
return resolveURL(base, found)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractBodyChars concatenates the text of <p> tags inside <article> or
|
||||
// <main>, falling back to all <p> tags, and returns the trimmed length.
|
||||
func extractBodyChars(doc *goquery.Document) int {
|
||||
sel := doc.Find("article p, main p")
|
||||
if sel.Length() == 0 {
|
||||
sel = doc.Find("p")
|
||||
// pickImageSrc returns the most useful image URL from an <img> selection,
|
||||
// preferring data-src/data-lazy-src (lazy-load) over src when src is a
|
||||
// placeholder (empty or data: URI).
|
||||
func pickImageSrc(s *goquery.Selection, src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if !strings.HasPrefix(src, "data:") && src != "" {
|
||||
return src
|
||||
}
|
||||
var b strings.Builder
|
||||
sel.Each(func(_ int, s *goquery.Selection) {
|
||||
t := strings.TrimSpace(s.Text())
|
||||
if t == "" {
|
||||
return
|
||||
for _, attr := range []string{"data-src", "data-lazy-src", "data-original"} {
|
||||
if v, ok := s.Attr(attr); ok {
|
||||
v = strings.TrimSpace(v)
|
||||
if v != "" && !strings.HasPrefix(v, "data:") {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteString(t)
|
||||
})
|
||||
return len(strings.TrimSpace(b.String()))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// looksLikeContentImage filters out obvious non-article images: gravatars,
|
||||
// social-share icons, tracking pixels. Anything with declared dimensions
|
||||
// under 100px on either side is treated as chrome.
|
||||
func looksLikeContentImage(s *goquery.Selection, url string) bool {
|
||||
low := strings.ToLower(url)
|
||||
for _, bad := range []string{"gravatar.com", "/avatar/", "/icons/", "share-", "pixel.gif", "sprite"} {
|
||||
if strings.Contains(low, bad) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if w := atoiAttr(s, "width"); w > 0 && w < 100 {
|
||||
return false
|
||||
}
|
||||
if h := atoiAttr(s, "height"); h > 0 && h < 100 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func atoiAttr(s *goquery.Selection, name string) int {
|
||||
v, ok := s.Attr(name)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, c := range strings.TrimSpace(v) {
|
||||
if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// extractBodyChars returns the visible body length used to gauge whether a
|
||||
// page looks like a real article. It tries <p> tags inside <article>/<main>,
|
||||
// then all <p> tags, then the raw text inside <article>/<main> — that last
|
||||
// fallback catches sites like Phoronix that separate paragraphs with <br>
|
||||
// instead of <p>.
|
||||
func extractBodyChars(doc *goquery.Document) int {
|
||||
best := len(joinParagraphText(doc.Find("article p, main p")))
|
||||
if best < PaywallBodyThreshold {
|
||||
if n := len(joinParagraphText(doc.Find("p"))); n > best {
|
||||
best = n
|
||||
}
|
||||
}
|
||||
if best < PaywallBodyThreshold {
|
||||
if n := len(bestContainerText(doc)); n > best {
|
||||
best = n
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/safehttp"
|
||||
)
|
||||
|
||||
// NormalizeImageURL rewrites known CDN thumbnail URLs to a higher-resolution
|
||||
@@ -36,15 +38,10 @@ func NormalizeImageURL(raw string) string {
|
||||
return u.String()
|
||||
}
|
||||
|
||||
var imageClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
// imageClient validates feed-supplied image URLs. It routes through safehttp
|
||||
// so a hostile feed can't steer the HEAD probe at loopback / RFC1918 / cloud
|
||||
// metadata IPs — the dial-time guard also re-checks every redirect target.
|
||||
var imageClient = safehttp.NewClient(10 * time.Second)
|
||||
|
||||
// ValidateImageURL checks that an image URL returns a valid image response.
|
||||
// Returns false (with no error) on any failure — story posts without image.
|
||||
|
||||
@@ -5,8 +5,18 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"pete/internal/safehttp"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// httptest servers bind to 127.0.0.1; allow the safe-dial check to
|
||||
// accept them in tests only.
|
||||
safehttp.AllowPrivate = true
|
||||
}
|
||||
|
||||
func TestFetchOGImage(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -70,3 +80,91 @@ func TestFirstImgSrc(t *testing.T) {
|
||||
t.Errorf("empty input should yield empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractBodyCharsANNLayout regresses a false-positive paywall flag on
|
||||
// Anime News Network: their layout has no <article>/<main> tags and most of
|
||||
// the prose lives outside <p> tags, inside <div class="KonaBody"> containers.
|
||||
// Before the fix, body-length came out below PaywallBodyThreshold and the
|
||||
// poller flagged the story as gated.
|
||||
func TestExtractBodyCharsANNLayout(t *testing.T) {
|
||||
body := `<html><body>
|
||||
<div id="maincontent" class="maincontent news article">
|
||||
<h1>Yen Press Licenses Wistoria</h1>
|
||||
<div id="content-zone"><div class="news"><div class="text-zone"><div class="KonaBody">
|
||||
<div class="meat"><p>Yen Press announced on Friday during its panel that it has licensed seven manga and two novel series for release in December 2026 and winter 2027.</p>
|
||||
Snowmelt and Agapanthus is a manga series being released in the coming season; the publisher described it as a romance story with supernatural undertones, drawn in a delicate watercolor style that should appeal to fans of slice-of-life fantasy. Witch and Cat follows a young magic apprentice and her talking familiar through a series of low-stakes adventures across a sleepy countryside town, leaning into cozy atmosphere over plot. Sinful Is the Angel Who Loves is positioned for an older audience and runs in the publisher's mature imprint. Each release will be available in print and digital across English-language markets, with the first volumes scheduled for the December 2026 wave.
|
||||
</div>
|
||||
</div></div></div></div>
|
||||
</div></body></html>`
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if n := extractBodyChars(doc); n < PaywallBodyThreshold {
|
||||
t.Fatalf("extractBodyChars = %d, want >= %d (ANN-style body was incorrectly treated as paywalled)", n, PaywallBodyThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractBodyTextStripsInlineScript guards against inline embed scripts
|
||||
// (Datawrapper iframe resizers, analytics) leaking into reader mode. goquery's
|
||||
// .Text() concatenates the source of <script> nodes, so extraction must strip
|
||||
// them first. The script here sits between two prose paragraphs, mirroring the
|
||||
// Politico/Datawrapper layout that surfaced the bug.
|
||||
func TestExtractBodyTextStripsInlineScript(t *testing.T) {
|
||||
body := `<html><body><article>
|
||||
<p>While he has an overall net trust rating of +11 percent nationally, this masks a large regional gap between the north and south of the country.</p>
|
||||
<figure><script type="text/javascript">!function(){"use strict";window.addEventListener("message",(function(a){if(void 0!==a.data["datawrapper-height"]){var e=document.querySelectorAll("iframe");for(var t in a.data["datawrapper-height"])for(var r=0;r<e.length;r++);}}))}();</script></figure>
|
||||
<p>Burnham is on the cusp of becoming the U.K.'s seventh prime minister in 10 years and will enter Number 10 later this month after being elected unopposed.</p>
|
||||
</article></body></html>`
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
stripNonContent(doc)
|
||||
out := extractBodyText(doc)
|
||||
if strings.Contains(out, "datawrapper-height") || strings.Contains(out, "addEventListener") {
|
||||
t.Fatalf("inline script leaked into body text:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "net trust rating") || !strings.Contains(out, "seventh prime minister") {
|
||||
t.Fatalf("prose paragraphs missing from body text:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectSubscriberOnly(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "lwn subscriber-only marker",
|
||||
url: "https://lwn.net/Articles/999999/",
|
||||
body: `<html><body><p>The page you have requested is available only to LWN subscribers. Please log in.</p></body></html>`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "lwn free article",
|
||||
url: "https://lwn.net/Articles/123456/",
|
||||
body: `<html><body><article><p>Full free article body here.</p></article></body></html>`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non-lwn host with same marker text is ignored",
|
||||
url: "https://example.com/x",
|
||||
body: `<html><body>available only to lwn subscribers</body></html>`,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(tc.body))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got := detectSubscriberOnly(tc.url, doc); got != tc.want {
|
||||
t.Fatalf("detectSubscriberOnly = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package ingestion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -9,42 +10,84 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mmcdole/gofeed"
|
||||
ext "github.com/mmcdole/gofeed/extensions"
|
||||
|
||||
"pete/internal/safehttp"
|
||||
)
|
||||
|
||||
const userAgent = "Pete/1.0 (newsbot; +https://github.com/reala-misaki/pete)"
|
||||
|
||||
var feedClient = &http.Client{Timeout: 30 * time.Second}
|
||||
// maxFeedBytes caps how much of a feed body we'll buffer into the parser, so a
|
||||
// hostile feed streaming an endless body within the request timeout can't OOM
|
||||
// the process. Generous headroom — real RSS/Atom feeds are well under this.
|
||||
const maxFeedBytes = 16 << 20
|
||||
|
||||
var feedClient = safehttp.NewClient(30 * time.Second)
|
||||
|
||||
// FeedItem represents a parsed RSS item ready for routing.
|
||||
type FeedItem struct {
|
||||
GUID string
|
||||
Headline string
|
||||
Lede string
|
||||
Content string // full article text from content:encoded, block structure preserved; "" when the feed ships only a snippet
|
||||
ImageURL string
|
||||
ArticleURL string
|
||||
Source string
|
||||
DirectRoute string
|
||||
Language string // per-item <language> (or dc:language) when present; "" otherwise
|
||||
PublishedAt int64 // unix seconds from RSS pubDate / Atom published (or updated as fallback); 0 if absent or unparseable
|
||||
}
|
||||
|
||||
var (
|
||||
htmlTagRe = regexp.MustCompile(`<[^>]*>`)
|
||||
wsRe = regexp.MustCompile(`\s+`)
|
||||
imgSrcRe = regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+)["']`)
|
||||
|
||||
// nonContentElemRe strips whole <script>/<style>/<noscript> elements —
|
||||
// tags AND their text — before the generic tag strip runs. htmlTagRe alone
|
||||
// removes only the tags, which would leave inline JavaScript (e.g. a
|
||||
// Datawrapper embed resizer) as literal text in reader mode.
|
||||
nonContentElemRe = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script\s*>|<style\b[^>]*>.*?</style\s*>|<noscript\b[^>]*>.*?</noscript\s*>`)
|
||||
|
||||
// Used by extractContentText to preserve paragraph structure when turning
|
||||
// content:encoded HTML into plain text.
|
||||
blockCloseRe = regexp.MustCompile(`(?i)</(p|div|li|h[1-6]|blockquote|article|section|figure|figcaption|ul|ol|table|tr|pre)>`)
|
||||
brRe = regexp.MustCompile(`(?i)<br\s*/?>`)
|
||||
hspaceRe = regexp.MustCompile(`[ \t\f\r]+`)
|
||||
manyNewlineRe = regexp.MustCompile(`\n{3,}`)
|
||||
)
|
||||
|
||||
// maxContentChars bounds the stored article text. Generous enough for any real
|
||||
// article while keeping row size and the reader payload sane.
|
||||
const maxContentChars = 20000
|
||||
|
||||
// FetchFeed fetches and parses an RSS/Atom feed, returning items.
|
||||
func FetchFeed(feedURL string) ([]FeedItem, error) {
|
||||
// FetchFeed fetches and parses an RSS/Atom feed, returning items. ua overrides
|
||||
// the User-Agent header; pass "" to use Pete's honest default bot UA.
|
||||
func FetchFeed(feedURL, ua string) ([]FeedItem, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fp := gofeed.NewParser()
|
||||
fp.Client = feedClient
|
||||
fp.UserAgent = userAgent
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ua == "" {
|
||||
ua = userAgent
|
||||
}
|
||||
req.Header.Set("User-Agent", ua)
|
||||
resp, err := feedClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("feed fetch %s: status %d", feedURL, resp.StatusCode)
|
||||
}
|
||||
|
||||
feed, err := fp.ParseURLWithContext(feedURL, ctx)
|
||||
feed, err := gofeed.NewParser().Parse(safehttp.LimitedBody(resp.Body, maxFeedBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -52,11 +95,14 @@ func FetchFeed(feedURL string) ([]FeedItem, error) {
|
||||
items := make([]FeedItem, 0, len(feed.Items))
|
||||
for _, item := range feed.Items {
|
||||
fi := FeedItem{
|
||||
GUID: itemGUID(item),
|
||||
Headline: strings.TrimSpace(item.Title),
|
||||
Lede: extractLede(item.Description),
|
||||
ImageURL: NormalizeImageURL(extractImageURL(item)),
|
||||
ArticleURL: strings.TrimSpace(item.Link),
|
||||
GUID: itemGUID(item),
|
||||
Headline: strings.TrimSpace(item.Title),
|
||||
Lede: extractLede(item.Description),
|
||||
Content: extractContentText(item.Content),
|
||||
ImageURL: NormalizeImageURL(extractImageURL(item)),
|
||||
ArticleURL: strings.TrimSpace(item.Link),
|
||||
Language: itemLanguage(item),
|
||||
PublishedAt: itemPublished(item),
|
||||
}
|
||||
if fi.GUID == "" || fi.ArticleURL == "" {
|
||||
continue
|
||||
@@ -68,6 +114,22 @@ func FetchFeed(feedURL string) ([]FeedItem, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// itemLanguage returns the per-item language tag if the feed provides one.
|
||||
// Politico Europe puts a raw <language> inside each <item>; gofeed parks
|
||||
// unknown item-level elements in item.Custom. We also check the standard
|
||||
// dc:language extension for feeds that use Dublin Core.
|
||||
func itemLanguage(item *gofeed.Item) string {
|
||||
if item.Custom != nil {
|
||||
if v, ok := item.Custom["language"]; ok {
|
||||
return strings.ToLower(strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
if item.DublinCoreExt != nil && len(item.DublinCoreExt.Language) > 0 {
|
||||
return strings.ToLower(strings.TrimSpace(item.DublinCoreExt.Language[0]))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func itemGUID(item *gofeed.Item) string {
|
||||
if item.GUID != "" {
|
||||
return item.GUID
|
||||
@@ -75,16 +137,79 @@ func itemGUID(item *gofeed.Item) string {
|
||||
return item.Link
|
||||
}
|
||||
|
||||
// itemPublished returns the item's publish time as unix seconds, falling back
|
||||
// to the updated time if pubDate was absent. Returns 0 when neither parses;
|
||||
// the poller clamps future timestamps to ingest time.
|
||||
func itemPublished(item *gofeed.Item) int64 {
|
||||
if item.PublishedParsed != nil {
|
||||
return item.PublishedParsed.Unix()
|
||||
}
|
||||
if item.UpdatedParsed != nil {
|
||||
return item.UpdatedParsed.Unix()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// extractLede returns the feed description verbatim, with HTML stripped.
|
||||
func extractLede(desc string) string {
|
||||
if desc == "" {
|
||||
return ""
|
||||
}
|
||||
text := htmlTagRe.ReplaceAllString(desc, "")
|
||||
// Drop <script>/<style>/<noscript> (tags and their inner text) before the
|
||||
// generic tag strip, which would otherwise leave the inner text behind.
|
||||
desc = nonContentElemRe.ReplaceAllString(desc, " ")
|
||||
// Replace tags with a space so adjacent blocks like </p><p> don't fuse words.
|
||||
text := htmlTagRe.ReplaceAllString(desc, " ")
|
||||
text = html.UnescapeString(text)
|
||||
text = wsRe.ReplaceAllString(text, " ")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// extractContentText converts a feed's content:encoded HTML into plain text
|
||||
// with paragraph breaks preserved, for reader mode. Block-level boundaries
|
||||
// (</p>, </div>, <br>, list items, headings…) become newlines before the
|
||||
// remaining tags are stripped, so the reader can re-wrap the text into
|
||||
// paragraphs. Returns "" when the feed carried no content:encoded.
|
||||
func extractContentText(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return ""
|
||||
}
|
||||
// Remove <script>/<style>/<noscript> elements whole (tags + inner text)
|
||||
// first — otherwise htmlTagRe below strips only the tags and leaves inline
|
||||
// JavaScript (e.g. a Datawrapper embed resizer) as literal reader text.
|
||||
s := nonContentElemRe.ReplaceAllString(raw, "\n")
|
||||
s = brRe.ReplaceAllString(s, "\n")
|
||||
s = blockCloseRe.ReplaceAllString(s, "\n\n")
|
||||
s = htmlTagRe.ReplaceAllString(s, "")
|
||||
s = html.UnescapeString(s)
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, ln := range lines {
|
||||
lines[i] = strings.TrimSpace(hspaceRe.ReplaceAllString(ln, " "))
|
||||
}
|
||||
s = manyNewlineRe.ReplaceAllString(strings.Join(lines, "\n"), "\n\n")
|
||||
s = strings.TrimSpace(s)
|
||||
return truncateUTF8(s, maxContentChars)
|
||||
}
|
||||
|
||||
// truncateUTF8 caps s at max bytes without splitting a multi-byte rune. A plain
|
||||
// byte slice can leave a partial trailing rune, which is invalid UTF-8; that
|
||||
// corrupt byte then breaks the RSS content:encoded XML (Go's xml encoder writes
|
||||
// it raw) and shows as a replacement char in JSON. Trim back to a rune boundary.
|
||||
func truncateUTF8(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
s = s[:max]
|
||||
for len(s) > 0 {
|
||||
if r, size := utf8.DecodeLastRuneInString(s); r == utf8.RuneError && size <= 1 {
|
||||
s = s[:len(s)-1] // strip one byte of an incomplete trailing rune
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// extractImageURL tries to find an image URL from feed item metadata.
|
||||
// Order: media:content/thumbnail, enclosures, item.Image, then <img> tags
|
||||
// scraped from content:encoded / description (catches feeds like Ars that
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package ingestion
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractLede(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -48,6 +51,11 @@ func TestExtractLede(t *testing.T) {
|
||||
"It's a new era—one of change.",
|
||||
"It's a new era\u2014one of change.",
|
||||
},
|
||||
{
|
||||
"adjacent block tags inject a space",
|
||||
"<p>...end of moments</p><p>Clarence B Jones, a former...</p>",
|
||||
"...end of moments Clarence B Jones, a former...",
|
||||
},
|
||||
{
|
||||
"tags and entities combined",
|
||||
"<p>Oil prices rose & gas fell by >5%.</p>",
|
||||
@@ -64,3 +72,48 @@ func TestExtractLede(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentText(t *testing.T) {
|
||||
if got := extractContentText(""); got != "" {
|
||||
t.Errorf("empty input = %q, want empty", got)
|
||||
}
|
||||
if got := extractContentText(" \n "); got != "" {
|
||||
t.Errorf("whitespace-only input = %q, want empty", got)
|
||||
}
|
||||
|
||||
in := `<p>First paragraph with <a href="/x">a link</a> & entity.</p>` +
|
||||
`<p>Second line one.<br>line two.</p><ul><li>item</li></ul>`
|
||||
got := extractContentText(in)
|
||||
want := "First paragraph with a link & entity.\n\nSecond line one.\nline two.\n\nitem"
|
||||
if got != want {
|
||||
t.Errorf("extractContentText:\n got %q\n want %q", got, want)
|
||||
}
|
||||
|
||||
// Output is capped so a runaway body can't bloat the row.
|
||||
long := "<p>" + strings.Repeat("x", maxContentChars+500) + "</p>"
|
||||
if n := len(extractContentText(long)); n > maxContentChars {
|
||||
t.Errorf("capped length = %d, want <= %d", n, maxContentChars)
|
||||
}
|
||||
|
||||
// Inline embed scripts (Datawrapper resizers, analytics) must be dropped
|
||||
// whole — tags and body — not left as literal reader text. htmlTagRe alone
|
||||
// strips only the tags, so extractContentText has to remove the element first.
|
||||
embed := `<p>Net trust rating of +11 percent nationally.</p>` +
|
||||
`<figure><script type="text/javascript">!function(){"use strict";window.addEventListener("message",(function(a){var e=document.querySelectorAll("iframe");}))}();</script></figure>` +
|
||||
`<p>Burnham is on the cusp of becoming prime minister.</p>`
|
||||
gotEmbed := extractContentText(embed)
|
||||
if strings.Contains(gotEmbed, "datawrapper") || strings.Contains(gotEmbed, "addEventListener") || strings.Contains(gotEmbed, "querySelectorAll") {
|
||||
t.Errorf("inline script leaked into content text:\n%s", gotEmbed)
|
||||
}
|
||||
for _, want := range []string{"Net trust rating", "prime minister"} {
|
||||
if !strings.Contains(gotEmbed, want) {
|
||||
t.Errorf("prose %q missing from content text:\n%s", want, gotEmbed)
|
||||
}
|
||||
}
|
||||
|
||||
// The same guard applies to descriptions/ledes.
|
||||
ledeIn := `Trust gap widens.<script>track("x");</script> North vs south.`
|
||||
if gotLede := extractLede(ledeIn); strings.Contains(gotLede, "track") {
|
||||
t.Errorf("inline script leaked into lede: %q", gotLede)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package ingestion
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -94,10 +95,18 @@ func (p *Poller) pollOnce(ctx context.Context, src config.SourceConfig) {
|
||||
}
|
||||
|
||||
func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) error {
|
||||
items, err := FetchFeed(src.FeedURL)
|
||||
items, err := FetchFeed(src.FeedURL, src.UserAgent)
|
||||
if err != nil {
|
||||
// Persist the failure for the owner-facing source-health dashboard. The
|
||||
// in-memory consecutiveFailures in pollSource drives log warnings; this
|
||||
// row is the authoritative record the dashboard reads.
|
||||
storage.RecordPollResult(src.Name, false, 0, err)
|
||||
return err
|
||||
}
|
||||
// A successful fetch: record health now (before per-item processing) with
|
||||
// the number of items the feed returned, so the dashboard reflects feed
|
||||
// reachability even if individual items are later dropped as duplicates.
|
||||
storage.RecordPollResult(src.Name, true, len(items), nil)
|
||||
|
||||
newCount := 0
|
||||
for i := range items {
|
||||
@@ -109,6 +118,17 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
|
||||
continue
|
||||
}
|
||||
|
||||
// Drop items in languages this source isn't configured to surface.
|
||||
// Items without a language tag pass through — only filter when the
|
||||
// feed explicitly marks an item with a non-matching language.
|
||||
if src.Language != "" && items[i].Language != "" &&
|
||||
!strings.HasPrefix(items[i].Language, strings.ToLower(src.Language)) {
|
||||
slog.Info("dropping story (language filter)",
|
||||
"guid", items[i].GUID, "source", src.Name,
|
||||
"item_lang", items[i].Language, "want", src.Language)
|
||||
continue
|
||||
}
|
||||
|
||||
// Dedup checks first — canonical and headline use the ORIGINAL URL so
|
||||
// they stay stable whether we end up swapping to a Wayback snapshot.
|
||||
originalURL := items[i].ArticleURL
|
||||
@@ -137,6 +157,21 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
|
||||
// swapping in a stale snapshot for a transient blip is worse than
|
||||
// posting the live link.
|
||||
meta := FetchArticleMeta(originalURL)
|
||||
// Some sources (e.g. LWN's subscriber-only articles) have no public
|
||||
// version reachable by any workaround. Drop these from view entirely
|
||||
// rather than stamping them as paywalled.
|
||||
if meta.SubscriberOnly {
|
||||
// No DB row written, so the next poll will re-fetch and re-detect
|
||||
// this URL — fine in practice since LWN's feed is tiny and the
|
||||
// alternative (a tombstone row) leaks into every story query.
|
||||
slog.Info("dropping subscriber-only story",
|
||||
"guid", items[i].GUID, "url", originalURL, "source", src.Name)
|
||||
continue
|
||||
}
|
||||
// bodyText is the article text we may store for reader mode. It starts
|
||||
// as the body scraped during the fetch above and is upgraded to the
|
||||
// snapshot's body if we end up swapping in an archive snapshot.
|
||||
bodyText := meta.BodyText
|
||||
gated := meta.Gated() || (meta.Fetched && meta.BodyChars < PaywallBodyThreshold)
|
||||
// paywalled tracks whether the link the user will click is still
|
||||
// gated — i.e. gating was detected AND no archive workaround worked.
|
||||
@@ -152,6 +187,9 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
|
||||
if items[i].ImageURL == "" && snapMeta.ImageURL != "" {
|
||||
items[i].ImageURL = snapMeta.ImageURL
|
||||
}
|
||||
if snapMeta.BodyText != "" {
|
||||
bodyText = snapMeta.BodyText
|
||||
}
|
||||
workedAround = true
|
||||
slog.Info("paywall detected, using archive snapshot",
|
||||
"guid", items[i].GUID, "original", originalURL,
|
||||
@@ -177,18 +215,35 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
|
||||
items[i].Source = src.Name
|
||||
items[i].DirectRoute = src.DirectRoute
|
||||
|
||||
// Store immediately (before routing) to prevent re-ingestion
|
||||
// Store immediately (before routing) to prevent re-ingestion.
|
||||
// Future-dated pubDates (publishers occasionally post-date drafts or
|
||||
// have a misconfigured clock) get clamped to ingest time so they don't
|
||||
// stick at the top of the queue indefinitely.
|
||||
seenAt := time.Now().Unix()
|
||||
publishedAt := items[i].PublishedAt
|
||||
if publishedAt > seenAt {
|
||||
publishedAt = seenAt
|
||||
}
|
||||
// Reader-mode body: prefer whichever source gave us more complete text.
|
||||
// Feeds that ship full content:encoded usually win; feeds that only
|
||||
// syndicate a snippet fall back to the scraped article body.
|
||||
content := items[i].Content
|
||||
if len(bodyText) > len(content) {
|
||||
content = bodyText
|
||||
}
|
||||
if err := storage.InsertStory(&storage.Story{
|
||||
GUID: items[i].GUID,
|
||||
Headline: items[i].Headline,
|
||||
Lede: items[i].Lede,
|
||||
Content: content,
|
||||
ImageURL: items[i].ImageURL,
|
||||
ArticleURL: items[i].ArticleURL,
|
||||
URLCanonical: canonical,
|
||||
HeadlineNorm: headlineNorm,
|
||||
Source: items[i].Source,
|
||||
Paywalled: paywalled,
|
||||
SeenAt: time.Now().Unix(),
|
||||
SeenAt: seenAt,
|
||||
PublishedAt: publishedAt,
|
||||
}); err != nil {
|
||||
slog.Error("failed to insert story", "guid", items[i].GUID, "err", err)
|
||||
continue
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/safehttp"
|
||||
)
|
||||
|
||||
var waybackClient = &http.Client{Timeout: 10 * time.Second}
|
||||
var waybackClient = safehttp.NewClient(10 * time.Second)
|
||||
|
||||
// maxSnapshotAge bounds how stale a Wayback snapshot can be before we treat
|
||||
// it as useless for a freshly-published news article. The "closest" snapshot
|
||||
@@ -86,14 +88,16 @@ func snapshotFreshEnough(ts string) bool {
|
||||
return time.Since(t) <= maxSnapshotAge
|
||||
}
|
||||
|
||||
var archiveTodayClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
// Don't follow the redirect — we just want the Location header pointing
|
||||
// at the most recent capture.
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
// archiveTodayClient routes through safehttp for the dial-time SSRF guard, but
|
||||
// overrides CheckRedirect so we read the 302 Location header (the most recent
|
||||
// capture) instead of following it.
|
||||
var archiveTodayClient = func() *http.Client {
|
||||
c := safehttp.NewClient(10 * time.Second)
|
||||
c.CheckRedirect = func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
return c
|
||||
}()
|
||||
|
||||
// ResolveArchiveToday looks up the newest archive.today / archive.ph snapshot
|
||||
// for the given URL. archive.ph has no public availability API, but its
|
||||
|
||||
@@ -13,12 +13,13 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
"pete/internal/safehttp"
|
||||
|
||||
_ "go.mau.fi/util/dbutil/litestream" // registers "sqlite3-fk-wal" driver used by cryptohelper
|
||||
|
||||
@@ -142,30 +143,45 @@ func New(cfg config.MatrixConfig) (*Client, error) {
|
||||
"room", evt.RoomID, "event_id", evt.ID, "sender", evt.Sender, "err", err)
|
||||
}
|
||||
|
||||
// LoginAs enables the cryptohelper to re-login if the token expires
|
||||
ch.LoginAs = &mautrix.ReqLogin{
|
||||
Type: mautrix.AuthTypePassword,
|
||||
Identifier: mautrix.UserIdentifier{
|
||||
Type: mautrix.IdentifierTypeUser,
|
||||
User: cfg.UserID,
|
||||
},
|
||||
Password: cfg.Password,
|
||||
InitialDeviceDisplayName: cfg.DisplayName,
|
||||
}
|
||||
|
||||
// IMPORTANT: do NOT set ch.LoginAs here. We already performed an explicit
|
||||
// password login above (or restored a saved token), so mx is fully
|
||||
// authenticated against device.json's device. When LoginAs is set AND the
|
||||
// crypto store is fresh, cryptohelper.Init() performs a SECOND login
|
||||
// (StoreCredentials=true), minting a separate device and pinning the olm
|
||||
// account to it — while device.json still records the first device. That
|
||||
// split-brain is exactly the "device never verifies" bug: the device the
|
||||
// token authenticates as is not the device the crypto account belongs to,
|
||||
// and each cold start leaks another orphan device. Token-expiry recovery is
|
||||
// already handled by the isTokenValid() check + re-login path above.
|
||||
if err := ch.Init(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("crypto helper init: %w", err)
|
||||
}
|
||||
|
||||
mx.Crypto = ch
|
||||
|
||||
// Bootstrap cross-signing only if not already set up.
|
||||
// Ensure our CURRENT device is cross-signed. We bootstrap (generate + upload
|
||||
// fresh cross-signing keys, then self-sign) whenever either no keys exist yet,
|
||||
// or keys exist on the server but our device is not signed by them. The latter
|
||||
// is exactly the post-wipe state: when crypto.db is deleted the private
|
||||
// self-signing key is lost (it is never persisted locally without an SSSS
|
||||
// passphrase, and we discard the random recovery key), so the lingering server
|
||||
// master key is useless to us and must be replaced.
|
||||
//
|
||||
// CRITICAL: do NOT gate this on IsDeviceTrusted(mach.OwnIdentity()).
|
||||
// OwnIdentity() hard-codes Trust=id.TrustStateVerified, and ResolveTrustContext
|
||||
// short-circuits on that, so the call is a tautology that ALWAYS returns true —
|
||||
// it makes this branch unreachable and the device never gets signed.
|
||||
// GetOwnVerificationStatus does a real check: is our own device key signed by
|
||||
// our self-signing key?
|
||||
mach := ch.Machine()
|
||||
existingKeys, err := mach.GetOwnCrossSigningPublicKeys(context.Background())
|
||||
hasKeys, deviceCrossSigned, err := mach.GetOwnVerificationStatus(context.Background())
|
||||
if err != nil {
|
||||
slog.Warn("cross-signing: failed to fetch existing keys", "err", err)
|
||||
slog.Warn("cross-signing: failed to check verification status", "err", err)
|
||||
}
|
||||
if existingKeys == nil || existingKeys.MasterKey == "" {
|
||||
if !hasKeys || !deviceCrossSigned {
|
||||
if hasKeys {
|
||||
slog.Warn("cross-signing: keys exist but current device is not cross-signed, resetting cross-signing")
|
||||
}
|
||||
_, _, err = mach.GenerateAndUploadCrossSigningKeys(context.Background(), func(ui *mautrix.RespUserInteractive) interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": mautrix.AuthTypePassword,
|
||||
@@ -195,7 +211,7 @@ func New(cfg config.MatrixConfig) (*Client, error) {
|
||||
slog.Info("cross-signing: master key signed")
|
||||
}
|
||||
} else {
|
||||
slog.Info("cross-signing: already configured, skipping bootstrap")
|
||||
slog.Info("cross-signing: already configured and current device cross-signed, skipping bootstrap")
|
||||
}
|
||||
|
||||
slog.Info("E2EE initialized",
|
||||
@@ -415,14 +431,18 @@ func (c *Client) PostStory(channel string, story *PostableStory) (eventID id.Eve
|
||||
return resp.EventID, imageSent, nil
|
||||
}
|
||||
|
||||
// imageDownloadClient fetches feed-supplied image URLs for re-upload to Matrix.
|
||||
// It routes through safehttp so a hostile feed can't point ImageURL at internal
|
||||
// services (loopback / RFC1918 / cloud metadata) — including via redirects.
|
||||
var imageDownloadClient = safehttp.NewClient(15 * time.Second)
|
||||
|
||||
// sendImage downloads an image, uploads it to Matrix, and sends it as m.image.
|
||||
func (c *Client) sendImage(ctx context.Context, roomID id.RoomID, imageURL string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create image request: %w", err)
|
||||
}
|
||||
httpClient := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := httpClient.Do(req)
|
||||
resp, err := imageDownloadClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download image: %w", err)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,21 @@ package matrix
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// safeHref returns raw if it is an http(s) URL, else "". Keeps feed-supplied
|
||||
// links from injecting javascript:/data: schemes into the formatted_body href
|
||||
// (html.EscapeString blocks attribute breakout but not the scheme itself).
|
||||
func safeHref(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return ""
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// PostableStory contains the data needed to format and send a story.
|
||||
type PostableStory struct {
|
||||
ImageURL string
|
||||
@@ -25,38 +37,52 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
|
||||
if s.Lede != "" {
|
||||
plainParts = append(plainParts, s.Lede)
|
||||
}
|
||||
plainParts = append(plainParts, formatSourceTag(s.Source, s.Platforms, false))
|
||||
if tag := formatSourceTag(s.Source, s.Platforms, false); tag != "" {
|
||||
plainParts = append(plainParts, tag)
|
||||
}
|
||||
plain = strings.Join(plainParts, "\n")
|
||||
|
||||
// HTML body
|
||||
var htmlParts []string
|
||||
htmlParts = append(htmlParts, fmt.Sprintf(
|
||||
`<strong><a href="%s">%s</a></strong>`,
|
||||
html.EscapeString(s.ArticleURL),
|
||||
html.EscapeString(s.Headline),
|
||||
))
|
||||
if href := safeHref(s.ArticleURL); href != "" {
|
||||
htmlParts = append(htmlParts, fmt.Sprintf(
|
||||
`<strong><a href="%s">%s</a></strong>`,
|
||||
html.EscapeString(href),
|
||||
html.EscapeString(s.Headline),
|
||||
))
|
||||
} else {
|
||||
// Non-http(s) link: render the headline without an anchor.
|
||||
htmlParts = append(htmlParts, fmt.Sprintf(`<strong>%s</strong>`, html.EscapeString(s.Headline)))
|
||||
}
|
||||
if s.Lede != "" {
|
||||
htmlParts = append(htmlParts, html.EscapeString(s.Lede))
|
||||
}
|
||||
htmlParts = append(htmlParts, formatSourceTag(s.Source, s.Platforms, true))
|
||||
if tag := formatSourceTag(s.Source, s.Platforms, true); tag != "" {
|
||||
htmlParts = append(htmlParts, tag)
|
||||
}
|
||||
htmlBody = strings.Join(htmlParts, "<br/>")
|
||||
|
||||
return plain, htmlBody
|
||||
}
|
||||
|
||||
// formatSourceTag builds the source + platform tags line.
|
||||
// formatSourceTag builds the source + platform tags line. An empty source is
|
||||
// omitted rather than tagged: Pete's own reporting has no outlet to credit, and
|
||||
// an empty tag would read as him signing his own name.
|
||||
func formatSourceTag(source string, platforms []string, isHTML bool) string {
|
||||
if isHTML {
|
||||
parts := []string{fmt.Sprintf("<code>%s</code>", html.EscapeString(strings.ToLower(source)))}
|
||||
for _, p := range platforms {
|
||||
parts = append(parts, fmt.Sprintf("<code>%s</code>", html.EscapeString(p)))
|
||||
}
|
||||
return strings.Join(parts, " \u00b7 ")
|
||||
var parts []string
|
||||
if source != "" {
|
||||
parts = append(parts, strings.ToLower(source))
|
||||
}
|
||||
|
||||
parts := []string{fmt.Sprintf("`%s`", strings.ToLower(source))}
|
||||
for _, p := range platforms {
|
||||
parts = append(parts, fmt.Sprintf("`%s`", p))
|
||||
parts = append(parts, platforms...)
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
for i, p := range parts {
|
||||
if isHTML {
|
||||
parts[i] = fmt.Sprintf("<code>%s</code>", html.EscapeString(p))
|
||||
} else {
|
||||
parts[i] = fmt.Sprintf("`%s`", p)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " \u00b7 ")
|
||||
}
|
||||
|
||||
180
internal/opentdb/opentdb.go
Normal file
180
internal/opentdb/opentdb.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Package opentdb fills the casino's trivia bank from the Open Trivia Database.
|
||||
//
|
||||
// The questions are *prefetched* into a local table, not fetched per question,
|
||||
// and that is a deliberate call rather than an optimisation. A trivia ladder
|
||||
// asks a question every fifteen seconds with money on the clock: a per-question
|
||||
// fetch would put somebody else's latency, rate limit and downtime inside a
|
||||
// timed round the player is being scored against. Pull the bank in the
|
||||
// background, and a round becomes a local read that either works or doesn't.
|
||||
//
|
||||
// OpenTDB allows one request every five seconds per IP and caps a batch at 50,
|
||||
// so the refill is a slow, polite drip, run in the background and never in the
|
||||
// path of anything a player is waiting for.
|
||||
package opentdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/trivia"
|
||||
"pete/internal/safehttp"
|
||||
)
|
||||
|
||||
// endpoint is the API. It is the only host this package ever talks to, and it
|
||||
// goes through safehttp like every other outbound fetch in Pete.
|
||||
const endpoint = "https://opentdb.com/api.php"
|
||||
|
||||
// Batch is the most OpenTDB will hand over in one request.
|
||||
const Batch = 50
|
||||
|
||||
// Politeness is the gap the API asks for between requests. Going faster earns a
|
||||
// response_code 5 and nothing else.
|
||||
const Politeness = 6 * time.Second
|
||||
|
||||
// fetchTimeout bounds a single request. The refill runs in the background, so a
|
||||
// slow answer costs nothing but its own goroutine — but it must still end.
|
||||
const fetchTimeout = 20 * time.Second
|
||||
|
||||
// maxBody caps what we will read from the API, hostile or merely broken.
|
||||
const maxBody = 1 << 20
|
||||
|
||||
// apiResponse is OpenTDB's envelope. ResponseCode is the part that matters:
|
||||
// zero is the only one that means "here are your questions".
|
||||
type apiResponse struct {
|
||||
ResponseCode int `json:"response_code"`
|
||||
Results []struct {
|
||||
Category string `json:"category"`
|
||||
Type string `json:"type"`
|
||||
Question string `json:"question"`
|
||||
Correct string `json:"correct_answer"`
|
||||
Incorrect []string `json:"incorrect_answers"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
// responseErr turns a non-zero code into something a log line can explain.
|
||||
func responseErr(code int) error {
|
||||
switch code {
|
||||
case 1:
|
||||
return fmt.Errorf("opentdb: no results for that query")
|
||||
case 2:
|
||||
return fmt.Errorf("opentdb: the query was invalid")
|
||||
case 3, 4:
|
||||
return fmt.Errorf("opentdb: session token expired or exhausted")
|
||||
case 5:
|
||||
return fmt.Errorf("opentdb: rate limited — slow down")
|
||||
default:
|
||||
return fmt.Errorf("opentdb: response code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Client fetches questions.
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New() *Client {
|
||||
return &Client{http: safehttp.NewClient(fetchTimeout)}
|
||||
}
|
||||
|
||||
// Fetch pulls up to n multiple-choice questions of one difficulty.
|
||||
//
|
||||
// Only "multiple" questions are asked for: the ladder is four buttons, and a
|
||||
// true/false question on the same felt would be a coin flip dressed up as a
|
||||
// question — and a coin flip the player is being paid a difficulty multiple for.
|
||||
func (c *Client) Fetch(ctx context.Context, difficulty string, n int) ([]trivia.Question, error) {
|
||||
if n <= 0 || n > Batch {
|
||||
n = Batch
|
||||
}
|
||||
q := url.Values{
|
||||
"amount": {fmt.Sprint(n)},
|
||||
"difficulty": {difficulty},
|
||||
"type": {"multiple"},
|
||||
}
|
||||
raw := endpoint + "?" + q.Encode()
|
||||
if err := safehttp.ValidateURL(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "pete-games/1.0 (+https://games.parodia.dev)")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("opentdb: http %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(safehttp.LimitedBody(resp.Body, maxBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out apiResponse
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, fmt.Errorf("opentdb: %w", err)
|
||||
}
|
||||
if out.ResponseCode != 0 {
|
||||
return nil, responseErr(out.ResponseCode)
|
||||
}
|
||||
|
||||
qs := make([]trivia.Question, 0, len(out.Results))
|
||||
for _, r := range out.Results {
|
||||
// The API hands back HTML entities ("Who wrote "Dune"?"), which
|
||||
// would otherwise be drawn literally onto a button.
|
||||
text := clean(r.Question)
|
||||
correct := clean(r.Correct)
|
||||
if text == "" || correct == "" || len(r.Incorrect) != 3 {
|
||||
continue // a malformed question is one we simply don't take
|
||||
}
|
||||
|
||||
// Correct: 0 here is a convention, not a tell. The engine reshuffles every
|
||||
// question against the game's own seed as it builds the ladder, so where
|
||||
// the right answer sits in the bank never reaches a player.
|
||||
answers := make([]string, 0, 4)
|
||||
answers = append(answers, correct)
|
||||
dupe := false
|
||||
for _, w := range r.Incorrect {
|
||||
a := clean(w)
|
||||
// A wrong answer that reads the same as the right one — usually two
|
||||
// spellings that collapse once the entities are decoded — is a question
|
||||
// with two identical buttons on it, and the shuffle can only call one of
|
||||
// them correct. A player who clicked the right words and was told they
|
||||
// were wrong has lost the whole ladder to our typography. Drop it.
|
||||
if a == "" || a == correct {
|
||||
dupe = true
|
||||
break
|
||||
}
|
||||
answers = append(answers, a)
|
||||
}
|
||||
if dupe || len(answers) != 4 {
|
||||
continue
|
||||
}
|
||||
qs = append(qs, trivia.Question{
|
||||
Category: clean(r.Category),
|
||||
Text: text,
|
||||
Answers: answers,
|
||||
Correct: 0,
|
||||
})
|
||||
}
|
||||
return qs, nil
|
||||
}
|
||||
|
||||
// clean turns an API string into something you can put on a button: entities
|
||||
// decoded, whitespace tidied.
|
||||
func clean(s string) string {
|
||||
return strings.TrimSpace(html.UnescapeString(s))
|
||||
}
|
||||
@@ -92,7 +92,10 @@ func (q *Queue) Wait() {
|
||||
|
||||
// ForcePost pops the next queued item for the given channel and posts it
|
||||
// immediately, bypassing min-interval, burst cap, and daily cap. Dedup
|
||||
// (canonical URL cooldown) still applies. Returns true if an item was sent.
|
||||
// (canonical URL cooldown) still applies. Returns true only if a Matrix
|
||||
// message was actually sent — a dedup-skip returns false so the caller
|
||||
// can fall back (e.g., to a DB lookup) instead of silently consuming the
|
||||
// queued item with no user-visible result.
|
||||
func (q *Queue) ForcePost(channel string) bool {
|
||||
q.mu.Lock()
|
||||
items := q.queues[channel]
|
||||
@@ -106,8 +109,7 @@ func (q *Queue) ForcePost(channel string) bool {
|
||||
|
||||
slog.Info("force-posting story on demand",
|
||||
"guid", item.GUID, "channel", channel)
|
||||
q.postItem(item)
|
||||
return true
|
||||
return q.postItem(item, true)
|
||||
}
|
||||
|
||||
func (q *Queue) drain() {
|
||||
@@ -192,17 +194,23 @@ func (q *Queue) drainChannel(channel string) {
|
||||
q.queues[channel] = items[1:]
|
||||
q.mu.Unlock()
|
||||
|
||||
q.postItem(item)
|
||||
q.postItem(item, false)
|
||||
}
|
||||
|
||||
// PostNow sends a story immediately, bypassing the in-memory queue and all
|
||||
// pacing limits. Last-mile canonical-URL dedup still applies. Used by !post
|
||||
// to satisfy on-demand requests with stories pulled directly from storage.
|
||||
func (q *Queue) PostNow(item QueueItem) {
|
||||
q.postItem(item)
|
||||
q.postItem(item, true)
|
||||
}
|
||||
|
||||
func (q *Queue) postItem(item QueueItem) {
|
||||
// postItem returns true when a Matrix event was actually sent, false on
|
||||
// any non-success path (dedup-skip, transport failure with or without
|
||||
// retry, dead-letter). ForcePost uses the return value to decide whether
|
||||
// to acknowledge the user-initiated !post or fall back to a DB lookup.
|
||||
// forced=true marks the resulting post_log row so it's excluded from the
|
||||
// global daily cap (manual overrides shouldn't steal the rotation budget).
|
||||
func (q *Queue) postItem(item QueueItem, forced bool) bool {
|
||||
// Last-mile dedup: if this canonical URL was already posted to this channel
|
||||
// within the cooldown window, drop silently. Catches "same article, different
|
||||
// GUID across feeds" and any race where two items slipped past ingest dedup.
|
||||
@@ -215,7 +223,7 @@ func (q *Queue) postItem(item QueueItem) {
|
||||
"url_canonical", canonical,
|
||||
"cooldown_hours", q.config.DedupCooldownHours,
|
||||
)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
story := &matrix.PostableStory{
|
||||
@@ -237,7 +245,7 @@ func (q *Queue) postItem(item QueueItem) {
|
||||
"channel", item.Channel,
|
||||
"err", err,
|
||||
)
|
||||
return
|
||||
return false
|
||||
}
|
||||
slog.Error("failed to post story, will retry",
|
||||
"guid", item.GUID,
|
||||
@@ -254,15 +262,16 @@ func (q *Queue) postItem(item QueueItem) {
|
||||
q.mu.Lock()
|
||||
q.queues[item.Channel] = append([]QueueItem{item}, q.queues[item.Channel]...)
|
||||
q.mu.Unlock()
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Record in post log (INSERT OR IGNORE via unique index)
|
||||
storage.InsertPostLog(item.GUID, item.Channel, string(eventID), canonical)
|
||||
storage.InsertPostLog(item.GUID, item.Channel, string(eventID), canonical, forced)
|
||||
|
||||
slog.Info("story posted",
|
||||
"guid", item.GUID,
|
||||
"channel", item.Channel,
|
||||
"event_id", eventID,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -18,6 +18,15 @@ type ReactionCallback func(roomID id.RoomID, targetEventID id.EventID, guid, cha
|
||||
var (
|
||||
reactionCallbackMu sync.RWMutex
|
||||
reactionCallback ReactionCallback
|
||||
|
||||
// reactionSem bounds concurrent in-flight reaction callbacks. Each callback
|
||||
// may do DB / Matrix / LLM I/O, so an unbounded fan-out (`go cb(...)` per
|
||||
// reaction) lets a reaction flood — legitimate burst or an abusive room
|
||||
// member spamming emoji — exhaust goroutines, connections, and memory.
|
||||
// Tries to acquire non-blocking: if the cap is saturated we drop the
|
||||
// callback with a warn rather than queueing, on the theory that the room
|
||||
// is already busy enough.
|
||||
reactionSem = make(chan struct{}, 8)
|
||||
)
|
||||
|
||||
// SetReactionCallback installs a hook called after every recorded reaction.
|
||||
@@ -48,7 +57,19 @@ func HandleReaction(roomID id.RoomID, eventID id.EventID, targetEventID id.Event
|
||||
reactionCallbackMu.RLock()
|
||||
cb := reactionCallback
|
||||
reactionCallbackMu.RUnlock()
|
||||
if cb != nil {
|
||||
go cb(roomID, targetEventID, guid, channel, emoji)
|
||||
if cb == nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case reactionSem <- struct{}{}:
|
||||
default:
|
||||
slog.Warn("reaction callback dropped: worker pool saturated",
|
||||
"guid", guid, "channel", channel, "emoji", emoji)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer func() { <-reactionSem }()
|
||||
cb(roomID, targetEventID, guid, channel, emoji)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestHandleReaction_KnownPost(t *testing.T) {
|
||||
setupTrackerTestDB(t)
|
||||
|
||||
// Insert a post log entry
|
||||
storage.InsertPostLog("story-1", "tech", "$post1:example.org", "")
|
||||
storage.InsertPostLog("story-1", "tech", "$post1:example.org", "", false)
|
||||
|
||||
// Handle a reaction to that post
|
||||
HandleReaction(
|
||||
@@ -63,7 +63,7 @@ func TestHandleReaction_UnknownPost(t *testing.T) {
|
||||
func TestHandleReaction_DuplicateIgnored(t *testing.T) {
|
||||
setupTrackerTestDB(t)
|
||||
|
||||
storage.InsertPostLog("story-1", "tech", "$post1:example.org", "")
|
||||
storage.InsertPostLog("story-1", "tech", "$post1:example.org", "", false)
|
||||
|
||||
// Send same reaction twice
|
||||
HandleReaction(
|
||||
|
||||
147
internal/safehttp/safehttp.go
Normal file
147
internal/safehttp/safehttp.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Package safehttp provides an http.Client hardened against SSRF and
|
||||
// memory-DoS via hostile upstreams. Every outbound fetch the bot makes
|
||||
// against feed-supplied URLs (RSS feeds, article pages, image hosts)
|
||||
// should go through one of these clients so a malicious feed can't
|
||||
// steer Pete at loopback, link-local, RFC1918, or cloud metadata IPs,
|
||||
// and can't OOM the process by streaming an unbounded body.
|
||||
package safehttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrBlockedHost is returned when a URL resolves to a non-public IP.
|
||||
var ErrBlockedHost = errors.New("safehttp: blocked non-public host")
|
||||
|
||||
// AllowPrivate, when true, disables the loopback/RFC1918 dial guard. It
|
||||
// exists for tests that spin up httptest.NewServer on 127.0.0.1 — never
|
||||
// set this in production.
|
||||
var AllowPrivate bool
|
||||
|
||||
// safeDialContext refuses connections to non-public IPs. It runs after
|
||||
// DNS resolution, so a hostile DNS rebinding that returns 127.0.0.1
|
||||
// still gets blocked at dial time.
|
||||
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips, err := (&net.Resolver{}).LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var allowed net.IP
|
||||
for _, ip := range ips {
|
||||
if AllowPrivate || isPublicIP(ip) {
|
||||
allowed = ip
|
||||
break
|
||||
}
|
||||
}
|
||||
if allowed == nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrBlockedHost, host)
|
||||
}
|
||||
d := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}
|
||||
return d.DialContext(ctx, network, net.JoinHostPort(allowed.String(), port))
|
||||
}
|
||||
|
||||
// isPublicIP reports whether ip is a globally routable unicast address.
|
||||
// Rejects loopback, link-local, multicast, RFC1918, CGNAT, and the
|
||||
// AWS/GCP/Azure metadata IPs 169.254.169.254 / fd00:ec2::254 (these
|
||||
// already fall under link-local but spell it out for clarity).
|
||||
func isPublicIP(ip net.IP) bool {
|
||||
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() ||
|
||||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||
ip.IsMulticast() || ip.IsPrivate() {
|
||||
return false
|
||||
}
|
||||
// 100.64.0.0/10 (CGNAT) is not covered by IsPrivate on older Go.
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
|
||||
return false
|
||||
}
|
||||
// 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24
|
||||
if v4[0] == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ValidateURL returns nil if the URL is http(s) and parseable. It does
|
||||
// not resolve DNS — the dial step does that — but it does reject bare
|
||||
// schemes (file://, gopher://, etc.) before we even open a connection.
|
||||
func ValidateURL(raw string) error {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("safehttp: unsupported scheme %q", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return errors.New("safehttp: empty host")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewClient returns an http.Client whose transport blocks non-public
|
||||
// destinations at dial time, caps redirects at 5, and re-validates each
|
||||
// redirect target's scheme. timeout is the per-request overall budget.
|
||||
func NewClient(timeout time.Duration) *http.Client {
|
||||
tr := &http.Transport{
|
||||
DialContext: safeDialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 32,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("safehttp: stopped after 5 redirects")
|
||||
}
|
||||
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("safehttp: unsupported redirect scheme %q", req.URL.Scheme)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LimitedBody wraps r in an io.LimitReader that returns io.ErrUnexpectedEOF
|
||||
// once more than max bytes have been read. Use to cap how much of a
|
||||
// response body downstream parsers (goquery, gofeed, image.Decode) will
|
||||
// ever see — a hostile origin streaming an endless body otherwise OOMs
|
||||
// the process.
|
||||
func LimitedBody(r io.Reader, max int64) io.Reader {
|
||||
return &limitedReader{R: r, N: max}
|
||||
}
|
||||
|
||||
type limitedReader struct {
|
||||
R io.Reader
|
||||
N int64
|
||||
}
|
||||
|
||||
func (l *limitedReader) Read(p []byte) (int, error) {
|
||||
if l.N <= 0 {
|
||||
return 0, fmt.Errorf("safehttp: response body exceeded cap")
|
||||
}
|
||||
if int64(len(p)) > l.N {
|
||||
p = p[:l.N]
|
||||
}
|
||||
n, err := l.R.Read(p)
|
||||
l.N -= int64(n)
|
||||
return n, err
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func TestTick_AlreadyPostedExcluded(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
insertPostable(t, "t-posted", "GuardianTech", "tech", 200)
|
||||
insertPostable(t, "t-fresh", "GuardianTech", "tech", 100)
|
||||
storage.InsertPostLog("t-posted", "tech", "$evt1", "https://example.com/t-posted")
|
||||
storage.InsertPostLog("t-posted", "tech", "$evt1", "https://example.com/t-posted", false)
|
||||
|
||||
fq := &fakeQueue{}
|
||||
rr := New([]string{"tech"}, 4, fq)
|
||||
|
||||
@@ -81,13 +81,33 @@ func runMigrations(d *sql.DB) error {
|
||||
addColumnIfMissing(d, "stories", "url_canonical", "TEXT")
|
||||
addColumnIfMissing(d, "stories", "headline_norm", "TEXT")
|
||||
addColumnIfMissing(d, "stories", "paywalled", "INTEGER NOT NULL DEFAULT 0")
|
||||
// content holds the full article text (feed content:encoded when present,
|
||||
// else the body scraped during paywall detection) for reader mode. Stories
|
||||
// ingested before this column existed simply have NULL and fall back to lede.
|
||||
addColumnIfMissing(d, "stories", "content", "TEXT")
|
||||
// content_chars caches the character count of content so the "N min read"
|
||||
// chip never has to LENGTH() the full body on the hot listing path. Filled at
|
||||
// insert time; the backfill below populates rows that predate the column.
|
||||
addColumnIfMissing(d, "stories", "content_chars", "INTEGER NOT NULL DEFAULT 0")
|
||||
backfillContentChars(d)
|
||||
addColumnIfMissing(d, "stories", "published_at", "INTEGER")
|
||||
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
|
||||
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
|
||||
addColumnIfMissing(d, "round_robin_state", "last_channel", "TEXT")
|
||||
// Occupancy of a shared table. Rows written before the casino went multiplayer
|
||||
// are solo games and read as NULL, which is exactly what they are.
|
||||
addColumnIfMissing(d, "game_live_hands", "table_id", "TEXT")
|
||||
// The public detail sheet (stats + equipped gear) for an adventurer's
|
||||
// click-through page. Rides the roster snapshot; NULL on rows pushed by a
|
||||
// gogobee build that predates the detail page.
|
||||
addColumnIfMissing(d, "adventure_roster", "detail_json", "TEXT")
|
||||
|
||||
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
|
||||
// Check sqlite_master before creating.
|
||||
var ftsExists int
|
||||
d.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='stories_fts'`).Scan(&ftsExists)
|
||||
if err := d.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='stories_fts'`).Scan(&ftsExists); err != nil {
|
||||
return fmt.Errorf("probe FTS5 table: %w", err)
|
||||
}
|
||||
if ftsExists == 0 {
|
||||
if _, err := d.Exec(ftsSchema); err != nil {
|
||||
return fmt.Errorf("create FTS5 table: %w", err)
|
||||
@@ -112,17 +132,54 @@ func RunMaintenance() {
|
||||
exec("prune old reactions",
|
||||
`DELETE FROM reactions WHERE reacted_at < ?`, storyCutoff)
|
||||
|
||||
// Drop per-user read/bookmark rows whose story has been pruned above, so the
|
||||
// table can't accumulate dangling references as stories age out.
|
||||
exec("prune orphan user_story_state",
|
||||
`DELETE FROM user_story_state WHERE story_id NOT IN (SELECT id FROM stories)`)
|
||||
|
||||
// Same for per-story view counts once their story has aged out.
|
||||
exec("prune orphan story_views",
|
||||
`DELETE FROM story_views WHERE story_id NOT IN (SELECT id FROM stories)`)
|
||||
|
||||
// Daily unique tokens are only useful for the recent window; their salts are
|
||||
// long gone. page_views is kept forever (tiny aggregate, all-time totals).
|
||||
exec("prune old daily_visitors",
|
||||
`DELETE FROM daily_visitors WHERE day < ?`, unixDay()-30)
|
||||
|
||||
exec("wal checkpoint", "PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
exec("optimize", "PRAGMA optimize")
|
||||
}
|
||||
|
||||
// exec is a fire-and-forget helper that logs errors.
|
||||
// exec is a fire-and-forget helper that logs errors. Several callers run it from
|
||||
// background goroutines (metrics, view counts), which can outlive a Close() — so
|
||||
// unlike Get() it must not panic on a nil handle: it simply skips the write.
|
||||
func exec(label, query string, args ...any) {
|
||||
if _, err := Get().Exec(query, args...); err != nil {
|
||||
mu.RLock()
|
||||
db := globalDB
|
||||
mu.RUnlock()
|
||||
if db == nil {
|
||||
slog.Warn("db exec skipped: no database", "op", label)
|
||||
return
|
||||
}
|
||||
if _, err := db.Exec(query, args...); err != nil {
|
||||
slog.Error("db exec failed", "op", label, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// backfillContentChars populates content_chars for rows carrying a body but a
|
||||
// zero count — i.e. stories ingested before the column existed. LENGTH() counts
|
||||
// characters (code points) for TEXT, matching the utf8.RuneCountInString done at
|
||||
// insert. After the first run this matches no rows (bodied stories are set,
|
||||
// bodyless ones stay 0 and are filtered by content IS NOT NULL), so it's a cheap
|
||||
// startup no-op thereafter.
|
||||
func backfillContentChars(d *sql.DB) {
|
||||
if _, err := d.Exec(
|
||||
`UPDATE stories SET content_chars = LENGTH(content)
|
||||
WHERE content_chars = 0 AND content IS NOT NULL AND content <> ''`); err != nil {
|
||||
slog.Error("backfill content_chars failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func addColumnIfMissing(d *sql.DB, table, column, columnType string) {
|
||||
q := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, columnType)
|
||||
if _, err := d.Exec(q); err != nil {
|
||||
|
||||
131
internal/storage/detail.go
Normal file
131
internal/storage/detail.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// PlayerDetail is one player's private, owner-only expansion — inventory, vault,
|
||||
// house, pets — pushed by gogobee keyed by localpart. Pete stores it in its own
|
||||
// keyspace (player_self_detail) and only ever serves it back to the one
|
||||
// authenticated user it belongs to. Token rides along so the detail page can
|
||||
// prove owner↔page without ever reversing the anonymous roster token.
|
||||
type PlayerDetail struct {
|
||||
Localpart string `json:"localpart"`
|
||||
Token string `json:"token"`
|
||||
Inventory []ItemView `json:"inventory,omitempty"`
|
||||
Vault []ItemView `json:"vault,omitempty"`
|
||||
House HouseView `json:"house"`
|
||||
Pets []PetView `json:"pets,omitempty"`
|
||||
}
|
||||
|
||||
// ItemView is one backpack or vault item.
|
||||
type ItemView struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Tier int `json:"tier"`
|
||||
Value int64 `json:"value"`
|
||||
Temper int `json:"temper,omitempty"`
|
||||
}
|
||||
|
||||
// HouseView is the owner's housing summary.
|
||||
type HouseView struct {
|
||||
Tier int `json:"tier"`
|
||||
LoanBalance int `json:"loan_balance,omitempty"`
|
||||
Autopay bool `json:"autopay,omitempty"`
|
||||
Rate float64 `json:"rate,omitempty"`
|
||||
}
|
||||
|
||||
// PetView is one pet slot.
|
||||
type PetView struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
XP int `json:"xp,omitempty"`
|
||||
ArmorTier int `json:"armor_tier,omitempty"`
|
||||
}
|
||||
|
||||
// ReplacePlayerDetail swaps the whole private-detail set in one transaction —
|
||||
// replace, never merge, the same contract as the roster: a player who dropped
|
||||
// out of gogobee's push must lose their stale self-view rather than have it
|
||||
// linger. localpart is lowercased upstream to match how a session Username reads.
|
||||
func ReplacePlayerDetail(players []PlayerDetail, snapshotAt int64) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM player_self_detail`); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO player_self_detail (localpart, token, detail_json, snapshot_at)
|
||||
VALUES (?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, p := range players {
|
||||
if p.Localpart == "" || p.Token == "" {
|
||||
continue // a self-view with no owner or no page to hang on is unusable
|
||||
}
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := stmt.Exec(p.Localpart, p.Token, string(body), snapshotAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// PlayerDetailByOwner returns the private detail for localpart, but only when it
|
||||
// owns the given page token. This is the ownership join the detail page needs:
|
||||
// the signed-in user's localpart is trusted (it comes from their verified
|
||||
// session), and a row exists only if gogobee pushed that same (localpart, token)
|
||||
// pair — so a viewer can only ever unlock the self extras on their own page, and
|
||||
// Pete never has to turn a token back into a handle to decide it.
|
||||
func PlayerDetailByOwner(localpart, token string) (PlayerDetail, bool, error) {
|
||||
if localpart == "" || token == "" {
|
||||
return PlayerDetail{}, false, nil
|
||||
}
|
||||
var storedToken, detailJSON string
|
||||
err := Get().QueryRow(
|
||||
`SELECT token, detail_json FROM player_self_detail WHERE localpart = ?`, localpart).
|
||||
Scan(&storedToken, &detailJSON)
|
||||
if err == sql.ErrNoRows {
|
||||
return PlayerDetail{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return PlayerDetail{}, false, err
|
||||
}
|
||||
if storedToken != token {
|
||||
return PlayerDetail{}, false, nil // signed in, but not the owner of this page
|
||||
}
|
||||
var pd PlayerDetail
|
||||
if err := json.Unmarshal([]byte(detailJSON), &pd); err != nil {
|
||||
return PlayerDetail{}, false, err
|
||||
}
|
||||
pd.Localpart = localpart
|
||||
pd.Token = storedToken
|
||||
return pd, true, nil
|
||||
}
|
||||
|
||||
// SelfToken returns the roster token owned by localpart, if gogobee's last push
|
||||
// carried one. Lets the board mark "your adventurer" without exposing the
|
||||
// localpart↔token map anywhere public.
|
||||
func SelfToken(localpart string) (string, bool) {
|
||||
if localpart == "" {
|
||||
return "", false
|
||||
}
|
||||
var token string
|
||||
err := Get().QueryRow(
|
||||
`SELECT token FROM player_self_detail WHERE localpart = ?`, localpart).Scan(&token)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
126
internal/storage/detail_test.go
Normal file
126
internal/storage/detail_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
// The private self-detail set is the one place Pete holds a localpart↔token
|
||||
// association. These tests pin the security contract of that store: the
|
||||
// ownership join only ever unlocks a page for the localpart that owns it, and a
|
||||
// replace wipes a departed player's stale self-view rather than leaving it to
|
||||
// linger.
|
||||
|
||||
func seedSelfDetail(t *testing.T, localpart, token string) PlayerDetail {
|
||||
t.Helper()
|
||||
return PlayerDetail{
|
||||
Localpart: localpart,
|
||||
Token: token,
|
||||
Inventory: []ItemView{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}},
|
||||
Vault: []ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
|
||||
House: HouseView{Tier: 2, LoanBalance: 1500},
|
||||
Pets: []PetView{{Type: "cat", Name: "Mittens", Level: 3}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayerDetailByOwnerMatrix is the ownership matrix: the self-view unlocks
|
||||
// only when the signed-in localpart owns the exact page token. A different
|
||||
// localpart, or the owner viewing someone else's page, gets nothing.
|
||||
func TestPlayerDetailByOwnerMatrix(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
seedSelfDetail(t, "quack", "tok-quack"),
|
||||
}, 1000); err != nil {
|
||||
t.Fatalf("ReplacePlayerDetail: %v", err)
|
||||
}
|
||||
|
||||
// The owner, on their own page: unlocked, and the private goods come through.
|
||||
pd, ok, err := PlayerDetailByOwner("josie", "tok-josie")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("owner on own page: ok=%v err=%v, want unlocked", ok, err)
|
||||
}
|
||||
if len(pd.Inventory) != 1 || pd.House.Tier != 2 || len(pd.Pets) != 1 {
|
||||
t.Errorf("owner detail = %+v, want inventory+house+pet carried", pd)
|
||||
}
|
||||
|
||||
// Josie signed in, looking at Quack's page: her localpart doesn't own that
|
||||
// token, so the join must refuse — no peeking at another player's private set.
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-quack"); ok {
|
||||
t.Error("owner unlocked ANOTHER player's page — the token guard failed")
|
||||
}
|
||||
|
||||
// A signed-in stranger with no self-detail row at all.
|
||||
if _, ok, _ := PlayerDetailByOwner("nobody", "tok-josie"); ok {
|
||||
t.Error("a stranger unlocked a page they have no row for")
|
||||
}
|
||||
|
||||
// Empty inputs never unlock.
|
||||
if _, ok, _ := PlayerDetailByOwner("", "tok-josie"); ok {
|
||||
t.Error("empty localpart unlocked a page")
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", ""); ok {
|
||||
t.Error("empty token unlocked a page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplacePlayerDetailReplaces: the set is swapped whole, never merged — a
|
||||
// player gogobee stops pushing (deleted character, dropped out) must lose their
|
||||
// stale self-view, the same complete-snapshot contract as the board.
|
||||
func TestReplacePlayerDetailReplaces(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
seedSelfDetail(t, "quack", "tok-quack"),
|
||||
}, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Next push carries only Josie; Quack has left.
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
}, 1060); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("quack", "tok-quack"); ok {
|
||||
t.Error("a dropped player's self-view survived the replace")
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-josie"); !ok {
|
||||
t.Error("the surviving player lost their self-view")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplacePlayerDetailTokenFollows: when a player's board token rotates
|
||||
// (re-derived each push), the ownership check must follow it. The stale token no
|
||||
// longer unlocks; the current one does.
|
||||
func TestReplacePlayerDetailTokenFollows(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{seedSelfDetail(t, "josie", "tok-old")}, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{seedSelfDetail(t, "josie", "tok-new")}, 1060); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-old"); ok {
|
||||
t.Error("the stale token still unlocked the page after a rotation")
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-new"); !ok {
|
||||
t.Error("the current token failed to unlock the page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplacePlayerDetailSkipsUnusable: a row with no owner or no page to hang
|
||||
// on is dropped at write time — it could never be served and would only be dead
|
||||
// weight.
|
||||
func TestReplacePlayerDetailSkipsUnusable(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
{Localpart: "", Token: "tok-orphan"},
|
||||
{Localpart: "ghost", Token: ""},
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
}, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok, ok := SelfToken("josie"); !ok || tok != "tok-josie" {
|
||||
t.Errorf("SelfToken(josie) = %q,%v, want tok-josie", tok, ok)
|
||||
}
|
||||
if _, ok := SelfToken("ghost"); ok {
|
||||
t.Error("a tokenless row was stored")
|
||||
}
|
||||
}
|
||||
772
internal/storage/games.go
Normal file
772
internal/storage/games.go
Normal file
@@ -0,0 +1,772 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The chip ledger and the euro/chip border.
|
||||
//
|
||||
// Chips are euros that have crossed into the casino. They are 1:1 with euros and
|
||||
// they are not a second wallet: every chip that exists came from a euro gogobee
|
||||
// debited, and every chip destroyed becomes a euro gogobee credits back. Pete
|
||||
// never writes a euro balance. The border is crossed only by a game_escrow row,
|
||||
// whose guid is the idempotency key gogobee hands to DebitIdem/CreditIdem — so a
|
||||
// claim whose acknowledgement is lost on the wire can be retried without the
|
||||
// player paying for it twice.
|
||||
//
|
||||
// The whole reason for the border is latency. gogobee has no inbound API and is
|
||||
// not getting one, so it polls; a bet that round-tripped through a poll loop
|
||||
// would take seconds to be dealt. Instead the poll loop runs twice per *session*
|
||||
// — buy in, cash out — and every hand in between plays against chips held here,
|
||||
// at full speed, with no economy call in the hot path.
|
||||
|
||||
// MaxChipsOnTable caps how many chips a player can hold at once. A buy-in that
|
||||
// would push them over is refused before it ever reaches gogobee.
|
||||
//
|
||||
// This is the inflation brake. A web casino runs orders of magnitude more hands
|
||||
// per hour than a Matrix-paced one ever did, so whatever the house edge is, it
|
||||
// compounds far faster in both directions. The cap bounds the worst case for a
|
||||
// single sitting; the rake (see the blackjack engine) bleeds the rest back out.
|
||||
const MaxChipsOnTable int64 = 10_000
|
||||
|
||||
// EscrowStaleAfter is how long a claimed-but-unsettled escrow row waits before
|
||||
// the poll endpoint offers it again. gogobee can die between claiming a row and
|
||||
// pushing its result; without a re-offer, the player's money sits in limbo
|
||||
// forever. Re-claiming is safe precisely because the guid makes it idempotent.
|
||||
const EscrowStaleAfter = 90 * time.Second
|
||||
|
||||
// SessionIdleAfter is when the reaper decides a player has walked away and cashes
|
||||
// their chips back to euros on their behalf. Chips in an abandoned session are
|
||||
// euros in limbo, and limbo is not a state a player's money should be in.
|
||||
const SessionIdleAfter = 30 * time.Minute
|
||||
|
||||
// Escrow kinds and states. These strings cross the wire to gogobee, so they are
|
||||
// part of the contract — see §4 of pete_games_plan.md.
|
||||
const (
|
||||
KindBuyIn = "buyin"
|
||||
KindCashOut = "cashout"
|
||||
|
||||
EscrowRequested = "requested" // the player asked; gogobee hasn't seen it yet
|
||||
EscrowClaimed = "claimed" // gogobee has it and is moving the euros
|
||||
EscrowFunded = "funded" // buy-in landed; the chips are spendable
|
||||
EscrowRejected = "rejected" // buy-in refused; no chips, no euros moved
|
||||
EscrowSettled = "settled" // cash-out landed; chips destroyed, euros credited
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInsufficientChips = errors.New("games: not enough chips")
|
||||
ErrOverTableCap = errors.New("games: that would put more than the cap on the table")
|
||||
ErrBadAmount = errors.New("games: amount must be positive")
|
||||
ErrNoSuchEscrow = errors.New("games: no such escrow row")
|
||||
)
|
||||
|
||||
// Escrow is one crossing of the euro/chip border.
|
||||
type Escrow struct {
|
||||
GUID string `json:"guid"`
|
||||
MatrixUser string `json:"matrix_user"`
|
||||
Kind string `json:"kind"`
|
||||
Amount int64 `json:"amount"`
|
||||
State string `json:"state,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
BalanceAfter float64 `json:"balance_after,omitempty"`
|
||||
CreatedAt int64 `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// ChipStack is what a player has on the table right now.
|
||||
type ChipStack struct {
|
||||
Chips int64 // spendable
|
||||
// Pending is chips asked for but not yet funded — a buy-in gogobee hasn't
|
||||
// claimed or settled. Shown as "buying chips…", never spendable.
|
||||
Pending int64
|
||||
EuroBalance float64 // advisory, from the last gogobee push; may be minutes stale
|
||||
LastPlayed int64
|
||||
}
|
||||
|
||||
// newGUID mints an escrow id. It's the idempotency key for a real money move, so
|
||||
// it comes from crypto/rand rather than anything a caller could collide with.
|
||||
func newGUID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("games: mint guid: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// Chips reports a player's stack. A player who has never played has no row and
|
||||
// reads as an empty stack rather than an error.
|
||||
func Chips(user string) (ChipStack, error) {
|
||||
var st ChipStack
|
||||
var euro sql.NullFloat64
|
||||
err := Get().QueryRow(
|
||||
`SELECT chips, euro_balance, last_played FROM game_chips WHERE matrix_user = ?`, user,
|
||||
).Scan(&st.Chips, &euro, &st.LastPlayed)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return ChipStack{}, fmt.Errorf("games: read chips: %w", err)
|
||||
}
|
||||
st.EuroBalance = euro.Float64
|
||||
|
||||
if err := Get().QueryRow(
|
||||
`SELECT COALESCE(SUM(amount), 0) FROM game_escrow
|
||||
WHERE matrix_user = ? AND kind = ? AND state IN (?, ?)`,
|
||||
user, KindBuyIn, EscrowRequested, EscrowClaimed,
|
||||
).Scan(&st.Pending); err != nil {
|
||||
return ChipStack{}, fmt.Errorf("games: read pending buy-ins: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// RequestBuyIn opens a buy-in: the player wants `amount` euros turned into chips.
|
||||
// No chips exist yet — they appear only when gogobee confirms it took the euros.
|
||||
// The table cap is enforced here, against chips already held *plus* buy-ins still
|
||||
// in flight, so a player can't clear the cap by firing several at once.
|
||||
func RequestBuyIn(user string, amount int64) (Escrow, error) {
|
||||
if amount <= 0 {
|
||||
return Escrow{}, ErrBadAmount
|
||||
}
|
||||
st, err := Chips(user)
|
||||
if err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
if st.Chips+st.Pending+amount > MaxChipsOnTable {
|
||||
return Escrow{}, ErrOverTableCap
|
||||
}
|
||||
|
||||
guid, err := newGUID()
|
||||
if err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
now := nowUnix()
|
||||
if _, err := Get().Exec(
|
||||
`INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
guid, user, KindBuyIn, amount, EscrowRequested, now, now,
|
||||
); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: request buy-in: %w", err)
|
||||
}
|
||||
return Escrow{GUID: guid, MatrixUser: user, Kind: KindBuyIn, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil
|
||||
}
|
||||
|
||||
// RequestCashOut opens a cash-out: chips are destroyed *now*, and the matching
|
||||
// euros arrive when gogobee claims the row.
|
||||
//
|
||||
// Destroying them up front is what keeps the invariant true. If the chips lingered
|
||||
// until gogobee confirmed, a player could bet them while the cash-out was in
|
||||
// flight and the same euro would exist on both sides of the border. If the credit
|
||||
// somehow fails, RefundCashOut puts the chips back.
|
||||
func RequestCashOut(user string, amount int64) (Escrow, error) {
|
||||
if amount <= 0 {
|
||||
return Escrow{}, ErrBadAmount
|
||||
}
|
||||
guid, err := newGUID()
|
||||
if err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: begin cash-out: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
// Conditional update: the chips leave only if they're actually there.
|
||||
res, err := tx.Exec(
|
||||
`UPDATE game_chips SET chips = chips - ?, updated_at = ?
|
||||
WHERE matrix_user = ? AND chips >= ?`,
|
||||
amount, now, user, amount,
|
||||
)
|
||||
if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: debit chips: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return Escrow{}, ErrInsufficientChips
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
guid, user, KindCashOut, amount, EscrowRequested, now, now,
|
||||
); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: request cash-out: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: commit cash-out: %w", err)
|
||||
}
|
||||
return Escrow{GUID: guid, MatrixUser: user, Kind: KindCashOut, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil
|
||||
}
|
||||
|
||||
// PendingEscrow is what gogobee's poll loop reads: everything waiting to be moved.
|
||||
//
|
||||
// It returns rows nobody has claimed, *and* rows claimed long enough ago that we
|
||||
// have to assume gogobee died holding them. Re-offering a claimed row is safe
|
||||
// because the guid is idempotent end to end: if gogobee already moved the euros,
|
||||
// the retry is a no-op that reports the same answer.
|
||||
func PendingEscrow(limit int) ([]Escrow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
stale := nowUnix() - int64(EscrowStaleAfter.Seconds())
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, matrix_user, kind, amount, state, created_at
|
||||
FROM game_escrow
|
||||
WHERE state = ?
|
||||
OR (state = ? AND COALESCE(claimed_at, 0) < ?)
|
||||
ORDER BY created_at
|
||||
LIMIT ?`,
|
||||
EscrowRequested, EscrowClaimed, stale, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("games: pending escrow: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Escrow
|
||||
for rows.Next() {
|
||||
var e Escrow
|
||||
if err := rows.Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &e.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("games: scan escrow: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ClaimEscrow marks a row as taken by gogobee. Claiming is idempotent and is not
|
||||
// a lock: a row already claimed can be claimed again (that's how a stale re-offer
|
||||
// works), but a row already *finished* cannot be, which is what stops a settled
|
||||
// cash-out from being paid a second time.
|
||||
func ClaimEscrow(guid string) (Escrow, error) {
|
||||
now := nowUnix()
|
||||
res, err := Get().Exec(
|
||||
`UPDATE game_escrow SET state = ?, claimed_at = ?, updated_at = ?
|
||||
WHERE guid = ? AND state IN (?, ?)`,
|
||||
EscrowClaimed, now, now, guid, EscrowRequested, EscrowClaimed,
|
||||
)
|
||||
if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: claim escrow: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
// Either it doesn't exist or it's already finished. Tell the caller which.
|
||||
e, err := EscrowByGUID(guid)
|
||||
if err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
return EscrowByGUID(guid)
|
||||
}
|
||||
|
||||
// EscrowByGUID reads one row.
|
||||
func EscrowByGUID(guid string) (Escrow, error) {
|
||||
var e Escrow
|
||||
var reason sql.NullString
|
||||
var bal sql.NullFloat64
|
||||
err := Get().QueryRow(
|
||||
`SELECT guid, matrix_user, kind, amount, state, reason, balance_after, created_at
|
||||
FROM game_escrow WHERE guid = ?`, guid,
|
||||
).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &reason, &bal, &e.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Escrow{}, ErrNoSuchEscrow
|
||||
}
|
||||
if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: read escrow: %w", err)
|
||||
}
|
||||
e.Reason, e.BalanceAfter = reason.String, bal.Float64
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// SettleEscrow applies gogobee's verdict on a claimed row, and is the only place
|
||||
// chips are created or finally destroyed.
|
||||
//
|
||||
// buy-in, ok -> chips appear (funded)
|
||||
// buy-in, !ok -> nothing happens, nothing moved (rejected)
|
||||
// cash-out, ok -> chips stay destroyed, euros paid (settled)
|
||||
// cash-out, !ok -> chips come back (funded — the player never lost them)
|
||||
//
|
||||
// It is idempotent: gogobee's push queue retries, so the same verdict can arrive
|
||||
// more than once and only the first one may move chips. A row that has already
|
||||
// reached a terminal state is a no-op, not an error.
|
||||
func SettleEscrow(guid string, ok bool, reason string, balanceAfter float64) (Escrow, error) {
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: begin settle: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
var e Escrow
|
||||
var st string
|
||||
if err := tx.QueryRow(
|
||||
`SELECT guid, matrix_user, kind, amount, state FROM game_escrow WHERE guid = ?`, guid,
|
||||
).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &st); errors.Is(err, sql.ErrNoRows) {
|
||||
return Escrow{}, ErrNoSuchEscrow
|
||||
} else if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: settle lookup: %w", err)
|
||||
}
|
||||
|
||||
// Terminal already — a retried push. Report what we decided the first time.
|
||||
if st == EscrowFunded || st == EscrowRejected || st == EscrowSettled {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: commit settle: %w", err)
|
||||
}
|
||||
return EscrowByGUID(guid)
|
||||
}
|
||||
|
||||
final := EscrowFunded
|
||||
switch {
|
||||
case e.Kind == KindBuyIn && ok:
|
||||
if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
case e.Kind == KindBuyIn && !ok:
|
||||
final = EscrowRejected // gogobee took nothing, so we create nothing
|
||||
case e.Kind == KindCashOut && ok:
|
||||
final = EscrowSettled // the chips were destroyed when the row was opened
|
||||
case e.Kind == KindCashOut && !ok:
|
||||
// gogobee couldn't pay. The chips were already destroyed on our side, so
|
||||
// give them back rather than vanishing the player's money.
|
||||
if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE game_escrow SET state = ?, reason = ?, balance_after = ?, updated_at = ?
|
||||
WHERE guid = ?`,
|
||||
final, reason, balanceAfter, now, guid,
|
||||
); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: settle update: %w", err)
|
||||
}
|
||||
|
||||
// The euro balance gogobee just reported is the freshest one we'll get.
|
||||
// Advisory only — we display it, we never decide anything with it.
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE game_chips SET euro_balance = ?, updated_at = ? WHERE matrix_user = ?`,
|
||||
balanceAfter, now, e.MatrixUser,
|
||||
); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: cache euro balance: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: commit settle: %w", err)
|
||||
}
|
||||
return EscrowByGUID(guid)
|
||||
}
|
||||
|
||||
// addChips credits a stack inside an open transaction, creating the row if the
|
||||
// player has never held chips before.
|
||||
func addChips(tx *sql.Tx, user string, amount int64, now int64) error {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO UPDATE SET chips = chips + excluded.chips, updated_at = excluded.updated_at`,
|
||||
user, amount, now, now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: credit chips: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stake takes chips off a player's stack to put them at risk on a hand. It is the
|
||||
// conditional-update kind of debit: the chips leave in the same statement that
|
||||
// checks they're there, so two hands opened at once can't spend the same chip.
|
||||
func Stake(user string, amount int64) error {
|
||||
if amount <= 0 {
|
||||
return ErrBadAmount
|
||||
}
|
||||
now := nowUnix()
|
||||
res, err := Get().Exec(
|
||||
`UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ?
|
||||
WHERE matrix_user = ? AND chips >= ?`,
|
||||
amount, now, now, user, amount,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: stake: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrInsufficientChips
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Award returns chips to a player when a hand settles: stake plus winnings, net
|
||||
// of rake, exactly as the engine computed it. A losing hand awards nothing and
|
||||
// should not call this.
|
||||
//
|
||||
// This is the standalone form, for a caller with no transaction of its own. A
|
||||
// settle must not use it — see award, and the warning on CommitHand.
|
||||
func Award(user string, amount int64) error {
|
||||
if amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin award: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
if err := award(tx, user, amount, nowUnix()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit award: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// award credits a stack inside an open transaction.
|
||||
//
|
||||
// It differs from addChips in one deliberate way: it moves last_played, because
|
||||
// being paid is something that happens at a table and the reaper should see it.
|
||||
// A buy-in is not — that is why addChips leaves the idle clock alone.
|
||||
func award(tx *sql.Tx, user string, amount int64, now int64) error {
|
||||
if amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO UPDATE SET
|
||||
chips = chips + excluded.chips, last_played = excluded.last_played, updated_at = excluded.updated_at`,
|
||||
user, amount, now, now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: award chips: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Hand is one settled hand, as the audit log keeps it.
|
||||
type Hand struct {
|
||||
MatrixUser string
|
||||
Game string
|
||||
Bet int64
|
||||
Payout int64
|
||||
Rake int64
|
||||
Outcome string
|
||||
Seed1 uint64
|
||||
Seed2 uint64
|
||||
}
|
||||
|
||||
// RecordHand writes a finished hand to the audit trail. The seeds are the point:
|
||||
// with them, any hand in the log can be dealt again exactly as it fell, which is
|
||||
// how a dispute gets answered with a fact instead of an apology.
|
||||
func RecordHand(h Hand) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin record hand: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
if err := recordHand(tx, h, nowUnix()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit record hand: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordHand writes the audit row inside an open transaction.
|
||||
func recordHand(tx *sql.Tx, h Hand, now int64) error {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_hands (matrix_user, game, bet, payout, rake, outcome, seed1, seed2, played_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
h.MatrixUser, h.Game, h.Bet, h.Payout, h.Rake, h.Outcome,
|
||||
int64(h.Seed1), int64(h.Seed2), now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: record hand: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IdleStacks lists players holding chips who stopped playing a while ago. The
|
||||
// reaper cashes these out on their behalf: chips in an abandoned session are
|
||||
// euros in limbo, and they should be back in the player's balance where they can
|
||||
// see them.
|
||||
func IdleStacks(idleFor time.Duration) ([]ChipStack, []string, error) {
|
||||
cutoff := nowUnix() - int64(idleFor.Seconds())
|
||||
rows, err := Get().Query(
|
||||
`SELECT matrix_user, chips, last_played FROM game_chips
|
||||
WHERE chips > 0 AND last_played > 0 AND last_played < ?`, cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("games: idle stacks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stacks []ChipStack
|
||||
var users []string
|
||||
for rows.Next() {
|
||||
var st ChipStack
|
||||
var user string
|
||||
if err := rows.Scan(&user, &st.Chips, &st.LastPlayed); err != nil {
|
||||
return nil, nil, fmt.Errorf("games: scan idle stack: %w", err)
|
||||
}
|
||||
stacks = append(stacks, st)
|
||||
users = append(users, user)
|
||||
}
|
||||
return stacks, users, rows.Err()
|
||||
}
|
||||
|
||||
// ReapIdleSessions cashes out everyone who walked away, and reports how many it
|
||||
// sent home. Safe to run on a timer: a player who comes back simply buys in again,
|
||||
// and a cash-out that's already in flight can't be opened twice because the chips
|
||||
// are gone from the stack the moment the first one is.
|
||||
func ReapIdleSessions(idleFor time.Duration) (int, error) {
|
||||
stacks, users, err := IdleStacks(idleFor)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
reaped := 0
|
||||
for i, user := range users {
|
||||
if _, err := RequestCashOut(user, stacks[i].Chips); err != nil {
|
||||
// One player's stack failing to reap shouldn't strand everyone else's.
|
||||
if !errors.Is(err, ErrInsufficientChips) {
|
||||
return reaped, fmt.Errorf("games: reap %s: %w", user, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
reaped++
|
||||
}
|
||||
return reaped, nil
|
||||
}
|
||||
|
||||
// Touch marks a player as active, so the reaper leaves them alone. Called on any
|
||||
// deliberate action at a table — not on a page load, or an open tab would keep a
|
||||
// walked-away player's chips hostage forever.
|
||||
func Touch(user string) {
|
||||
exec("games: touch session",
|
||||
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
|
||||
nowUnix(), nowUnix(), user)
|
||||
}
|
||||
|
||||
// ---- the hand in progress -------------------------------------------------
|
||||
|
||||
var (
|
||||
// ErrNoLiveHand means the player isn't in a hand right now.
|
||||
ErrNoLiveHand = errors.New("games: no hand in progress")
|
||||
// ErrHandInProgress means they already are, and may not be dealt another.
|
||||
ErrHandInProgress = errors.New("games: already in a hand")
|
||||
)
|
||||
|
||||
// LiveHand is a hand a player is in the middle of. State is the engine's own
|
||||
// State, serialized whole — the shoe is in there, which is exactly why this row
|
||||
// never leaves the server.
|
||||
type LiveHand struct {
|
||||
Game string
|
||||
State []byte
|
||||
Seed1 uint64
|
||||
Seed2 uint64
|
||||
// TableID is set when the player is sitting at a shared table instead of playing
|
||||
// alone. The cards are then in game_tables and State here is empty: this row is
|
||||
// the occupancy claim and nothing else. One row per player either way, which is
|
||||
// the point — the primary key that stops a second solo hand is the same one that
|
||||
// stops a second seat.
|
||||
TableID string
|
||||
}
|
||||
|
||||
// StartLiveHand seats a *new* hand, and refuses if the player is already in one.
|
||||
// The plain INSERT is the point: it is the primary key, not a prior read, that
|
||||
// decides. Two Deal clicks racing each other would otherwise both see an empty
|
||||
// felt, both take a stake, and the second would overwrite the first — taking the
|
||||
// player's chips for a hand that no longer exists anywhere.
|
||||
func StartLiveHand(user string, h LiveHand) error {
|
||||
res, err := Get().Exec(
|
||||
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO NOTHING`,
|
||||
user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), nowUnix(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: start live hand: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrHandInProgress
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveLiveHand stores the hand a player is in, replacing any earlier one. The
|
||||
// player's stake has already left their stack by the time this is called, so
|
||||
// the write is what makes the hand recoverable if Pete restarts mid-deal.
|
||||
func SaveLiveHand(user string, h LiveHand) error {
|
||||
now := nowUnix()
|
||||
if _, err := Get().Exec(
|
||||
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO UPDATE SET
|
||||
game = excluded.game, state = excluded.state,
|
||||
seed1 = excluded.seed1, seed2 = excluded.seed2, updated_at = excluded.updated_at`,
|
||||
user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: save live hand: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadLiveHand returns the hand a player is in, or ErrNoLiveHand.
|
||||
func LoadLiveHand(user string) (LiveHand, error) {
|
||||
var h LiveHand
|
||||
var state string
|
||||
var s1, s2 int64
|
||||
var tableID sql.NullString
|
||||
err := Get().QueryRow(
|
||||
`SELECT game, state, seed1, seed2, table_id FROM game_live_hands WHERE matrix_user = ?`, user,
|
||||
).Scan(&h.Game, &state, &s1, &s2, &tableID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return LiveHand{}, ErrNoLiveHand
|
||||
}
|
||||
if err != nil {
|
||||
return LiveHand{}, fmt.Errorf("games: load live hand: %w", err)
|
||||
}
|
||||
h.State, h.Seed1, h.Seed2, h.TableID = []byte(state), uint64(s1), uint64(s2), tableID.String
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// ClearLiveHand ends a hand. Called when it settles — the audit log in
|
||||
// game_hands is what survives it.
|
||||
func ClearLiveHand(user string) error {
|
||||
if _, err := Get().Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil {
|
||||
return fmt.Errorf("games: clear live hand: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- the settle ------------------------------------------------------------
|
||||
|
||||
// Commit is one write-back of a game: the state, and — if the game is over —
|
||||
// everything settling it takes.
|
||||
type Commit struct {
|
||||
Live LiveHand
|
||||
Fresh bool // a game just started, which is the one write that may be refused
|
||||
|
||||
// Stake is what the player put up to open this game. It is refunded, in this
|
||||
// same transaction, if the seat turns out to be taken. Only meaningful with
|
||||
// Fresh.
|
||||
Stake int64
|
||||
|
||||
Done bool
|
||||
Payout int64 // stake plus winnings, net of rake. Zero on a loss.
|
||||
Audit Hand // the audit row. Ignored unless Done.
|
||||
}
|
||||
|
||||
// CommitHand writes a game back and settles it if it is over — all of it in one
|
||||
// transaction.
|
||||
//
|
||||
// It used to be four separate autocommit statements (save, award, record,
|
||||
// clear), which was survivable while a game belonged to exactly one player: the
|
||||
// ordering paid first and cleared second, so a crash in between left a settled
|
||||
// game on the felt, which reads as done and can be cleared. It does not survive
|
||||
// a game with a pot in it. Pay the winner, die before the state write, and the
|
||||
// table still says the hand is live — so it settles a second time and the winner
|
||||
// is paid twice. Chips minted from nothing, and gogobee will happily turn them
|
||||
// into euros.
|
||||
//
|
||||
// So: one Begin, one Commit, and the money and the state move together or not at
|
||||
// all.
|
||||
//
|
||||
// The rule this enforces, and the reason award/recordHand exist in tx-taking
|
||||
// form at all: **nothing inside here may call Get().Exec**. The pool runs at
|
||||
// MaxOpenConns(1), so a bare Exec inside an open transaction waits for the one
|
||||
// connection that this transaction is holding — forever. It is not an error, it
|
||||
// is a hung process, and since the news app shares the pool it takes that down
|
||||
// too. The tx-taking helper is the pattern; addChips has done it this way since
|
||||
// the escrow ledger was written.
|
||||
func CommitHand(user string, c Commit) error {
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin commit: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
// Seat the game first, even one that is already over — a blackjack natural
|
||||
// settles the instant it is dealt. The INSERT is what enforces one game at a
|
||||
// time, and it has to happen for *every* new one, or a natural dealt on top of
|
||||
// a game already in progress would settle, clear the felt, and take the other
|
||||
// game's stake with it.
|
||||
if c.Fresh {
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO NOTHING`,
|
||||
user, c.Live.Game, string(c.Live.State), int64(c.Live.Seed1), int64(c.Live.Seed2), now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: start live hand: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
// Somebody is already sitting here. This game was never seated, so the
|
||||
// chips it staked go back — and they go back *in this transaction*, which
|
||||
// is the point. As two statements, a crash between the refusal and the
|
||||
// refund took the player's stake for a game that never existed anywhere.
|
||||
if err := award(tx, user, c.Stake, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit refund: %w", err)
|
||||
}
|
||||
return ErrHandInProgress
|
||||
}
|
||||
} else if _, err := tx.Exec(
|
||||
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO UPDATE SET
|
||||
game = excluded.game, state = excluded.state,
|
||||
seed1 = excluded.seed1, seed2 = excluded.seed2, updated_at = excluded.updated_at`,
|
||||
user, c.Live.Game, string(c.Live.State), int64(c.Live.Seed1), int64(c.Live.Seed2), now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: save live hand: %w", err)
|
||||
}
|
||||
|
||||
if c.Done {
|
||||
if err := award(tx, user, c.Payout, now); err != nil {
|
||||
return err
|
||||
}
|
||||
// The audit row is now inside the transaction with the payout, which means a
|
||||
// failure to write it rolls the payout back rather than paying quietly and
|
||||
// logging. That is a deliberate change: the two are the same fact, and a
|
||||
// payout nobody can account for is worse than a payout that didn't happen —
|
||||
// the game stays live and settles again on the next request.
|
||||
if err := recordHand(tx, c.Audit, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil {
|
||||
return fmt.Errorf("games: clear live hand: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Touch, folded in: a deliberate action at a table, so the reaper leaves them
|
||||
// alone. A player with no chip row yet has nothing to touch, and this is a
|
||||
// no-op for them.
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
|
||||
now, now, user,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: touch session: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit hand: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HouseTake is the total rake collected since a given time — the number that
|
||||
// answers "is this economy inflating".
|
||||
func HouseTake(since int64) (int64, error) {
|
||||
var total int64
|
||||
if err := Get().QueryRow(
|
||||
`SELECT COALESCE(SUM(rake), 0) FROM game_hands WHERE played_at >= ?`, since,
|
||||
).Scan(&total); err != nil {
|
||||
return 0, fmt.Errorf("games: house take: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
494
internal/storage/games_test.go
Normal file
494
internal/storage/games_test.go
Normal file
@@ -0,0 +1,494 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const player = "@reala:parodia.dev"
|
||||
|
||||
// fund runs a buy-in all the way through the happy path, so a test that needs
|
||||
// chips on the table can just say so.
|
||||
func fund(t *testing.T, user string, amount int64) {
|
||||
t.Helper()
|
||||
e, err := RequestBuyIn(user, amount)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func chipsOf(t *testing.T, user string) int64 {
|
||||
t.Helper()
|
||||
st, err := Chips(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st.Chips
|
||||
}
|
||||
|
||||
func TestBuyIn_ChipsOnlyExistOnceGogobeeConfirms(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
e, err := RequestBuyIn(player, 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.State != EscrowRequested {
|
||||
t.Fatalf("state = %q, want %q", e.State, EscrowRequested)
|
||||
}
|
||||
|
||||
// Requested is not funded. Nothing is spendable yet — gogobee hasn't taken
|
||||
// the euros, so creating chips here would mint money out of nothing.
|
||||
st, err := Chips(player)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Chips != 0 {
|
||||
t.Fatalf("chips = %d before settlement, want 0", st.Chips)
|
||||
}
|
||||
if st.Pending != 500 {
|
||||
t.Fatalf("pending = %d, want 500", st.Pending)
|
||||
}
|
||||
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st, err = Chips(player)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Chips != 500 {
|
||||
t.Fatalf("chips = %d after funding, want 500", st.Chips)
|
||||
}
|
||||
if st.Pending != 0 {
|
||||
t.Fatalf("pending = %d after funding, want 0", st.Pending)
|
||||
}
|
||||
if st.EuroBalance != 4500 {
|
||||
t.Fatalf("advisory euro balance = %v, want 4500", st.EuroBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuyIn_RejectedCreatesNoChips(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
e, err := RequestBuyIn(player, 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 12)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != EscrowRejected {
|
||||
t.Fatalf("state = %q, want %q", got.State, EscrowRejected)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 0 {
|
||||
t.Fatalf("chips = %d after a rejected buy-in, want 0", c)
|
||||
}
|
||||
}
|
||||
|
||||
// The push queue retries. A verdict that lands twice must only move chips once.
|
||||
func TestSettle_IsIdempotent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
e, err := RequestBuyIn(player, 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
|
||||
t.Fatalf("settle %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if c := chipsOf(t, player); c != 500 {
|
||||
t.Fatalf("chips = %d after three identical pushes, want 500", c)
|
||||
}
|
||||
}
|
||||
|
||||
// A late, contradictory push must not overturn a settled row — otherwise a
|
||||
// delayed "rejected" could confiscate chips the player already won hands with.
|
||||
func TestSettle_TerminalStateWins(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
e, err := RequestBuyIn(player, 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != EscrowFunded {
|
||||
t.Fatalf("state = %q, want the original %q", got.State, EscrowFunded)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 500 {
|
||||
t.Fatalf("chips = %d, want 500 — a late rejection took funded chips", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCashOut_ChipsLeaveImmediately(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
|
||||
e, err := RequestCashOut(player, 400)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The chips are gone the moment the row opens. If they lingered until gogobee
|
||||
// confirmed, the player could bet them while the euros were also in flight —
|
||||
// the same euro on both sides of the border.
|
||||
if c := chipsOf(t, player); c != 600 {
|
||||
t.Fatalf("chips = %d immediately after cash-out, want 600", c)
|
||||
}
|
||||
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := SettleEscrow(e.GUID, true, "", 4400)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != EscrowSettled {
|
||||
t.Fatalf("state = %q, want %q", got.State, EscrowSettled)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 600 {
|
||||
t.Fatalf("chips = %d after settlement, want 600", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCashOut_FailedCreditGivesTheChipsBack(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
|
||||
e, err := RequestCashOut(player, 400)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// gogobee couldn't pay. The chips were already destroyed here, so they have to
|
||||
// come back — the player's money cannot simply evaporate at the border.
|
||||
if _, err := SettleEscrow(e.GUID, false, "ledger_error", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 1000 {
|
||||
t.Fatalf("chips = %d after a failed cash-out, want the full 1000 back", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCashOut_CannotExceedTheStack(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 100)
|
||||
|
||||
if _, err := RequestCashOut(player, 500); !errors.Is(err, ErrInsufficientChips) {
|
||||
t.Fatalf("err = %v, want ErrInsufficientChips", err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 100 {
|
||||
t.Fatalf("chips = %d after a refused cash-out, want 100", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuyIn_TableCapCountsChipsAndInFlightBuyIns(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
if _, err := RequestBuyIn(player, MaxChipsOnTable+1); !errors.Is(err, ErrOverTableCap) {
|
||||
t.Fatalf("err = %v, want ErrOverTableCap", err)
|
||||
}
|
||||
|
||||
fund(t, player, MaxChipsOnTable-1000)
|
||||
|
||||
// A second buy-in that fits is fine.
|
||||
if _, err := RequestBuyIn(player, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A third, while the second is still in flight, must not clear the cap by
|
||||
// racing — pending buy-ins count against it.
|
||||
if _, err := RequestBuyIn(player, 1); !errors.Is(err, ErrOverTableCap) {
|
||||
t.Fatalf("err = %v, want ErrOverTableCap — in-flight buy-ins must count", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequest_RejectsNonPositiveAmounts(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 100)
|
||||
|
||||
for _, amount := range []int64{0, -50} {
|
||||
if _, err := RequestBuyIn(player, amount); !errors.Is(err, ErrBadAmount) {
|
||||
t.Errorf("buy-in %d: err = %v, want ErrBadAmount", amount, err)
|
||||
}
|
||||
if _, err := RequestCashOut(player, amount); !errors.Is(err, ErrBadAmount) {
|
||||
t.Errorf("cash-out %d: err = %v, want ErrBadAmount", amount, err)
|
||||
}
|
||||
}
|
||||
if c := chipsOf(t, player); c != 100 {
|
||||
t.Fatalf("chips = %d, want 100", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStakeAndAward(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 500)
|
||||
|
||||
if err := Stake(player, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 400 {
|
||||
t.Fatalf("chips = %d after staking 100, want 400", c)
|
||||
}
|
||||
|
||||
// A win pays the stake back plus the profit, net of rake.
|
||||
if err := Award(player, 195); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 595 {
|
||||
t.Fatalf("chips = %d after a 195 payout, want 595", c)
|
||||
}
|
||||
|
||||
// You cannot bet chips you don't have.
|
||||
if err := Stake(player, 10_000); !errors.Is(err, ErrInsufficientChips) {
|
||||
t.Fatalf("err = %v, want ErrInsufficientChips", err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 595 {
|
||||
t.Fatalf("chips = %d after a refused stake, want 595", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStake_UnknownPlayerHasNothingToBet(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := Stake("@stranger:parodia.dev", 10); !errors.Is(err, ErrInsufficientChips) {
|
||||
t.Fatalf("err = %v, want ErrInsufficientChips", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingEscrow_OffersUnclaimedAndAbandonedRows(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
fresh, err := RequestBuyIn(player, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
abandoned, err := RequestBuyIn("@other:parodia.dev", 200)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pending, err := PendingEscrow(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 2 {
|
||||
t.Fatalf("%d pending rows, want 2", len(pending))
|
||||
}
|
||||
|
||||
// Claim both: neither should be offered again while gogobee is working on them.
|
||||
for _, e := range []Escrow{fresh, abandoned} {
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
pending, err = PendingEscrow(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("%d rows offered while claimed, want 0", len(pending))
|
||||
}
|
||||
|
||||
// Now pretend gogobee died holding one of them. A claim that never came back
|
||||
// must be re-offered, or the player's money sits in limbo forever.
|
||||
stale := nowUnix() - int64(EscrowStaleAfter.Seconds()) - 1
|
||||
if _, err := Get().Exec(`UPDATE game_escrow SET claimed_at = ? WHERE guid = ?`, stale, abandoned.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending, err = PendingEscrow(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].GUID != abandoned.GUID {
|
||||
t.Fatalf("stale claim was not re-offered: got %d rows", len(pending))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaim_CannotReopenAFinishedRow(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 300)
|
||||
|
||||
e, err := RequestCashOut(player, 300)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Re-claiming a settled cash-out must not walk it back to claimed, or gogobee
|
||||
// would pay the same euros out twice.
|
||||
got, err := ClaimEscrow(e.GUID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != EscrowSettled {
|
||||
t.Fatalf("state = %q after re-claiming a settled row, want %q", got.State, EscrowSettled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscrow_UnknownGUID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if _, err := EscrowByGUID("nope"); !errors.Is(err, ErrNoSuchEscrow) {
|
||||
t.Fatalf("err = %v, want ErrNoSuchEscrow", err)
|
||||
}
|
||||
if _, err := SettleEscrow("nope", true, "", 0); !errors.Is(err, ErrNoSuchEscrow) {
|
||||
t.Fatalf("err = %v, want ErrNoSuchEscrow", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaper_CashesOutTheWalkedAway(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 700)
|
||||
|
||||
// Still playing: the reaper must leave them alone.
|
||||
Touch(player)
|
||||
n, err := ReapIdleSessions(SessionIdleAfter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 || chipsOf(t, player) != 700 {
|
||||
t.Fatalf("reaped %d active players (chips now %d)", n, chipsOf(t, player))
|
||||
}
|
||||
|
||||
// Now they've been gone an hour.
|
||||
old := nowUnix() - int64((2 * time.Hour).Seconds())
|
||||
if _, err := Get().Exec(`UPDATE game_chips SET last_played = ? WHERE matrix_user = ?`, old, player); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err = ReapIdleSessions(SessionIdleAfter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("reaped %d, want 1", n)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 0 {
|
||||
t.Fatalf("chips = %d after the reaper ran, want 0 — they should be euros now", c)
|
||||
}
|
||||
|
||||
// And it must be a real cash-out, waiting for gogobee to pay it out.
|
||||
pending, err := PendingEscrow(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].Kind != KindCashOut || pending[0].Amount != 700 {
|
||||
t.Fatalf("reaper did not queue a 700 cash-out: %+v", pending)
|
||||
}
|
||||
|
||||
// Running again finds nothing left to reap.
|
||||
if n, err := ReapIdleSessions(SessionIdleAfter); err != nil || n != 0 {
|
||||
t.Fatalf("second sweep reaped %d (err=%v), want 0", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordHand_AndHouseTake(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
hands := []Hand{
|
||||
{MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 195, Rake: 5, Outcome: "win", Seed1: 42, Seed2: 7},
|
||||
{MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 0, Rake: 0, Outcome: "bust", Seed1: 43, Seed2: 8},
|
||||
{MatrixUser: player, Game: "blackjack", Bet: 200, Payout: 486, Rake: 14, Outcome: "blackjack", Seed1: 44, Seed2: 9},
|
||||
}
|
||||
for _, h := range hands {
|
||||
if err := RecordHand(h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
take, err := HouseTake(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if take != 19 {
|
||||
t.Fatalf("house take = %d, want 19", take)
|
||||
}
|
||||
|
||||
// The seeds have to survive the round trip, or a disputed hand can't be re-dealt.
|
||||
var s1, s2 int64
|
||||
if err := Get().QueryRow(
|
||||
`SELECT seed1, seed2 FROM game_hands WHERE outcome = 'blackjack'`,
|
||||
).Scan(&s1, &s2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s1 != 44 || s2 != 9 {
|
||||
t.Fatalf("seeds came back as (%d, %d), want (44, 9)", s1, s2)
|
||||
}
|
||||
}
|
||||
|
||||
// The invariant, end to end: every euro that entered the casino is either a chip
|
||||
// on the table or a euro on its way home. None are minted, none evaporate.
|
||||
func TestBorder_ChipsAreConserved(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
|
||||
// Play a losing hand and a winning one.
|
||||
if err := Stake(player, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
} // 900
|
||||
if err := Stake(player, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
} // 800
|
||||
if err := Award(player, 195); err != nil {
|
||||
t.Fatal(err)
|
||||
} // 995 — one loss, one win less rake
|
||||
|
||||
if c := chipsOf(t, player); c != 995 {
|
||||
t.Fatalf("chips = %d, want 995", c)
|
||||
}
|
||||
|
||||
e, err := RequestCashOut(player, 995)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 4995); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if c := chipsOf(t, player); c != 0 {
|
||||
t.Fatalf("chips = %d after cashing out everything, want 0", c)
|
||||
}
|
||||
// 1000 in, 995 out, 5 lost to the table and the rake. Nothing left stranded.
|
||||
st, err := Chips(player)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Pending != 0 {
|
||||
t.Fatalf("pending = %d, want 0", st.Pending)
|
||||
}
|
||||
}
|
||||
171
internal/storage/metrics.go
Normal file
171
internal/storage/metrics.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const secondsPerDay = 86400
|
||||
|
||||
// unixDay returns the current UTC day number (floor(unix / 86400)).
|
||||
func unixDay() int64 { return nowUnix() / secondsPerDay }
|
||||
|
||||
// UnixDay is the exported current UTC day number, for callers building
|
||||
// day-windowed queries (e.g. the trending rail's 7-day cutoff).
|
||||
func UnixDay() int64 { return unixDay() }
|
||||
|
||||
// RecordPageView increments the all-time and per-day view counter for a coarse
|
||||
// path label ("home", a channel slug, …). Fire-and-forget: a failure here must
|
||||
// never affect serving a page, so errors are logged and swallowed.
|
||||
func RecordPageView(path string) {
|
||||
exec("record page view",
|
||||
`INSERT INTO page_views (path, day, views) VALUES (?, ?, 1)
|
||||
ON CONFLICT(path, day) DO UPDATE SET views = views + 1`,
|
||||
path, unixDay())
|
||||
}
|
||||
|
||||
// RecordVisitor records a (already-hashed, salted) visitor token for today's
|
||||
// unique estimate. Duplicate tokens within the same day are ignored.
|
||||
func RecordVisitor(token string) {
|
||||
exec("record visitor",
|
||||
`INSERT OR IGNORE INTO daily_visitors (day, visitor) VALUES (?, ?)`,
|
||||
unixDay(), token)
|
||||
}
|
||||
|
||||
// RecordStoryView bumps the per-day read counter for a single story. Called
|
||||
// when a visitor opens the story in reader mode. Fire-and-forget like the other
|
||||
// metrics writes: a failure here must never affect serving the article.
|
||||
func RecordStoryView(id int64) {
|
||||
exec("record story view",
|
||||
`INSERT INTO story_views (story_id, day, views) VALUES (?, ?, 1)
|
||||
ON CONFLICT(story_id, day) DO UPDATE SET views = views + 1`,
|
||||
id, unixDay())
|
||||
}
|
||||
|
||||
// StoryViewTotals returns all-time read counts for the given story ids, as a
|
||||
// map keyed by id. Ids with no recorded views are simply absent from the map
|
||||
// (callers treat missing as zero). Best-effort: on error it returns whatever it
|
||||
// managed to read, so a metrics hiccup never blanks a page.
|
||||
func StoryViewTotals(ids []int64) map[int64]int {
|
||||
out := make(map[int64]int, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return out
|
||||
}
|
||||
ph, args := intInClause(ids)
|
||||
rows, err := Get().Query(
|
||||
`SELECT story_id, SUM(views) FROM story_views
|
||||
WHERE story_id IN (`+ph+`) GROUP BY story_id`, args...)
|
||||
if err != nil {
|
||||
slog.Error("metrics: story view totals query failed", "err", err)
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var n int
|
||||
if err := rows.Scan(&id, &n); err != nil {
|
||||
slog.Error("metrics: scan story view total failed", "err", err)
|
||||
continue
|
||||
}
|
||||
out[id] = n
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
slog.Error("metrics: story view totals iteration failed", "err", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// intInClause builds a "?, ?, …" placeholder string and matching args slice for
|
||||
// a SQL IN (…) over int64 ids.
|
||||
func intInClause(ids []int64) (string, []any) {
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
return placeholders(len(ids)), args
|
||||
}
|
||||
|
||||
// PathStat is one row of the per-page usage breakdown.
|
||||
type PathStat struct {
|
||||
Path string
|
||||
Total int // all-time views
|
||||
Today int // views since the start of the current UTC day
|
||||
}
|
||||
|
||||
// DayStat is one day's unique-visitor estimate.
|
||||
type DayStat struct {
|
||||
Day int64 // unix day number
|
||||
Uniques int
|
||||
}
|
||||
|
||||
// MetricsSummary is the aggregate usage readout surfaced by !petestats.
|
||||
type MetricsSummary struct {
|
||||
Pages []PathStat // per-path, busiest first
|
||||
TotalViews int // all-time, all paths
|
||||
ViewsToday int // all paths, current UTC day
|
||||
UniquesToday int // distinct visitor tokens, current UTC day
|
||||
Last7Days []DayStat // daily uniques, oldest → newest (note: salt rotates
|
||||
// daily, so these CANNOT be summed into a cross-day unique count)
|
||||
}
|
||||
|
||||
// GetMetricsSummary assembles the usage readout. Best-effort: any sub-query
|
||||
// failure leaves that field at its zero value rather than failing the whole
|
||||
// command.
|
||||
func GetMetricsSummary() MetricsSummary {
|
||||
today := unixDay()
|
||||
var m MetricsSummary
|
||||
|
||||
rows, err := Get().Query(
|
||||
`SELECT path,
|
||||
SUM(views) AS total,
|
||||
COALESCE(SUM(CASE WHEN day = ? THEN views END), 0) AS today
|
||||
FROM page_views
|
||||
GROUP BY path`, today)
|
||||
if err != nil {
|
||||
slog.Error("metrics: page_views query failed", "err", err)
|
||||
} else {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var p PathStat
|
||||
if err := rows.Scan(&p.Path, &p.Total, &p.Today); err != nil {
|
||||
slog.Error("metrics: scan page stat failed", "err", err)
|
||||
continue
|
||||
}
|
||||
m.Pages = append(m.Pages, p)
|
||||
m.TotalViews += p.Total
|
||||
m.ViewsToday += p.Today
|
||||
}
|
||||
}
|
||||
sort.Slice(m.Pages, func(i, j int) bool {
|
||||
if m.Pages[i].Total != m.Pages[j].Total {
|
||||
return m.Pages[i].Total > m.Pages[j].Total
|
||||
}
|
||||
return m.Pages[i].Path < m.Pages[j].Path
|
||||
})
|
||||
|
||||
if err := Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM daily_visitors WHERE day = ?`, today,
|
||||
).Scan(&m.UniquesToday); err != nil {
|
||||
slog.Error("metrics: uniques-today query failed", "err", err)
|
||||
}
|
||||
|
||||
since := today - 6 // inclusive 7-day window
|
||||
vrows, err := Get().Query(
|
||||
`SELECT day, COUNT(*) FROM daily_visitors
|
||||
WHERE day >= ? GROUP BY day ORDER BY day`, since)
|
||||
if err != nil {
|
||||
slog.Error("metrics: 7-day uniques query failed", "err", err)
|
||||
} else {
|
||||
defer vrows.Close()
|
||||
for vrows.Next() {
|
||||
var d DayStat
|
||||
if err := vrows.Scan(&d.Day, &d.Uniques); err != nil {
|
||||
slog.Error("metrics: scan day stat failed", "err", err)
|
||||
continue
|
||||
}
|
||||
m.Last7Days = append(m.Last7Days, d)
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
42
internal/storage/metrics_test.go
Normal file
42
internal/storage/metrics_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMetrics_ViewsAndUniques(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
// Three views on home, two on gaming.
|
||||
RecordPageView("home")
|
||||
RecordPageView("home")
|
||||
RecordPageView("home")
|
||||
RecordPageView("gaming")
|
||||
RecordPageView("gaming")
|
||||
|
||||
// Two distinct visitors today; the repeat token must not double-count.
|
||||
RecordVisitor("tok-a")
|
||||
RecordVisitor("tok-a")
|
||||
RecordVisitor("tok-b")
|
||||
|
||||
m := GetMetricsSummary()
|
||||
|
||||
if m.TotalViews != 5 {
|
||||
t.Errorf("TotalViews = %d, want 5", m.TotalViews)
|
||||
}
|
||||
if m.ViewsToday != 5 {
|
||||
t.Errorf("ViewsToday = %d, want 5", m.ViewsToday)
|
||||
}
|
||||
if m.UniquesToday != 2 {
|
||||
t.Errorf("UniquesToday = %d, want 2 (deduped)", m.UniquesToday)
|
||||
}
|
||||
if len(m.Pages) == 0 || m.Pages[0].Path != "home" || m.Pages[0].Total != 3 {
|
||||
t.Errorf("Pages[0] = %+v, want home with 3 views first", m.Pages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetrics_EmptyIsZero(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
m := GetMetricsSummary()
|
||||
if m.TotalViews != 0 || m.ViewsToday != 0 || m.UniquesToday != 0 || len(m.Pages) != 0 {
|
||||
t.Errorf("expected zero-value summary, got %+v", m)
|
||||
}
|
||||
}
|
||||
335
internal/storage/mischief.go
Normal file
335
internal/storage/mischief.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// The mischief storefront's half of the euro border.
|
||||
//
|
||||
// A buyer signs in, picks a mark off the anonymous roster board, and places a
|
||||
// hit. Pete records only the *intent*: it never moves money and never runs a
|
||||
// single game rule. gogobee's poll loop reads the pending orders, does the real
|
||||
// work against its own ledger (debit, eligibility, open the contract), and hands
|
||||
// back a verdict Pete files against the order. The guid is the idempotency key
|
||||
// end to end — gogobee passes it to DebitIdem and stamps it on the contract — so
|
||||
// a verdict whose ack is lost on the wire can be retried without the buyer paying
|
||||
// twice or the mark catching two hits for one order.
|
||||
|
||||
// MischiefOrder is one storefront order and its current standing.
|
||||
type MischiefOrder struct {
|
||||
GUID string `json:"guid"`
|
||||
BuyerSub string `json:"-"` // OIDC subject; keys "my orders", never sent to gogobee
|
||||
BuyerUsername string `json:"buyer_username"` // localpart gogobee turns into @username:server
|
||||
TargetToken string `json:"target_token"`
|
||||
TargetName string `json:"target_name"`
|
||||
Tier string `json:"tier"`
|
||||
Signed bool `json:"signed"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// Order states. These strings cross the wire to gogobee (it POSTs the verdict),
|
||||
// so they are part of the contract — see the schema and gogobee_mischief_plan.md.
|
||||
const (
|
||||
MischiefPending = "pending" // placed; gogobee hasn't acted yet
|
||||
MischiefPlaced = "placed" // gogobee debited the buyer and opened a contract
|
||||
MischiefBouncedFunds = "bounced_funds" // buyer couldn't afford it after all
|
||||
MischiefBouncedIneligible = "bounced_ineligible" // target no longer a valid mark
|
||||
)
|
||||
|
||||
// validMischiefVerdict is the set of terminal states gogobee is allowed to hand
|
||||
// back. An unknown verdict is a contract mismatch, not something to file blindly.
|
||||
func validMischiefVerdict(status string) bool {
|
||||
switch status {
|
||||
case MischiefPlaced, MischiefBouncedFunds, MischiefBouncedIneligible:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var ErrNoSuchOrder = errors.New("mischief: no such order")
|
||||
|
||||
// InsertMischiefOrder records a fresh, pending order and returns it with a new
|
||||
// guid. Money and eligibility are gogobee's problem; all Pete asserts here is
|
||||
// that the buyer is signed in and the form was well formed (checked by the
|
||||
// caller). The guid is minted here so the buyer sees a stable reference the
|
||||
// instant they submit, before gogobee has ever heard of it.
|
||||
func InsertMischiefOrder(buyerSub, buyerUsername, targetToken, targetName, tier string, signed bool) (MischiefOrder, error) {
|
||||
guid, err := newGUID()
|
||||
if err != nil {
|
||||
return MischiefOrder{}, err
|
||||
}
|
||||
now := nowUnix()
|
||||
sig := 0
|
||||
if signed {
|
||||
sig = 1
|
||||
}
|
||||
if _, err := Get().Exec(
|
||||
`INSERT INTO mischief_orders
|
||||
(guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
guid, buyerSub, buyerUsername, targetToken, targetName, tier, sig, MischiefPending, now, now,
|
||||
); err != nil {
|
||||
return MischiefOrder{}, fmt.Errorf("mischief: insert order: %w", err)
|
||||
}
|
||||
return MischiefOrder{
|
||||
GUID: guid, BuyerSub: buyerSub, BuyerUsername: buyerUsername,
|
||||
TargetToken: targetToken, TargetName: targetName, Tier: tier,
|
||||
Signed: signed, Status: MischiefPending, CreatedAt: now, UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PendingMischiefOrders is what gogobee's poll loop reads: every order still
|
||||
// waiting to be acted on. There is no claimed-but-stale window like escrow has,
|
||||
// because a pending order carries no intermediate state — if gogobee dies partway
|
||||
// through, the order simply stays pending and is offered again next poll, and the
|
||||
// guid makes the replay a no-op.
|
||||
func PendingMischiefOrders(limit int) ([]MischiefOrder, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at
|
||||
FROM mischief_orders
|
||||
WHERE status = ?
|
||||
ORDER BY created_at
|
||||
LIMIT ?`,
|
||||
MischiefPending, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mischief: pending orders: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMischiefOrders(rows)
|
||||
}
|
||||
|
||||
// ResolveMischiefOrder files gogobee's verdict against a pending order. It is
|
||||
// idempotent: gogobee's poll loop can re-offer and re-resolve the same order, so
|
||||
// a verdict that arrives twice is a no-op the second time. Only a pending order
|
||||
// moves; an order already in a terminal state reports what it already decided,
|
||||
// which is what stops a retried verdict from overwriting the first one.
|
||||
func ResolveMischiefOrder(guid, status, detail string) (MischiefOrder, error) {
|
||||
if !validMischiefVerdict(status) {
|
||||
return MischiefOrder{}, fmt.Errorf("mischief: bad verdict %q", status)
|
||||
}
|
||||
now := nowUnix()
|
||||
if _, err := Get().Exec(
|
||||
`UPDATE mischief_orders SET status = ?, detail = ?, updated_at = ?
|
||||
WHERE guid = ? AND status = ?`,
|
||||
status, detail, now, guid, MischiefPending,
|
||||
); err != nil {
|
||||
return MischiefOrder{}, fmt.Errorf("mischief: resolve order: %w", err)
|
||||
}
|
||||
// Whether the update moved a pending order or matched nothing (missing, or
|
||||
// already terminal from an earlier verdict), the current row is the
|
||||
// authoritative answer — reading it back is the single path either way, and
|
||||
// MischiefOrderByGUID turns a missing row into ErrNoSuchOrder for the caller.
|
||||
return MischiefOrderByGUID(guid)
|
||||
}
|
||||
|
||||
// MischiefOrderByGUID reads one order.
|
||||
func MischiefOrderByGUID(guid string) (MischiefOrder, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at
|
||||
FROM mischief_orders WHERE guid = ?`, guid,
|
||||
)
|
||||
if err != nil {
|
||||
return MischiefOrder{}, fmt.Errorf("mischief: read order: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out, err := scanMischiefOrders(rows)
|
||||
if err != nil {
|
||||
return MischiefOrder{}, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return MischiefOrder{}, ErrNoSuchOrder
|
||||
}
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
// MischiefOrdersByBuyer returns a buyer's own recent orders, newest first, for
|
||||
// the storefront status panel. Keyed on the OIDC subject so a username change
|
||||
// doesn't strand a buyer's history.
|
||||
func MischiefOrdersByBuyer(buyerSub string, limit int) ([]MischiefOrder, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at
|
||||
FROM mischief_orders
|
||||
WHERE buyer_sub = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`,
|
||||
buyerSub, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mischief: orders by buyer: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMischiefOrders(rows)
|
||||
}
|
||||
|
||||
// CountMischiefOrdersSince counts how many orders a buyer has placed since a unix
|
||||
// cutoff. It backs the storefront's burst guard — the real economic caps (per
|
||||
// day, per target, boss weekly) live on gogobee, this only blunts form-spam.
|
||||
func CountMischiefOrdersSince(buyerSub string, since int64) (int, error) {
|
||||
var n int
|
||||
err := Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM mischief_orders WHERE buyer_sub = ? AND created_at >= ?`,
|
||||
buyerSub, since,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("mischief: count recent orders: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func scanMischiefOrders(rows *sql.Rows) ([]MischiefOrder, error) {
|
||||
var out []MischiefOrder
|
||||
for rows.Next() {
|
||||
var o MischiefOrder
|
||||
var sig int
|
||||
if err := rows.Scan(&o.GUID, &o.BuyerSub, &o.BuyerUsername, &o.TargetToken,
|
||||
&o.TargetName, &o.Tier, &sig, &o.Status, &o.Detail, &o.CreatedAt, &o.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("mischief: scan order: %w", err)
|
||||
}
|
||||
o.Signed = sig != 0
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---- user_euro: the buyer's own advisory balance -------------------------------
|
||||
|
||||
// ReplaceUserEuro swaps the whole balance table for a new snapshot, in one
|
||||
// transaction, mirroring the roster: a buyer gogobee stopped reporting (opted
|
||||
// out, deleted character) must drop out rather than keep a frozen number forever.
|
||||
// Replace — never merge.
|
||||
func ReplaceUserEuro(balances []MischiefBalance, snapshotAt int64) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM user_euro`); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO user_euro (username, euro, snapshot_at) VALUES (?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, b := range balances {
|
||||
if b.Username == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.Exec(b.Username, b.Euro, snapshotAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// MischiefBalance is one buyer's advisory euro balance in the roster push.
|
||||
type MischiefBalance struct {
|
||||
Username string `json:"username"`
|
||||
Euro float64 `json:"euro"`
|
||||
}
|
||||
|
||||
// ---- mischief_tiers: the price catalog, pushed by gogobee -----------------------
|
||||
|
||||
// MischiefTier is one rung of the storefront's price list. gogobee is the sole
|
||||
// authority on prices — it pushes the whole catalog on the roster tick so a fee
|
||||
// retune reaches the storefront within a snapshot and Pete never hardcodes a
|
||||
// number that can silently drift. Pete uses it only to render and to validate a
|
||||
// submitted tier key; the real debit is always gogobee's, at its own price.
|
||||
type MischiefTier struct {
|
||||
Key string `json:"key"`
|
||||
Display string `json:"display"`
|
||||
Fee int `json:"fee"`
|
||||
SignedFee int `json:"signed_fee"`
|
||||
Blurb string `json:"blurb,omitempty"`
|
||||
}
|
||||
|
||||
// ReplaceMischiefTiers swaps the whole catalog for gogobee's latest, preserving
|
||||
// the push order (grunt→boss) via an ordinal column. Replace, never merge — a
|
||||
// tier gogobee dropped must disappear from the storefront.
|
||||
func ReplaceMischiefTiers(tiers []MischiefTier) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM mischief_tiers`); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO mischief_tiers (key, display, fee, signed_fee, blurb, ordinal) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for i, t := range tiers {
|
||||
if t.Key == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.Exec(t.Key, t.Display, t.Fee, t.SignedFee, t.Blurb, i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// MischiefTiers returns the catalog in push order. Empty (not an error) until
|
||||
// gogobee has pushed one — the storefront treats that as "catalog not ready yet".
|
||||
func MischiefTiers() ([]MischiefTier, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT key, display, fee, signed_fee, COALESCE(blurb, '') FROM mischief_tiers ORDER BY ordinal`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mischief: read tiers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MischiefTier
|
||||
for rows.Next() {
|
||||
var t MischiefTier
|
||||
if err := rows.Scan(&t.Key, &t.Display, &t.Fee, &t.SignedFee, &t.Blurb); err != nil {
|
||||
return nil, fmt.Errorf("mischief: scan tier: %w", err)
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MischiefTierByKey looks up one tier for validating a submitted order.
|
||||
func MischiefTierByKey(key string) (MischiefTier, bool, error) {
|
||||
tiers, err := MischiefTiers()
|
||||
if err != nil {
|
||||
return MischiefTier{}, false, err
|
||||
}
|
||||
for _, t := range tiers {
|
||||
if t.Key == key {
|
||||
return t, true, nil
|
||||
}
|
||||
}
|
||||
return MischiefTier{}, false, nil
|
||||
}
|
||||
|
||||
// UserEuro reads one buyer's advisory balance. The bool is false when gogobee has
|
||||
// never reported a balance for them (never played, opted out) — the storefront
|
||||
// then shows tiers without an affordability hint rather than a misleading €0.
|
||||
func UserEuro(username string) (float64, bool, error) {
|
||||
var euro float64
|
||||
err := Get().QueryRow(`SELECT euro FROM user_euro WHERE username = ?`, username).Scan(&euro)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("mischief: read user euro: %w", err)
|
||||
}
|
||||
return euro, true, nil
|
||||
}
|
||||
178
internal/storage/mischief_test.go
Normal file
178
internal/storage/mischief_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMischiefOrderLifecycle(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
o, err := InsertMischiefOrder("sub-1", "reala", "tok-josie", "Josie", "elite", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o.Status != MischiefPending {
|
||||
t.Fatalf("fresh order status = %q, want pending", o.Status)
|
||||
}
|
||||
if !o.Signed {
|
||||
t.Error("signed flag lost through insert")
|
||||
}
|
||||
|
||||
pending, err := PendingMischiefOrders(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].GUID != o.GUID {
|
||||
t.Fatalf("pending = %+v, want the one order we just placed", pending)
|
||||
}
|
||||
|
||||
// gogobee places the contract.
|
||||
got, err := ResolveMischiefOrder(o.GUID, MischiefPlaced, "the word is out")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != MischiefPlaced || got.Detail != "the word is out" {
|
||||
t.Fatalf("resolved order = %+v, want placed with detail", got)
|
||||
}
|
||||
|
||||
// It must leave the pending set.
|
||||
if pending, _ := PendingMischiefOrders(10); len(pending) != 0 {
|
||||
t.Fatalf("placed order still pending: %+v", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMischiefResolveIsIdempotent is the whole reason the guid is an end-to-end
|
||||
// key: gogobee's poll loop retries, so a verdict can arrive twice, and the second
|
||||
// arrival must not overwrite the first or error.
|
||||
func TestMischiefResolveIsIdempotent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
o, err := InsertMischiefOrder("sub-1", "reala", "tok", "Josie", "grunt", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ResolveMischiefOrder(o.GUID, MischiefPlaced, "first"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second, *different* verdict arrives. First one wins.
|
||||
got, err := ResolveMischiefOrder(o.GUID, MischiefBouncedFunds, "second")
|
||||
if err != nil {
|
||||
t.Fatalf("re-resolve errored: %v", err)
|
||||
}
|
||||
if got.Status != MischiefPlaced || got.Detail != "first" {
|
||||
t.Fatalf("idempotency broken: order became %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMischiefResolveUnknownAndBadVerdict(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
if _, err := ResolveMischiefOrder("nope", MischiefPlaced, ""); !errors.Is(err, ErrNoSuchOrder) {
|
||||
t.Fatalf("unknown guid err = %v, want ErrNoSuchOrder", err)
|
||||
}
|
||||
|
||||
o, _ := InsertMischiefOrder("sub-1", "reala", "tok", "Josie", "grunt", false)
|
||||
if _, err := ResolveMischiefOrder(o.GUID, "exploded", ""); err == nil {
|
||||
t.Error("a bogus verdict status was accepted")
|
||||
}
|
||||
// The order must survive a rejected verdict as still-pending.
|
||||
if got, _ := MischiefOrderByGUID(o.GUID); got.Status != MischiefPending {
|
||||
t.Fatalf("order moved off pending on a bad verdict: %q", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMischiefOrdersByBuyerAndCount(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := InsertMischiefOrder("sub-A", "alice", "tok", "Josie", "grunt", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := InsertMischiefOrder("sub-B", "bob", "tok", "Josie", "grunt", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mine, err := MischiefOrdersByBuyer("sub-A", 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mine) != 3 {
|
||||
t.Fatalf("alice sees %d orders, want 3 (and none of bob's)", len(mine))
|
||||
}
|
||||
|
||||
n, err := CountMischiefOrdersSince("sub-A", time.Now().Add(-time.Hour).Unix())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Fatalf("count since an hour ago = %d, want 3", n)
|
||||
}
|
||||
if n, _ := CountMischiefOrdersSince("sub-A", time.Now().Add(time.Hour).Unix()); n != 0 {
|
||||
t.Fatalf("count since the future = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMischiefTiersReplaceAndLookup(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
tiers := []MischiefTier{
|
||||
{Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50, Blurb: "theatre"},
|
||||
{Key: "boss", Display: "Boss", Fee: 1200, SignedFee: 1500},
|
||||
}
|
||||
if err := ReplaceMischiefTiers(tiers); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := MischiefTiers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Key != "grunt" || got[1].Key != "boss" {
|
||||
t.Fatalf("catalog order not preserved: %+v", got)
|
||||
}
|
||||
|
||||
tier, ok, err := MischiefTierByKey("boss")
|
||||
if err != nil || !ok || tier.SignedFee != 1500 {
|
||||
t.Fatalf("lookup boss = %+v ok=%v err=%v", tier, ok, err)
|
||||
}
|
||||
if _, ok, _ := MischiefTierByKey("dragon"); ok {
|
||||
t.Error("lookup invented a tier that was never pushed")
|
||||
}
|
||||
|
||||
// Replace, never merge: a dropped tier vanishes.
|
||||
if err := ReplaceMischiefTiers([]MischiefTier{{Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := MischiefTierByKey("boss"); ok {
|
||||
t.Error("a tier dropped from the push survived the replace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEuroReplaceAndRead(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
if err := ReplaceUserEuro([]MischiefBalance{{Username: "reala", Euro: 820.5}}, time.Now().Unix()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
euro, has, err := UserEuro("reala")
|
||||
if err != nil || !has || euro != 820.5 {
|
||||
t.Fatalf("UserEuro(reala) = %v has=%v err=%v", euro, has, err)
|
||||
}
|
||||
|
||||
// A user gogobee has never reported reads as "unknown", not €0 — the
|
||||
// storefront shows no hint rather than a discouraging, wrong zero.
|
||||
if _, has, _ := UserEuro("stranger"); has {
|
||||
t.Error("UserEuro claimed to know a stranger's balance")
|
||||
}
|
||||
|
||||
// Replace drops anyone the new snapshot omits.
|
||||
if err := ReplaceUserEuro([]MischiefBalance{{Username: "bob", Euro: 10}}, time.Now().Unix()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, has, _ := UserEuro("reala"); has {
|
||||
t.Error("a balance that fell out of the snapshot survived the replace")
|
||||
}
|
||||
}
|
||||
50
internal/storage/prefs.go
Normal file
50
internal/storage/prefs.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// UserPrefs is one signed-in user's stored state. Prefs is an opaque JSON blob
|
||||
// owned by the web frontend (disabled feeds, weather location, toggles, …) —
|
||||
// the backend just persists and returns it, keyed by the OIDC subject.
|
||||
type UserPrefs struct {
|
||||
Sub string
|
||||
Prefs string
|
||||
Username string
|
||||
Email string
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// GetUserPrefs returns the stored prefs blob for an OIDC subject. It returns
|
||||
// ("", nil) when the user has no record yet (first sign-in).
|
||||
func GetUserPrefs(sub string) (string, error) {
|
||||
var prefs string
|
||||
err := Get().QueryRow(`SELECT prefs FROM user_preferences WHERE user_sub = ?`, sub).Scan(&prefs)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get user prefs: %w", err)
|
||||
}
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
// PutUserPrefs upserts a user's prefs blob along with identity hints (username,
|
||||
// email) for later features. The prefs string is stored verbatim.
|
||||
func PutUserPrefs(sub, prefs, username, email string) error {
|
||||
_, err := Get().Exec(`
|
||||
INSERT INTO user_preferences (user_sub, prefs, username, email, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_sub) DO UPDATE SET
|
||||
prefs = excluded.prefs,
|
||||
username = excluded.username,
|
||||
email = excluded.email,
|
||||
updated_at = excluded.updated_at`,
|
||||
sub, prefs, username, email, nowUnix())
|
||||
if err != nil {
|
||||
return fmt.Errorf("put user prefs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
44
internal/storage/prefs_test.go
Normal file
44
internal/storage/prefs_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserPrefsRoundTrip(t *testing.T) {
|
||||
if err := Init(filepath.Join(t.TempDir(), "prefs.db")); err != nil {
|
||||
t.Fatalf("init db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = Close() })
|
||||
|
||||
// Missing user → empty, no error.
|
||||
got, err := GetUserPrefs("nobody")
|
||||
if err != nil {
|
||||
t.Fatalf("get missing: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty for missing user, got %q", got)
|
||||
}
|
||||
|
||||
blob := `{"pete.weather.loc.v1":"{\"postal\":\"1000\"}"}`
|
||||
if err := PutUserPrefs("ak-sub-1", blob, "misaki", "m@example.com"); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
got, err = GetUserPrefs("ak-sub-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got != blob {
|
||||
t.Fatalf("blob mismatch:\n got %q\nwant %q", got, blob)
|
||||
}
|
||||
|
||||
// Upsert: second put for same sub replaces the blob.
|
||||
blob2 := `{"pete-weather-off":"1"}`
|
||||
if err := PutUserPrefs("ak-sub-1", blob2, "misaki", "m@example.com"); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
got, _ = GetUserPrefs("ak-sub-1")
|
||||
if got != blob2 {
|
||||
t.Fatalf("upsert mismatch: got %q want %q", got, blob2)
|
||||
}
|
||||
}
|
||||
115
internal/storage/push.go
Normal file
115
internal/storage/push.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package storage
|
||||
|
||||
import "fmt"
|
||||
|
||||
// PushSubscription is one browser/device endpoint a signed-in user has opted in
|
||||
// for Web Push digests. See the push_subscriptions schema for the field roles.
|
||||
type PushSubscription struct {
|
||||
Endpoint string
|
||||
UserSub string
|
||||
P256dh string
|
||||
Auth string
|
||||
CreatedAt int64
|
||||
LastNotifiedAt int64
|
||||
}
|
||||
|
||||
// AddPushSubscription records (or refreshes) a push endpoint for a user. The
|
||||
// endpoint is the primary key, so a re-subscribe from the same browser updates
|
||||
// the keys and resets the digest watermark to now — the user shouldn't be
|
||||
// paged for everything published before they opted in.
|
||||
func AddPushSubscription(sub, endpoint, p256dh, auth string) error {
|
||||
now := nowUnix()
|
||||
_, err := Get().Exec(`
|
||||
INSERT INTO push_subscriptions (endpoint, user_sub, p256dh, auth, created_at, last_notified_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
user_sub = excluded.user_sub,
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
last_notified_at = excluded.last_notified_at`,
|
||||
endpoint, sub, p256dh, auth, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add push subscription: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovePushSubscription drops one endpoint regardless of owner. Reserved for
|
||||
// the digest sender's prune path, where a push service has reported the endpoint
|
||||
// gone (404/410) and there's no caller identity to scope by. User-initiated
|
||||
// opt-outs must use RemovePushSubscriptionForUser.
|
||||
func RemovePushSubscription(endpoint string) error {
|
||||
_, err := Get().Exec(`DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove push subscription: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovePushSubscriptionForUser drops an endpoint only if it belongs to sub, so
|
||||
// a signed-in user can't unsubscribe another account's device by presenting its
|
||||
// endpoint string. A no-op (no matching row) is not an error.
|
||||
func RemovePushSubscriptionForUser(sub, endpoint string) error {
|
||||
_, err := Get().Exec(
|
||||
`DELETE FROM push_subscriptions WHERE endpoint = ? AND user_sub = ?`, endpoint, sub)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove push subscription: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPushSubscriptions returns every stored subscription, for the digest sender.
|
||||
func ListPushSubscriptions() ([]PushSubscription, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT endpoint, user_sub, p256dh, auth, created_at, last_notified_at
|
||||
FROM push_subscriptions`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PushSubscription
|
||||
for rows.Next() {
|
||||
var p PushSubscription
|
||||
if err := rows.Scan(&p.Endpoint, &p.UserSub, &p.P256dh, &p.Auth, &p.CreatedAt, &p.LastNotifiedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TouchPushSubscription advances an endpoint's digest watermark so its next
|
||||
// digest only considers stories seen after ts.
|
||||
func TouchPushSubscription(endpoint string, ts int64) error {
|
||||
_, err := Get().Exec(
|
||||
`UPDATE push_subscriptions SET last_notified_at = ? WHERE endpoint = ?`, ts, endpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("touch push subscription: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewClassifiedSince returns classified stories first seen after sinceUnix,
|
||||
// newest first, capped at limit. The digest sender uses it to count and preview
|
||||
// what's new for a subscriber; it carries just the fields a digest needs.
|
||||
func NewClassifiedSince(sinceUnix int64, limit int) ([]Story, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT id, headline, source, channel, seen_at
|
||||
FROM stories
|
||||
WHERE classified = 1 AND channel NOT IN ('_discarded', '_duplicate') AND seen_at > ?
|
||||
ORDER BY seen_at DESC
|
||||
LIMIT ?`, sinceUnix, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
if err := rows.Scan(&s.ID, &s.Headline, &s.Source, &s.Channel, &s.SeenAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
105
internal/storage/push_test.go
Normal file
105
internal/storage/push_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPushSubscriptionLifecycle(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
if err := AddPushSubscription("sub-1", "https://push.example/ep-a", "p256-a", "auth-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second endpoint for the same user (e.g. a second device).
|
||||
if err := AddPushSubscription("sub-1", "https://push.example/ep-b", "p256-b", "auth-b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A different user.
|
||||
if err := AddPushSubscription("sub-2", "https://push.example/ep-c", "p256-c", "auth-c"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
subs, err := ListPushSubscriptions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(subs) != 3 {
|
||||
t.Fatalf("got %d subscriptions, want 3", len(subs))
|
||||
}
|
||||
|
||||
// Re-subscribing the same endpoint updates keys in place, not a new row.
|
||||
if err := AddPushSubscription("sub-1", "https://push.example/ep-a", "p256-a2", "auth-a2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subs, _ = ListPushSubscriptions()
|
||||
if len(subs) != 3 {
|
||||
t.Fatalf("after re-subscribe got %d rows, want 3 (upsert, not insert)", len(subs))
|
||||
}
|
||||
var epA PushSubscription
|
||||
for _, s := range subs {
|
||||
if s.Endpoint == "https://push.example/ep-a" {
|
||||
epA = s
|
||||
}
|
||||
}
|
||||
if epA.P256dh != "p256-a2" || epA.Auth != "auth-a2" {
|
||||
t.Errorf("re-subscribe did not refresh keys: %+v", epA)
|
||||
}
|
||||
|
||||
if err := RemovePushSubscription("https://push.example/ep-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subs, _ = ListPushSubscriptions()
|
||||
if len(subs) != 2 {
|
||||
t.Fatalf("after remove got %d rows, want 2", len(subs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchPushSubscriptionAdvancesWatermark(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := AddPushSubscription("sub-1", "https://push.example/ep", "p", "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subs, _ := ListPushSubscriptions()
|
||||
orig := subs[0].LastNotifiedAt
|
||||
|
||||
want := orig + 5000
|
||||
if err := TouchPushSubscription("https://push.example/ep", want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subs, _ = ListPushSubscriptions()
|
||||
if subs[0].LastNotifiedAt != want {
|
||||
t.Errorf("watermark = %d, want %d", subs[0].LastNotifiedAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClassifiedSince(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
now := nowUnix()
|
||||
|
||||
insert := func(guid, headline, source, channel string, classified bool, seenAt int64) {
|
||||
if err := InsertStory(&Story{
|
||||
GUID: guid, Headline: headline, ArticleURL: "https://x/" + guid,
|
||||
Source: source, Channel: channel, Classified: classified, SeenAt: seenAt,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
insert("old", "Old news", "Feed A", "tech", true, now-1000)
|
||||
insert("fresh1", "Fresh one", "Feed A", "tech", true, now-100)
|
||||
insert("fresh2", "Fresh two", "Feed B", "gaming", true, now-50)
|
||||
insert("unclassified", "Pending", "Feed A", "", false, now-10)
|
||||
insert("discarded", "Junk", "Feed A", "_discarded", true, now-10)
|
||||
|
||||
got, err := NewClassifiedSince(now-500, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d stories, want 2 (fresh classified, non-discarded, after watermark)", len(got))
|
||||
}
|
||||
// Newest first.
|
||||
if got[0].GUID != "" && got[0].Headline != "Fresh two" {
|
||||
t.Errorf("first result = %q, want newest 'Fresh two'", got[0].Headline)
|
||||
}
|
||||
if got[0].Source != "Feed B" || got[1].Source != "Feed A" {
|
||||
t.Errorf("unexpected order/sources: %q then %q", got[0].Source, got[1].Source)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func nowUnix() int64 {
|
||||
@@ -33,14 +34,38 @@ func InsertStory(s *Story) error {
|
||||
if s.Paywalled {
|
||||
paywalled = 1
|
||||
}
|
||||
var publishedAt any
|
||||
if s.PublishedAt > 0 {
|
||||
publishedAt = s.PublishedAt
|
||||
}
|
||||
_, err := Get().Exec(
|
||||
`INSERT INTO stories (guid, headline, lede, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
s.GUID, s.Headline, s.Lede, s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt,
|
||||
`INSERT INTO stories (guid, headline, lede, content, content_chars, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
s.GUID, s.Headline, s.Lede, nullIfEmpty(s.Content), utf8.RuneCountInString(s.Content), s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt, publishedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetStoryReaderText returns the stored full article text and lede for a single
|
||||
// story, for reader mode. found is false when no story has that id. The query
|
||||
// mirrors the visible-story filter (classified, non-sentinel channel) so the
|
||||
// public /api/article endpoint can't be enumerated to pull the captured bodies
|
||||
// of discarded or not-yet-classified stories that never surface in the UI.
|
||||
func GetStoryReaderText(id int64) (content, lede string, found bool, err error) {
|
||||
var c sql.NullString
|
||||
var l sql.NullString
|
||||
row := Get().QueryRow(
|
||||
`SELECT content, lede FROM stories
|
||||
WHERE id = ? AND classified = 1 AND channel NOT IN ('_discarded', '_duplicate')`, id)
|
||||
switch err = row.Scan(&c, &l); {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return "", "", false, nil
|
||||
case err != nil:
|
||||
return "", "", false, err
|
||||
}
|
||||
return c.String, l.String, true, nil
|
||||
}
|
||||
|
||||
// nullIfEmpty returns a SQL NULL for empty strings so partial unique indexes
|
||||
// (WHERE col IS NOT NULL) can hold multiple "unknown" rows without conflict.
|
||||
func nullIfEmpty(s string) any {
|
||||
@@ -107,10 +132,10 @@ func MarkClassified(guid, channel, platforms string) {
|
||||
// GetStoryByGUID returns the full story record for a GUID, or nil if not found.
|
||||
func GetStoryByGUID(guid string) (*Story, error) {
|
||||
row := Get().QueryRow(
|
||||
`SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at
|
||||
`SELECT guid, headline, lede, COALESCE(content, ''), image_url, article_url, source, platforms, channel, seen_at
|
||||
FROM stories WHERE guid = ?`, guid)
|
||||
var s Story
|
||||
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
|
||||
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.Content, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
@@ -118,10 +143,16 @@ func GetStoryByGUID(guid string) (*Story, error) {
|
||||
|
||||
// InsertPostLog records that a story was posted to a channel.
|
||||
// Uses OR IGNORE to prevent duplicate posts for the same story+channel.
|
||||
func InsertPostLog(guid, channel, eventID, urlCanonical string) {
|
||||
// forced=true marks the row as a manual !post override so it doesn't count
|
||||
// against the global daily cap.
|
||||
func InsertPostLog(guid, channel, eventID, urlCanonical string, forced bool) {
|
||||
f := 0
|
||||
if forced {
|
||||
f = 1
|
||||
}
|
||||
exec("insert post_log",
|
||||
`INSERT OR IGNORE INTO post_log (guid, channel, event_id, url_canonical, posted_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
guid, channel, eventID, nullIfEmpty(urlCanonical), nowUnix())
|
||||
`INSERT OR IGNORE INTO post_log (guid, channel, event_id, url_canonical, posted_at, forced) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
guid, channel, eventID, nullIfEmpty(urlCanonical), nowUnix(), f)
|
||||
}
|
||||
|
||||
// GetLastPostTime returns the unix timestamp of the most recent post to a channel.
|
||||
@@ -135,10 +166,11 @@ func GetLastPostTime(channel string) int64 {
|
||||
}
|
||||
|
||||
// CountAllPostsInWindow counts posts across ALL channels within a time window.
|
||||
// Used for the global daily cap.
|
||||
// Used for the global daily cap. Excludes forced (!post) entries so manual
|
||||
// overrides don't eat into the auto-rotation budget.
|
||||
func CountAllPostsInWindow(windowStart int64) int {
|
||||
var count int
|
||||
if err := Get().QueryRow(`SELECT COUNT(*) FROM post_log WHERE posted_at >= ?`, windowStart).Scan(&count); err != nil {
|
||||
if err := Get().QueryRow(`SELECT COUNT(*) FROM post_log WHERE posted_at >= ? AND forced = 0`, windowStart).Scan(&count); err != nil {
|
||||
slog.Error("CountAllPostsInWindow query failed", "err", err)
|
||||
return 1<<31 - 1 // fail closed: huge number prevents posting
|
||||
}
|
||||
@@ -185,7 +217,7 @@ func GetNewestPostableStory(source string) (*Story, error) {
|
||||
AND channel IS NOT NULL
|
||||
AND channel NOT IN ('_discarded', '_duplicate')
|
||||
AND guid NOT IN (SELECT guid FROM post_log)
|
||||
ORDER BY seen_at DESC
|
||||
ORDER BY COALESCE(published_at, seen_at) DESC
|
||||
LIMIT 1`, source)
|
||||
var s Story
|
||||
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
|
||||
@@ -208,7 +240,7 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
|
||||
WHERE classified = 1
|
||||
AND channel = ?
|
||||
AND guid NOT IN (SELECT guid FROM post_log)
|
||||
ORDER BY seen_at DESC
|
||||
ORDER BY COALESCE(published_at, seen_at) DESC
|
||||
LIMIT 1`, channel)
|
||||
var s Story
|
||||
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
|
||||
@@ -220,17 +252,64 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// UnpostedAdventureSince returns adventure dispatches seen at or after `since`
|
||||
// that have never been posted to Matrix (no post_log row). PRIORITY beats post
|
||||
// live and so carry a post_log row; the ones left are exactly the BULLETINs the
|
||||
// daily digest collects. Oldest first so the digest reads chronologically.
|
||||
//
|
||||
// At most `limit` rows come back, but total is how many match in all — the digest
|
||||
// quotes it to readers, so it must count the window rather than the returned page.
|
||||
func UnpostedAdventureSince(since int64, limit int) (stories []Story, total int, err error) {
|
||||
const where = `WHERE classified = 1
|
||||
AND channel = 'adventure'
|
||||
AND seen_at >= ?
|
||||
AND guid NOT IN (SELECT guid FROM post_log)`
|
||||
|
||||
if err := Get().QueryRow(`SELECT COUNT(*) FROM stories `+where, since).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, headline, lede, article_url, seen_at
|
||||
FROM stories `+where+`
|
||||
ORDER BY seen_at ASC
|
||||
LIMIT ?`, since, limit)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ArticleURL, &s.SeenAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// MarkAdventureDigested records each bulletin guid as posted (in a shared digest
|
||||
// event) so the next daily digest doesn't re-collect it. Idempotent per guid via
|
||||
// the post_log OR IGNORE. eventID is the digest's synthetic key (e.g.
|
||||
// "adv-digest:2026-07-11") shared by every story that went out in that digest.
|
||||
func MarkAdventureDigested(guids []string, eventID string) {
|
||||
for _, g := range guids {
|
||||
InsertPostLog(g, "adventure", eventID, "", false)
|
||||
}
|
||||
}
|
||||
|
||||
// ListClassifiedByChannel returns up to `limit` classified stories routed to a
|
||||
// real channel, newest first, with optional offset for pagination. Sentinel
|
||||
// channels (_discarded, _duplicate) are excluded.
|
||||
func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
|
||||
`SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
|
||||
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
|
||||
FROM stories s
|
||||
WHERE s.classified = 1
|
||||
AND s.channel = ?
|
||||
ORDER BY s.seen_at DESC
|
||||
ORDER BY COALESCE(s.published_at, s.seen_at) DESC
|
||||
LIMIT ? OFFSET ?`, channel, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -239,7 +318,7 @@ func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error)
|
||||
var out []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
|
||||
if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
@@ -251,13 +330,13 @@ func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error)
|
||||
// channels, newest first. Sentinel channels are excluded.
|
||||
func ListAllClassified(limit, offset int) ([]Story, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
|
||||
`SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
|
||||
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
|
||||
FROM stories s
|
||||
WHERE s.classified = 1
|
||||
AND s.channel IS NOT NULL
|
||||
AND s.channel NOT IN ('_discarded', '_duplicate')
|
||||
ORDER BY s.seen_at DESC
|
||||
ORDER BY COALESCE(s.published_at, s.seen_at) DESC
|
||||
LIMIT ? OFFSET ?`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -266,7 +345,7 @@ func ListAllClassified(limit, offset int) ([]Story, error) {
|
||||
var out []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
|
||||
if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
@@ -274,11 +353,60 @@ func ListAllClassified(limit, offset int) ([]Story, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListForFeed returns up to `limit` classified stories for the outbound RSS and
|
||||
// JSON feeds, newest first. Unlike the web list queries it also selects the full
|
||||
// `content` and `published_at`, which the feeds need for content:encoded bodies
|
||||
// and real pubDates. channel == "" spans all real channels; otherwise it scopes
|
||||
// to that one channel. Sentinel channels are always excluded.
|
||||
func ListForFeed(channel string, limit int) ([]Story, error) {
|
||||
const cols = `s.id, s.guid, s.headline, s.lede, s.content, s.image_url, s.article_url, s.url_canonical, s.source, s.channel, s.seen_at, s.published_at`
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
if channel == "" {
|
||||
rows, err = Get().Query(
|
||||
`SELECT `+cols+`
|
||||
FROM stories s
|
||||
WHERE s.classified = 1
|
||||
AND s.channel IS NOT NULL
|
||||
AND s.channel NOT IN ('_discarded', '_duplicate')
|
||||
ORDER BY COALESCE(s.published_at, s.seen_at) DESC
|
||||
LIMIT ?`, limit)
|
||||
} else {
|
||||
rows, err = Get().Query(
|
||||
`SELECT `+cols+`
|
||||
FROM stories s
|
||||
WHERE s.classified = 1
|
||||
AND s.channel = ?
|
||||
ORDER BY COALESCE(s.published_at, s.seen_at) DESC
|
||||
LIMIT ?`, channel, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
var content, canonical sql.NullString
|
||||
var published sql.NullInt64
|
||||
if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &content, &s.ImageURL, &s.ArticleURL, &canonical, &s.Source, &s.Channel, &s.SeenAt, &published); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Content = content.String
|
||||
s.URLCanonical = canonical.String
|
||||
s.PublishedAt = published.Int64
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListRecentlyPosted returns up to `limit` stories that have been posted to
|
||||
// Matrix, ordered by post time (newest first). Posted is always true.
|
||||
func ListRecentlyPosted(limit int) ([]Story, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at
|
||||
`SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at
|
||||
FROM stories s
|
||||
JOIN post_log p ON p.guid = s.guid
|
||||
GROUP BY s.guid
|
||||
@@ -291,7 +419,7 @@ func ListRecentlyPosted(limit int) ([]Story, error) {
|
||||
var out []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt); err != nil {
|
||||
if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Posted = true
|
||||
@@ -300,6 +428,72 @@ func ListRecentlyPosted(limit int) ([]Story, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TrendingStories returns the most-read classified stories (real channels only)
|
||||
// counting views recorded on or after sinceDay (a unix day number), newest-ish
|
||||
// as a tiebreak. Used for the home page's "popular this week" rail. Stories with
|
||||
// no views in the window don't appear, so the rail is empty until reads exist.
|
||||
func TrendingStories(limit int, sinceDay int64) ([]Story, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
|
||||
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
|
||||
FROM stories s
|
||||
JOIN story_views v ON v.story_id = s.id
|
||||
WHERE s.classified = 1
|
||||
AND s.channel IS NOT NULL
|
||||
AND s.channel NOT IN ('_discarded', '_duplicate')
|
||||
AND v.day >= ?
|
||||
GROUP BY s.id
|
||||
ORDER BY SUM(v.views) DESC, COALESCE(s.published_at, s.seen_at) DESC
|
||||
LIMIT ?`, sinceDay, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// StoryContentLengths returns the captured article text length (in characters)
|
||||
// for the given story ids, as a map keyed by id. Used to estimate a "N min read"
|
||||
// chip. It reads the precomputed content_chars column rather than LENGTH()-ing
|
||||
// the full body, so the hot listing path never scans article text. Ids with no
|
||||
// captured content have content_chars = 0 and are absent from the map (treated
|
||||
// as zero by callers).
|
||||
func StoryContentLengths(ids []int64) map[int64]int {
|
||||
out := make(map[int64]int, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return out
|
||||
}
|
||||
ph, args := intInClause(ids)
|
||||
rows, err := Get().Query(
|
||||
`SELECT id, content_chars FROM stories
|
||||
WHERE id IN (`+ph+`) AND content_chars > 0`, args...)
|
||||
if err != nil {
|
||||
slog.Error("story content lengths query failed", "err", err)
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id, n int64
|
||||
if err := rows.Scan(&id, &n); err != nil {
|
||||
slog.Error("scan content length failed", "err", err)
|
||||
continue
|
||||
}
|
||||
out[id] = int(n)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
slog.Error("story content lengths iteration failed", "err", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CountClassifiedByChannel returns how many classified stories exist for a channel.
|
||||
func CountClassifiedByChannel(channel string) (int, error) {
|
||||
var n int
|
||||
|
||||
275
internal/storage/rank.go
Normal file
275
internal/storage/rank.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Ranking weights for the "For you" feed. Bookmarks are a stronger signal of
|
||||
// interest than a plain read, and channel affinity outweighs source affinity
|
||||
// (a reader follows topics more than outlets). Recency keeps the feed fresh so
|
||||
// a heavily-read channel doesn't surface week-old stories over today's news.
|
||||
const (
|
||||
affinityReadWeight = 1.0
|
||||
affinityBookmarkWeight = 3.0
|
||||
|
||||
forYouChannelWeight = 1.0
|
||||
forYouSourceWeight = 0.7
|
||||
forYouRecencyWeight = 0.9
|
||||
|
||||
// forYouCandidatePool bounds how many recent unread stories we score in Go.
|
||||
forYouCandidatePool = 400
|
||||
// forYouRecencyHalfLifeHours sets how fast the recency term decays.
|
||||
forYouRecencyHalfLifeHours = 48.0
|
||||
)
|
||||
|
||||
// userAffinity builds normalized channel and source affinity scores (each 0..1)
|
||||
// for a signed-in user from their read + bookmark history. Bookmarks count for
|
||||
// more than plain reads. total is the number of signal-bearing rows; when it is
|
||||
// zero the user has no history yet and both maps are empty.
|
||||
func userAffinity(sub string) (channel, source map[string]float64, total int, err error) {
|
||||
channel = make(map[string]float64)
|
||||
source = make(map[string]float64)
|
||||
if sub == "" {
|
||||
return channel, source, 0, nil
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT s.channel, s.source, u.read_at, u.bookmarked_at
|
||||
FROM user_story_state u
|
||||
JOIN stories s ON s.id = u.story_id
|
||||
WHERE u.user_sub = ?
|
||||
AND s.channel IS NOT NULL
|
||||
AND s.channel NOT IN ('_discarded', '_duplicate')`, sub)
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var ch, src string
|
||||
var readAt, markAt sql.NullInt64
|
||||
if err := rows.Scan(&ch, &src, &readAt, &markAt); err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
w := 0.0
|
||||
if readAt.Valid {
|
||||
w += affinityReadWeight
|
||||
}
|
||||
if markAt.Valid {
|
||||
w += affinityBookmarkWeight
|
||||
}
|
||||
if w == 0 {
|
||||
continue
|
||||
}
|
||||
channel[ch] += w
|
||||
source[src] += w
|
||||
total++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
normalizeMax(channel)
|
||||
normalizeMax(source)
|
||||
return channel, source, total, nil
|
||||
}
|
||||
|
||||
// normalizeMax scales a map's values so the largest becomes 1.0, leaving an
|
||||
// all-zero (or empty) map untouched.
|
||||
func normalizeMax(m map[string]float64) {
|
||||
var max float64
|
||||
for _, v := range m {
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
if max == 0 {
|
||||
return
|
||||
}
|
||||
for k := range m {
|
||||
m[k] /= max
|
||||
}
|
||||
}
|
||||
|
||||
// ForYou returns a personalized ranking of recent, unread stories for a
|
||||
// signed-in user, blending channel affinity, source affinity, and recency. It
|
||||
// returns (nil, nil) when the user has no read/bookmark history yet, so callers
|
||||
// can fall back to the plain latest feed.
|
||||
func ForYou(sub string, limit int) ([]Story, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
chAff, srcAff, total, err := userAffinity(sub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if total == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at, COALESCE(s.published_at, s.seen_at),
|
||||
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
|
||||
FROM stories s
|
||||
WHERE s.classified = 1
|
||||
AND s.channel IS NOT NULL
|
||||
AND s.channel NOT IN ('_discarded', '_duplicate')
|
||||
AND s.id NOT IN (
|
||||
SELECT story_id FROM user_story_state
|
||||
WHERE user_sub = ? AND read_at IS NOT NULL)
|
||||
ORDER BY COALESCE(s.published_at, s.seen_at) DESC
|
||||
LIMIT ?`, sub, forYouCandidatePool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
now := float64(nowUnix())
|
||||
type scored struct {
|
||||
s Story
|
||||
score float64
|
||||
}
|
||||
var cands []scored
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
var effectiveAt int64
|
||||
if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &effectiveAt, &s.Posted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ageHours := (now - float64(effectiveAt)) / 3600.0
|
||||
if ageHours < 0 {
|
||||
ageHours = 0
|
||||
}
|
||||
recency := math.Exp(-ageHours / forYouRecencyHalfLifeHours)
|
||||
score := forYouChannelWeight*chAff[s.Channel] +
|
||||
forYouSourceWeight*srcAff[s.Source] +
|
||||
forYouRecencyWeight*recency
|
||||
cands = append(cands, scored{s, score})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Candidates arrive newest-first, and a stable sort keeps that order for
|
||||
// equal scores, so ties break toward the more recent story.
|
||||
sort.SliceStable(cands, func(i, j int) bool { return cands[i].score > cands[j].score })
|
||||
if len(cands) > limit {
|
||||
cands = cands[:limit]
|
||||
}
|
||||
out := make([]Story, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
out = append(out, c.s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// relatedRecencyWindowSeconds bounds "related" results to roughly the same
|
||||
// window the story feed lives in (stories are pruned at 30 days anyway).
|
||||
const relatedRecencyWindowSeconds = 30 * 86400
|
||||
|
||||
// relatedStopwords are high-frequency, low-signal tokens dropped when building
|
||||
// the "related" FTS query so matches key off the story's actual subject matter.
|
||||
var relatedStopwords = map[string]bool{
|
||||
"the": true, "and": true, "for": true, "with": true, "that": true,
|
||||
"this": true, "from": true, "has": true, "have": true, "are": true,
|
||||
"was": true, "were": true, "will": true, "its": true, "into": true,
|
||||
"out": true, "how": true, "why": true, "what": true, "who": true,
|
||||
"you": true, "your": true, "not": true, "but": true, "all": true,
|
||||
"new": true, "now": true, "more": true, "after": true, "over": true,
|
||||
"about": true, "says": true, "said": true, "of": true, "to": true,
|
||||
"in": true, "is": true, "on": true, "at": true, "by": true, "as": true,
|
||||
"an": true, "be": true, "it": true, "or": true, "if": true, "we": true,
|
||||
"he": true, "do": true, "up": true, "so": true, "no": true, "my": true,
|
||||
}
|
||||
|
||||
// RelatedStories returns classified stories textually similar to the given
|
||||
// story, ranked by FTS5 relevance and excluding the story itself. The match
|
||||
// query is an OR of significant tokens from the seed story's headline and lede,
|
||||
// so it surfaces stories that share subject matter rather than requiring every
|
||||
// term (which is what SearchStories' implicit-AND query would demand).
|
||||
func RelatedStories(storyID int64, limit int) ([]Story, error) {
|
||||
if storyID <= 0 || limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var headline, lede sql.NullString
|
||||
err := Get().QueryRow(`SELECT headline, lede FROM stories WHERE id = ?`, storyID).Scan(&headline, &lede)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
fts := buildRelatedFTSQuery(headline.String + " " + lede.String)
|
||||
if fts == "" {
|
||||
return nil, nil
|
||||
}
|
||||
cutoff := nowUnix() - relatedRecencyWindowSeconds
|
||||
rows, err := Get().Query(
|
||||
`SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
|
||||
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
|
||||
FROM stories_fts f
|
||||
JOIN stories s ON s.id = f.rowid
|
||||
WHERE f.stories_fts MATCH ?
|
||||
AND s.id != ?
|
||||
AND s.classified = 1
|
||||
AND s.channel IS NOT NULL
|
||||
AND s.channel NOT IN ('_discarded', '_duplicate')
|
||||
AND s.seen_at >= ?
|
||||
ORDER BY bm25(stories_fts) ASC, s.seen_at DESC
|
||||
LIMIT ?`, fts, storyID, cutoff, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// buildRelatedFTSQuery tokenizes a story's headline+lede into a de-duplicated,
|
||||
// stopword-filtered OR query of quoted terms, capped so a long lede can't build
|
||||
// a pathological query. Returns "" when nothing usable remains.
|
||||
func buildRelatedFTSQuery(raw string) string {
|
||||
var tokens []string
|
||||
var cur strings.Builder
|
||||
seen := make(map[string]bool)
|
||||
flush := func() {
|
||||
if cur.Len() == 0 {
|
||||
return
|
||||
}
|
||||
t := strings.ToLower(cur.String())
|
||||
cur.Reset()
|
||||
if len([]rune(t)) < 2 || relatedStopwords[t] || seen[t] {
|
||||
return
|
||||
}
|
||||
seen[t] = true
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
for _, r := range raw {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
cur.WriteRune(r)
|
||||
case r > 127:
|
||||
cur.WriteRune(r)
|
||||
default:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
const maxTokens = 16
|
||||
if len(tokens) > maxTokens {
|
||||
tokens = tokens[:maxTokens]
|
||||
}
|
||||
for i, t := range tokens {
|
||||
tokens[i] = `"` + t + `"`
|
||||
}
|
||||
return strings.Join(tokens, " OR ")
|
||||
}
|
||||
141
internal/storage/rank_test.go
Normal file
141
internal/storage/rank_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedStoryFull inserts a story with an explicit channel + source so ranking
|
||||
// tests can build a skewed affinity profile.
|
||||
func seedStoryFull(t *testing.T, guid, channel, source, headline, lede string) int64 {
|
||||
t.Helper()
|
||||
s := &Story{
|
||||
GUID: guid,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ArticleURL: "https://example.com/" + guid,
|
||||
Source: source,
|
||||
Channel: channel,
|
||||
Classified: true,
|
||||
SeenAt: nowUnix(),
|
||||
}
|
||||
if err := InsertStory(s); err != nil {
|
||||
t.Fatalf("insert story %s: %v", guid, err)
|
||||
}
|
||||
var id int64
|
||||
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, guid).Scan(&id); err != nil {
|
||||
t.Fatalf("lookup id %s: %v", guid, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestForYou_LeansToAffinityAndExcludesRead(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
const sub = "sub-1"
|
||||
|
||||
// A skewed history: the user reads/bookmarks gaming stories.
|
||||
g1 := seedStoryFull(t, "g1", "gaming", "GameSite", "Game one", "")
|
||||
g2 := seedStoryFull(t, "g2", "gaming", "GameSite", "Game two", "")
|
||||
// Candidates to rank (all unread until we mark some).
|
||||
gc := seedStoryFull(t, "gc", "gaming", "GameSite", "Fresh gaming story", "")
|
||||
tc := seedStoryFull(t, "tc", "tech", "TechSite", "Fresh tech story", "")
|
||||
|
||||
if err := SetRead(sub, g1, true); err != nil {
|
||||
t.Fatalf("read g1: %v", err)
|
||||
}
|
||||
if err := SetBookmark(sub, g2, true); err != nil {
|
||||
t.Fatalf("bookmark g2: %v", err)
|
||||
}
|
||||
|
||||
got, err := ForYou(sub, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ForYou: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatal("expected results")
|
||||
}
|
||||
// Affinity leans gaming, so a gaming story tops the list and the tech story
|
||||
// sinks to the bottom. (gc and the unread-but-bookmarked g2 both qualify and
|
||||
// tie on score, so we assert on channel rather than a specific id.)
|
||||
if got[0].Channel != "gaming" {
|
||||
t.Fatalf("expected a gaming story first, got channel %q (id %d)", got[0].Channel, got[0].ID)
|
||||
}
|
||||
if got[len(got)-1].ID != tc {
|
||||
t.Fatalf("expected tech candidate ranked last, got id %d", got[len(got)-1].ID)
|
||||
}
|
||||
// Read stories must never appear.
|
||||
for _, s := range got {
|
||||
if s.ID == g1 {
|
||||
t.Fatalf("read story g1 leaked into ForYou")
|
||||
}
|
||||
}
|
||||
// Sanity: the gaming candidate that was never touched should be present.
|
||||
var sawGC bool
|
||||
for _, s := range got {
|
||||
if s.ID == gc {
|
||||
sawGC = true
|
||||
}
|
||||
}
|
||||
if !sawGC {
|
||||
t.Fatalf("expected unread gaming candidate gc in results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForYou_NoHistoryReturnsNil(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedStoryFull(t, "a", "tech", "TechSite", "Something", "")
|
||||
got, err := ForYou("nobody", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ForYou: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for a user with no history, got %d rows", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelatedStories_OnTopicExcludesSelf(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seed := seedStoryFull(t, "seed", "tech", "TechSite",
|
||||
"Apple unveils new iPhone camera", "The new camera sensor is larger.")
|
||||
rel := seedStoryFull(t, "rel", "tech", "OtherSite",
|
||||
"iPhone camera teardown reveals sensor", "A look at the new iPhone camera.")
|
||||
seedStoryFull(t, "off", "gaming", "GameSite",
|
||||
"Nintendo announces handheld", "A brand new portable console.")
|
||||
|
||||
got, err := RelatedStories(seed, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("RelatedStories: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatal("expected at least one related story")
|
||||
}
|
||||
for _, s := range got {
|
||||
if s.ID == seed {
|
||||
t.Fatal("seed story returned as its own related")
|
||||
}
|
||||
}
|
||||
if got[0].ID != rel {
|
||||
t.Fatalf("expected the on-topic iPhone story first, got id %d", got[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRelatedFTSQuery(t *testing.T) {
|
||||
// Stopwords and 1-char tokens drop out; the rest become an OR of quoted terms.
|
||||
q := buildRelatedFTSQuery("The new iPhone camera is a big deal")
|
||||
if q == "" {
|
||||
t.Fatal("expected a non-empty query")
|
||||
}
|
||||
for _, bad := range []string{`"the"`, `"is"`, `"new"`} {
|
||||
if strings.Contains(q, bad) {
|
||||
t.Fatalf("stopword leaked into query: %s in %q", bad, q)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`"iphone"`, `"camera"`} {
|
||||
if !strings.Contains(q, want) {
|
||||
t.Fatalf("expected %s in query %q", want, q)
|
||||
}
|
||||
}
|
||||
if buildRelatedFTSQuery("the a of to") != "" {
|
||||
t.Fatal("expected empty query when only stopwords remain")
|
||||
}
|
||||
}
|
||||
142
internal/storage/roster.go
Normal file
142
internal/storage/roster.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// RosterEntry is one adventurer's currently-true state, as of the last snapshot
|
||||
// gogobee pushed. Not an event — nothing here is a thing that *happened*.
|
||||
type RosterEntry struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
ClassRace string `json:"class_race"`
|
||||
Status string `json:"status"` // "expedition" | "idle"
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Day int `json:"day,omitempty"`
|
||||
IdleHours int `json:"idle_hours,omitempty"`
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
// Detail is the public expanded sheet (stats + gear), carried as raw JSON so
|
||||
// the board path never has to model or touch it — only the detail page decodes
|
||||
// it. Empty when a snapshot predates the detail push.
|
||||
Detail json.RawMessage `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
|
||||
//
|
||||
// Replace — never merge. A player who dropped out of gogobee's snapshot (deleted
|
||||
// character, or a fresh `!news optout`) must vanish from the board, and an
|
||||
// upsert would leave them standing there forever. The transaction means a reader
|
||||
// mid-swap sees the old board or the new one, never a half-empty realm.
|
||||
func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM adventure_roster`); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO adventure_roster
|
||||
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at, detail_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, e := range entries {
|
||||
var detail any // NULL when absent, so the column reads as "no detail", not "{}"
|
||||
if len(e.Detail) > 0 {
|
||||
detail = string(e.Detail)
|
||||
}
|
||||
if _, err := stmt.Exec(e.Token, e.Name, e.Level, e.ClassRace, e.Status,
|
||||
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt, detail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Stamp the snapshot even when it carried zero adventurers — that is a
|
||||
// legitimate state (quiet realm) and must not read as "gogobee went away".
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO adventure_roster_meta (id, snapshot_at) VALUES (1, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET snapshot_at = excluded.snapshot_at`, snapshotAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// LoadRoster returns the current board: everyone on an expedition first (most
|
||||
// recently departed at the top of that group), then the idle, longest-idle last.
|
||||
// Also returns the snapshot time so the caller can decide whether the wire has
|
||||
// gone quiet — see rosterStale.
|
||||
func LoadRoster() ([]RosterEntry, int64, error) {
|
||||
rows, err := Get().Query(`
|
||||
SELECT token, name, level, COALESCE(class_race, ''), status,
|
||||
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at
|
||||
FROM adventure_roster
|
||||
ORDER BY status = 'expedition' DESC, day DESC, idle_hours ASC, level DESC, name ASC`)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []RosterEntry
|
||||
for rows.Next() {
|
||||
var e RosterEntry
|
||||
if err := rows.Scan(&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
|
||||
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return out, RosterSnapshotAt(), nil
|
||||
}
|
||||
|
||||
// RosterEntryByToken looks up one adventurer by their roster token. The bool is
|
||||
// false when no such token is on the current board — which is exactly the check
|
||||
// the storefront needs: a buyer may only order a hit on a mark the live board is
|
||||
// actually showing, never a stale or guessed token.
|
||||
func RosterEntryByToken(token string) (RosterEntry, bool, error) {
|
||||
var e RosterEntry
|
||||
var detail sql.NullString
|
||||
err := Get().QueryRow(`
|
||||
SELECT token, name, level, COALESCE(class_race, ''), status,
|
||||
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at, detail_json
|
||||
FROM adventure_roster WHERE token = ?`, token).Scan(
|
||||
&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
|
||||
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt, &detail)
|
||||
if err == sql.ErrNoRows {
|
||||
return RosterEntry{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return RosterEntry{}, false, err
|
||||
}
|
||||
if detail.Valid && detail.String != "" {
|
||||
e.Detail = json.RawMessage(detail.String)
|
||||
}
|
||||
return e, true, nil
|
||||
}
|
||||
|
||||
// RosterSnapshotAt reports when the board was last refreshed, 0 if gogobee has
|
||||
// never pushed one. Read from the meta row, not the entries, so a snapshot that
|
||||
// legitimately carried nobody still counts as a snapshot.
|
||||
func RosterSnapshotAt() int64 {
|
||||
var at sql.NullInt64
|
||||
err := Get().QueryRow(`SELECT snapshot_at FROM adventure_roster_meta WHERE id = 1`).Scan(&at)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("RosterSnapshotAt query failed", "err", err)
|
||||
return 0
|
||||
}
|
||||
return at.Int64
|
||||
}
|
||||
@@ -6,6 +6,8 @@ CREATE TABLE IF NOT EXISTS stories (
|
||||
guid TEXT UNIQUE NOT NULL,
|
||||
headline TEXT NOT NULL,
|
||||
lede TEXT,
|
||||
content TEXT,
|
||||
content_chars INTEGER NOT NULL DEFAULT 0,
|
||||
image_url TEXT,
|
||||
article_url TEXT NOT NULL,
|
||||
url_canonical TEXT,
|
||||
@@ -15,16 +17,126 @@ CREATE TABLE IF NOT EXISTS stories (
|
||||
channel TEXT,
|
||||
classified INTEGER NOT NULL DEFAULT 0,
|
||||
paywalled INTEGER NOT NULL DEFAULT 0,
|
||||
seen_at INTEGER NOT NULL
|
||||
seen_at INTEGER NOT NULL,
|
||||
published_at INTEGER
|
||||
);
|
||||
|
||||
-- adventure_roster is a *snapshot*, not a log: gogobee POSTs the whole live
|
||||
-- board and it replaces this table wholesale. Rows are state that is currently
|
||||
-- true ("Josie is in holymachina"), which is the one thing the story feed can
|
||||
-- never be — every dispatch there is an accomplishment, and an accomplishment is
|
||||
-- a clipping the moment it lands.
|
||||
--
|
||||
-- token is gogobee's per-player roster token, not a Matrix handle and not a
|
||||
-- story GUID. Players who ran "!news optout" are omitted from the snapshot
|
||||
-- upstream and so never appear here at all.
|
||||
CREATE TABLE IF NOT EXISTS adventure_roster (
|
||||
token TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
class_race TEXT,
|
||||
status TEXT NOT NULL, -- "expedition" | "idle"
|
||||
zone TEXT,
|
||||
region TEXT,
|
||||
day INTEGER NOT NULL DEFAULT 0, -- expedition day, 0 if idle
|
||||
idle_hours INTEGER NOT NULL DEFAULT 0, -- hours since last player action
|
||||
snapshot_at INTEGER NOT NULL -- when gogobee took the snapshot
|
||||
);
|
||||
|
||||
-- The snapshot time lives outside the rows because an *empty* board is
|
||||
-- ambiguous: either nobody is playing, or gogobee has stopped talking to us. A
|
||||
-- MAX(snapshot_at) over zero rows can't tell those apart, and the page must —
|
||||
-- one is "quiet realm", the other is "the wire is down, trust nothing here".
|
||||
CREATE TABLE IF NOT EXISTS adventure_roster_meta (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed.
|
||||
-- Keyed by localpart (== Authentik preferred_username == the session's Username),
|
||||
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only
|
||||
-- ever read for the one authenticated user asking about themselves, so the board
|
||||
-- stays anonymous and no endpoint hands out anyone else's number. Advisory only —
|
||||
-- the storefront greys out tiers it thinks you can't afford, but the real debit
|
||||
-- happens on gogobee at claim time and a stale balance just bounces an order.
|
||||
CREATE TABLE IF NOT EXISTS user_euro (
|
||||
username TEXT PRIMARY KEY, -- Matrix localpart == session Username
|
||||
euro REAL NOT NULL DEFAULT 0,
|
||||
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- A mischief contract a buyer placed from the web storefront, on its way to
|
||||
-- gogobee. Pete never touches money and never runs the game rules — it only
|
||||
-- records the *intent* and later the verdict gogobee hands back. The status
|
||||
-- ladder:
|
||||
--
|
||||
-- pending -> placed (gogobee debited the buyer and opened a contract)
|
||||
-- -> bounced_funds (buyer couldn't actually afford it)
|
||||
-- -> bounced_ineligible (target no longer a valid mark: no expedition,
|
||||
-- a live contract already, cooldown, cap, ...)
|
||||
--
|
||||
-- guid is the idempotency key end to end: gogobee passes it to DebitIdem and
|
||||
-- stamps it on the contract, so a claim whose ack is lost on the wire can be
|
||||
-- retried without charging the buyer twice or opening two contracts. buyer_sub
|
||||
-- is the OIDC subject (stable across username changes) and keys "my orders";
|
||||
-- buyer_username is what gogobee turns into @username:server. target_token is
|
||||
-- the roster token of the mark — the same anonymous token the board renders, so
|
||||
-- ordering a hit never needs the victim's real handle.
|
||||
CREATE TABLE IF NOT EXISTS mischief_orders (
|
||||
guid TEXT PRIMARY KEY,
|
||||
buyer_sub TEXT NOT NULL,
|
||||
buyer_username TEXT NOT NULL,
|
||||
target_token TEXT NOT NULL,
|
||||
target_name TEXT NOT NULL, -- display copy, frozen at order time
|
||||
tier TEXT NOT NULL,
|
||||
signed INTEGER NOT NULL DEFAULT 0, -- 1 = sign openly (+25%), 0 = anonymous
|
||||
status TEXT NOT NULL, -- see the ladder above
|
||||
detail TEXT, -- gogobee's human note on the verdict
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mischief_orders_pending ON mischief_orders(status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_mischief_orders_buyer ON mischief_orders(buyer_sub, created_at DESC);
|
||||
|
||||
-- The storefront price list. gogobee is the sole authority on prices and pushes
|
||||
-- the whole catalog on the roster tick, so a fee retune reaches the storefront
|
||||
-- within a snapshot and Pete never hardcodes a number that can drift. ordinal
|
||||
-- preserves the grunt->boss order the push arrived in.
|
||||
CREATE TABLE IF NOT EXISTS mischief_tiers (
|
||||
key TEXT PRIMARY KEY,
|
||||
display TEXT NOT NULL,
|
||||
fee INTEGER NOT NULL,
|
||||
signed_fee INTEGER NOT NULL,
|
||||
blurb TEXT,
|
||||
ordinal INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- A player's private, owner-only expansion — inventory, vault, house, pets —
|
||||
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
|
||||
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
|
||||
-- like user_euro, it is only ever served back to the one authenticated user it
|
||||
-- belongs to, never on the public board. token is that player's current roster
|
||||
-- token, kept here so the detail page can prove owner↔page by a join without
|
||||
-- ever reversing the one-way token — the association lives only in this
|
||||
-- owner-private table and never reaches any public response. detail_json is the
|
||||
-- {inventory, vault, house, pets} body; it is replaced wholesale each tick, so a
|
||||
-- player who drops out of gogobee's push loses their stale self-view.
|
||||
CREATE TABLE IF NOT EXISTS player_self_detail (
|
||||
localpart TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
detail_json TEXT NOT NULL,
|
||||
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_player_self_detail_token ON player_self_detail(token);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
event_id TEXT,
|
||||
url_canonical TEXT,
|
||||
posted_at INTEGER NOT NULL
|
||||
posted_at INTEGER NOT NULL,
|
||||
forced INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS round_robin_state (
|
||||
@@ -43,15 +155,307 @@ CREATE TABLE IF NOT EXISTS reactions (
|
||||
reacted_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_sub TEXT PRIMARY KEY,
|
||||
prefs TEXT NOT NULL,
|
||||
username TEXT,
|
||||
email TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Per-user read + bookmark state for signed-in visitors, keyed by OIDC subject.
|
||||
-- One row carries both signals; a NULL timestamp means "not set". A row with
|
||||
-- both timestamps NULL is meaningless and is pruned, so presence of a row means
|
||||
-- the story is read, bookmarked, or both.
|
||||
CREATE TABLE IF NOT EXISTS user_story_state (
|
||||
user_sub TEXT NOT NULL,
|
||||
story_id INTEGER NOT NULL,
|
||||
read_at INTEGER,
|
||||
bookmarked_at INTEGER,
|
||||
PRIMARY KEY (user_sub, story_id)
|
||||
);
|
||||
|
||||
-- Aggregate web usage. page_views holds running view counts keyed by a coarse
|
||||
-- path label ("home", channel slug, …) and the UTC day, so we can report both
|
||||
-- all-time totals and per-day breakdowns without storing any per-request rows.
|
||||
CREATE TABLE IF NOT EXISTS page_views (
|
||||
path TEXT NOT NULL,
|
||||
day INTEGER NOT NULL, -- unix day (floor(unix / 86400)), UTC
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (path, day)
|
||||
);
|
||||
|
||||
-- Per-source poll health, one row per configured feed (keyed by source name).
|
||||
-- Written on every poll (success and failure) so the owner-facing dashboard can
|
||||
-- show which feeds are healthy without keeping the poller's in-memory state.
|
||||
-- last_success_at / last_item_count survive failures so a broken feed still
|
||||
-- shows when it last worked and how much it last returned.
|
||||
CREATE TABLE IF NOT EXISTS source_health (
|
||||
source TEXT PRIMARY KEY,
|
||||
last_poll_at INTEGER, -- unix, most recent poll attempt
|
||||
last_success_at INTEGER, -- unix, most recent successful fetch
|
||||
last_error TEXT, -- last failure message ('' when healthy)
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
last_item_count INTEGER NOT NULL DEFAULT 0, -- items in the last successful fetch
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Web Push subscriptions for signed-in users, one row per browser/device
|
||||
-- endpoint. p256dh + auth are the client's encryption keys (RFC 8291); the
|
||||
-- server needs them to encrypt each push. last_notified_at is the per-endpoint
|
||||
-- digest watermark: the sender only counts stories seen after it. A user can
|
||||
-- have several endpoints (phone, desktop) — each is notified independently.
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
endpoint TEXT PRIMARY KEY,
|
||||
user_sub TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_notified_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Privacy-preserving daily unique estimate. visitor is a salted hash of
|
||||
-- IP+User-Agent; the salt rotates every UTC day and is never persisted, so the
|
||||
-- hashes are irreversible and cannot be linked across days. We keep only enough
|
||||
-- to dedup within a single day, then prune.
|
||||
CREATE TABLE IF NOT EXISTS daily_visitors (
|
||||
day INTEGER NOT NULL,
|
||||
visitor TEXT NOT NULL,
|
||||
PRIMARY KEY (day, visitor)
|
||||
);
|
||||
|
||||
-- Per-story read counts, keyed by story id and UTC day. Incremented whenever a
|
||||
-- visitor opens a story in reader mode (/api/article). The day dimension lets
|
||||
-- us surface "popular this week" without a separate rollup; summing across all
|
||||
-- days gives the all-time count shown on cards. Rows age out with their story
|
||||
-- via the foreign-key-less prune in RunMaintenance.
|
||||
CREATE TABLE IF NOT EXISTS story_views (
|
||||
story_id INTEGER NOT NULL,
|
||||
day INTEGER NOT NULL, -- unix day (floor(unix / 86400)), UTC
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (story_id, day)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- games.parodia.dev
|
||||
--
|
||||
-- The invariant the whole casino rests on: a euro is either in gogobee's
|
||||
-- euro_balances or in Pete's chip escrow, never both. It crosses between them
|
||||
-- only via a GUID-idempotent claim, and Pete never writes a euro balance —
|
||||
-- gogobee does, when it claims the escrow row and tells us how it went.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- A player's chips: euros that have crossed into the casino and haven't crossed
|
||||
-- back yet. 1:1 with euros. Keyed by Matrix user id, because that's the identity
|
||||
-- gogobee's ledger uses and the one an Authentik username maps onto.
|
||||
CREATE TABLE IF NOT EXISTS game_chips (
|
||||
matrix_user TEXT PRIMARY KEY,
|
||||
chips INTEGER NOT NULL DEFAULT 0,
|
||||
-- Advisory only, and stale by design: the last euro balance gogobee told us
|
||||
-- about. Displayed, never trusted. The authoritative check is the debit at
|
||||
-- claim time, which happens on gogobee's box against gogobee's ledger.
|
||||
euro_balance REAL,
|
||||
last_played INTEGER NOT NULL DEFAULT 0, -- unix; the reaper reads this
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- One crossing of the euro/chip border, in either direction.
|
||||
--
|
||||
-- requested -> claimed -> funded (buy-in: gogobee debited, chips spendable)
|
||||
-- -> rejected (buy-in: insufficient funds, no chips)
|
||||
-- requested -> claimed -> settled (cash-out: chips gone, euros credited)
|
||||
--
|
||||
-- The guid is the idempotency key end to end: it's what gogobee passes to
|
||||
-- DebitIdem/CreditIdem, so a claim whose ack is lost on the wire can be retried
|
||||
-- without the player paying twice.
|
||||
CREATE TABLE IF NOT EXISTS game_escrow (
|
||||
guid TEXT PRIMARY KEY,
|
||||
matrix_user TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- 'buyin' | 'cashout'
|
||||
amount INTEGER NOT NULL, -- euros == chips
|
||||
state TEXT NOT NULL, -- see the ladder above
|
||||
reason TEXT, -- 'insufficient_funds', when rejected
|
||||
balance_after REAL, -- gogobee's euro balance after the move
|
||||
created_at INTEGER NOT NULL,
|
||||
claimed_at INTEGER, -- when gogobee took it; drives the re-poll
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_escrow_state ON game_escrow(state, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_escrow_user ON game_escrow(matrix_user, created_at DESC);
|
||||
|
||||
-- Every hand played, for money. This is the audit trail: seeds so a disputed
|
||||
-- hand can be re-dealt exactly as it fell, rake so the house's take is
|
||||
-- accountable, and enough shape to answer "how fast is this economy actually
|
||||
-- moving" before the answer becomes a problem.
|
||||
CREATE TABLE IF NOT EXISTS game_hands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
matrix_user TEXT NOT NULL,
|
||||
game TEXT NOT NULL, -- 'blackjack'
|
||||
bet INTEGER NOT NULL,
|
||||
payout INTEGER NOT NULL, -- chips returned, net of rake
|
||||
rake INTEGER NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
seed1 INTEGER NOT NULL, -- the shoe, reproducible
|
||||
seed2 INTEGER NOT NULL,
|
||||
played_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_hands_user ON game_hands(matrix_user, played_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_hands_played ON game_hands(played_at);
|
||||
|
||||
-- The hand a player is in the middle of. One per player: you cannot be dealt a
|
||||
-- second hand while chips are riding on the first.
|
||||
--
|
||||
-- The state column is the engine's State, serialized whole — shoe included. It
|
||||
-- lives here rather than in memory because Pete redeploys often, and a player
|
||||
-- whose stake has already been taken must find their cards where they left them
|
||||
-- rather than a table that has forgotten them. It is also why the deck never
|
||||
-- goes to the browser: the authoritative shoe is this row, on the server.
|
||||
CREATE TABLE IF NOT EXISTS game_live_hands (
|
||||
matrix_user TEXT PRIMARY KEY,
|
||||
game TEXT NOT NULL, -- 'blackjack'
|
||||
state TEXT NOT NULL, -- JSON: the engine's State
|
||||
seed1 INTEGER NOT NULL, -- carried to the audit log when it settles
|
||||
seed2 INTEGER NOT NULL,
|
||||
-- Set when the player is sitting at a shared table rather than playing alone.
|
||||
-- The engine state then lives in game_tables.state, not here, and this row is
|
||||
-- purely the occupancy claim: its PRIMARY KEY is what stops one player being
|
||||
-- in two games at once, and it is the row the cash-out check reads. Making
|
||||
-- game_seats a second uniqueness domain instead would be a split brain — see
|
||||
-- the comment on game_seats.
|
||||
table_id TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Shared tables: the casino with more than one person at it.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- A table other people can sit at. The state column is the engine's State,
|
||||
-- exactly as game_live_hands holds it for a solo game — one blob for the whole
|
||||
-- felt, because a pot is not divisible into per-player rows.
|
||||
--
|
||||
-- version is the concurrency authority, and the mutex in the web layer is only
|
||||
-- an optimisation on top of it. Every state write is a conditional UPDATE
|
||||
-- against the version the writer read; zero rows affected means somebody moved
|
||||
-- first. This has to live in the database rather than in a mutex map because a
|
||||
-- mutex does not survive a redeploy — during a drain, two processes hold two
|
||||
-- different mutexes over the same row and both believe they are alone.
|
||||
CREATE TABLE IF NOT EXISTS game_tables (
|
||||
id TEXT PRIMARY KEY,
|
||||
game TEXT NOT NULL, -- 'holdem' | 'uno' | 'blackjack'
|
||||
tier TEXT NOT NULL, -- the stake, as that game names it
|
||||
state TEXT NOT NULL, -- JSON: the engine's State
|
||||
seed1 INTEGER NOT NULL,
|
||||
seed2 INTEGER NOT NULL,
|
||||
phase TEXT NOT NULL, -- the engine's phase, lifted out so the lobby can read it
|
||||
hand_no INTEGER NOT NULL DEFAULT 0, -- with id, the identity of one hand: the payout key
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
-- Unix seconds by which the seat to act must act, or 0 for no clock. The turn
|
||||
-- clock scans this. It is set only when the turn lands on a human: bots resolve
|
||||
-- inside ApplyMove and are never waited for.
|
||||
deadline INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_tables_due ON game_tables(deadline) WHERE deadline > 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_game_tables_lobby ON game_tables(game, updated_at DESC);
|
||||
|
||||
-- Who is sitting where. A seat with no matrix_user is a bot.
|
||||
--
|
||||
-- This is deliberately *not* a uniqueness domain for players: there is no unique
|
||||
-- index on matrix_user, and there must not be one. Occupancy is decided by
|
||||
-- game_live_hands' primary key, which already stops a player being in two games,
|
||||
-- already makes a double-clicked join a 409, and is already what the cash-out
|
||||
-- check reads. A second domain that could disagree with the first would silently
|
||||
-- switch all three off — the worst of them being a player who cashes out to zero
|
||||
-- while sitting at a poker table with chips in the pot.
|
||||
--
|
||||
-- staked is what the player brought to the table and has not yet taken home. It
|
||||
-- is the chip-conservation anchor: the chips are off their game_chips stack and
|
||||
-- inside the table blob, where the idle reaper cannot see them.
|
||||
CREATE TABLE IF NOT EXISTS game_seats (
|
||||
table_id TEXT NOT NULL,
|
||||
seat INTEGER NOT NULL,
|
||||
matrix_user TEXT, -- NULL for a bot
|
||||
name TEXT NOT NULL,
|
||||
staked INTEGER NOT NULL DEFAULT 0,
|
||||
-- Set once a human's clock has run out on them. An absent human is not a bot,
|
||||
-- but the bot loop has to be allowed past their seat or a table with three
|
||||
-- ghosts spends a minute an orbit folding air. They come back the moment they act.
|
||||
away INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (table_id, seat)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_seats_user ON game_seats(matrix_user) WHERE matrix_user IS NOT NULL;
|
||||
|
||||
-- There is no payout ledger here, and its absence is deliberate — the design
|
||||
-- called for one and the money model made it unnecessary. Chips cross into a
|
||||
-- table when a player sits down and back out when they get up; a hand ending
|
||||
-- moves the pot *within* the state blob and credits nobody's game_chips row. So
|
||||
-- there is no money write to make idempotent: a settle is a state write,
|
||||
-- conditional on the version, and a replayed one affects zero rows and rolls
|
||||
-- back. See the header of internal/storage/tables.go.
|
||||
|
||||
-- Chat on the felt. Messages only — no typing indicators, which is the one thing
|
||||
-- that would have justified a socket. It does not mirror into Matrix.
|
||||
--
|
||||
-- hand_no is kept against every line for a reason: at a table of real people,
|
||||
-- collusion looks like chat, and the only way to ever answer that question is to
|
||||
-- be able to read what was said during the hand it was said in.
|
||||
CREATE TABLE IF NOT EXISTS game_chat (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
table_id TEXT NOT NULL,
|
||||
hand_no INTEGER NOT NULL,
|
||||
matrix_user TEXT, -- NULL when the house is talking
|
||||
name TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
said_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_chat_table ON game_chat(table_id, id);
|
||||
|
||||
-- The trivia bank: questions pulled from the Open Trivia Database ahead of time,
|
||||
-- so that asking one is a local read.
|
||||
--
|
||||
-- Prefetched rather than fetched per question because a trivia ladder asks a
|
||||
-- question every fifteen seconds with money on a clock the player is scored
|
||||
-- against. A live fetch would put somebody else's latency and rate limit inside
|
||||
-- that clock. The refill is a slow background drip (internal/opentdb); a round
|
||||
-- never waits on it.
|
||||
--
|
||||
-- The question text is UNIQUE, which is the whole dedup strategy: OpenTDB hands back
|
||||
-- overlapping batches and the bank would otherwise fill up with the same forty
|
||||
-- questions. correct/incorrect are stored as the API gives them; the *shuffle*
|
||||
-- happens in the engine, per game, against that game's seed — so where the right
|
||||
-- answer sits in this table tells a player nothing.
|
||||
CREATE TABLE IF NOT EXISTS trivia_questions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
difficulty TEXT NOT NULL, -- 'easy' | 'medium' | 'hard'
|
||||
category TEXT NOT NULL,
|
||||
question TEXT NOT NULL UNIQUE,
|
||||
correct TEXT NOT NULL,
|
||||
incorrect TEXT NOT NULL, -- JSON array of the three wrong answers
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trivia_difficulty ON trivia_questions(difficulty);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_log_canonical_channel ON post_log(url_canonical, channel, posted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_stories_classified_source ON stories(classified, source);
|
||||
CREATE INDEX IF NOT EXISTS idx_stories_channel_classified_seen ON stories(channel, classified, seen_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_stories_classified_seen ON stories(classified, seen_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_stories_image_url ON stories(image_url) WHERE image_url IS NOT NULL AND image_url <> '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_stories_url_canonical ON stories(url_canonical) WHERE url_canonical IS NOT NULL AND url_canonical <> '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_stories_source_headline_norm ON stories(source, headline_norm) WHERE headline_norm IS NOT NULL AND headline_norm <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_post_guid ON reactions(post_guid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_reactions_event_id ON reactions(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_page_views_day ON page_views(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_visitors_day ON daily_visitors(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_story_views_day ON story_views(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_state_bookmarks ON user_story_state(user_sub, bookmarked_at) WHERE bookmarked_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_state_reads ON user_story_state(user_sub, read_at) WHERE read_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_sub);
|
||||
`
|
||||
|
||||
const ftsSchema = `
|
||||
|
||||
193
internal/storage/settle_test.go
Normal file
193
internal/storage/settle_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The settle used to be four autocommit statements in the web layer — save the
|
||||
// state, award the payout, write the audit row, clear the felt — sequenced so
|
||||
// that a crash between any two of them cost the player as little as possible.
|
||||
//
|
||||
// These are the tests for the thing that replaced it. They are less about the
|
||||
// happy path (the game tests already cover that: a hand pays what it says it
|
||||
// pays) and more about the two properties the old shape did not have, and which
|
||||
// a shared table with a pot in it cannot do without.
|
||||
|
||||
func liveBlob(game string) LiveHand {
|
||||
return LiveHand{Game: game, State: []byte(`{"phase":"player"}`), Seed1: 7, Seed2: 9}
|
||||
}
|
||||
|
||||
// A settled hand moves the money, writes the audit row, and leaves the felt
|
||||
// empty — and it does all three or none of them.
|
||||
//
|
||||
// The old code logged and carried on if the clear failed. That is the double-pay:
|
||||
// a settled hand still sitting in game_live_hands is a hand that settles again on
|
||||
// the next request, and pays again with it.
|
||||
func TestSettleMovesTheMoneyAndTheFeltTogether(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
|
||||
if err := Stake(player, 200); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CommitHand(player, Commit{
|
||||
Live: liveBlob("blackjack"), Fresh: true, Stake: 200,
|
||||
Done: true, Payout: 390,
|
||||
Audit: Hand{MatrixUser: player, Game: "blackjack", Bet: 200, Payout: 390, Rake: 10, Outcome: "won"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Paid: 1000 - 200 staked + 390 back.
|
||||
if c := chipsOf(t, player); c != 1190 {
|
||||
t.Fatalf("chips = %d after a 390 payout on a 200 stake, want 1190", c)
|
||||
}
|
||||
// The felt is empty, which is what stops it settling a second time.
|
||||
if _, err := LoadLiveHand(player); !errors.Is(err, ErrNoLiveHand) {
|
||||
t.Fatalf("live hand after settle: err = %v, want ErrNoLiveHand", err)
|
||||
}
|
||||
// And the house took its cut, once.
|
||||
take, err := HouseTake(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if take != 10 {
|
||||
t.Fatalf("house take = %d, want 10", take)
|
||||
}
|
||||
}
|
||||
|
||||
// A seat that is already taken refuses the game *and hands the stake back*, in
|
||||
// the same transaction that refused it.
|
||||
//
|
||||
// This was two statements: the save came back ErrHandInProgress, and then a
|
||||
// separate Award put the chips back. A crash in between took a player's stake for
|
||||
// a game that never existed anywhere — no felt, no audit row, no way to find it.
|
||||
func TestARefusedSeatGivesTheStakeBackInTheSameBreath(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
|
||||
// A game is already in progress.
|
||||
if err := Stake(player, 200); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CommitHand(player, Commit{Live: liveBlob("uno"), Fresh: true, Stake: 200}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 800 {
|
||||
t.Fatalf("chips = %d with 200 staked on a live game, want 800", c)
|
||||
}
|
||||
|
||||
// A second game is dealt on top of it: the stake leaves, as it must, in the
|
||||
// same statement that checks it's there.
|
||||
if err := Stake(player, 300); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := CommitHand(player, Commit{
|
||||
Live: liveBlob("blackjack"), Fresh: true, Stake: 300,
|
||||
Done: true, Payout: 600, // a natural, which would settle the instant it's dealt
|
||||
Audit: Hand{MatrixUser: player, Game: "blackjack", Bet: 300, Payout: 600},
|
||||
})
|
||||
if !errors.Is(err, ErrHandInProgress) {
|
||||
t.Fatalf("err = %v, want ErrHandInProgress", err)
|
||||
}
|
||||
|
||||
// The 300 is back, and the natural was *not* paid — a game that was never
|
||||
// seated must not settle.
|
||||
if c := chipsOf(t, player); c != 800 {
|
||||
t.Fatalf("chips = %d after a refused deal, want 800 (the 300 refunded, the 600 never paid)", c)
|
||||
}
|
||||
// And the game already in progress is untouched. This is the bit that matters:
|
||||
// a natural dealt on top of a live game used to be able to settle, clear the
|
||||
// felt, and take the other game's stake down with it.
|
||||
live, err := LoadLiveHand(player)
|
||||
if err != nil {
|
||||
t.Fatalf("the live game is gone: %v", err)
|
||||
}
|
||||
if live.Game != "uno" {
|
||||
t.Fatalf("live game = %q, want the uno game still sitting there", live.Game)
|
||||
}
|
||||
// Nothing was recorded, because nothing finished.
|
||||
if take, err := HouseTake(0); err != nil || take != 0 {
|
||||
t.Fatalf("house take = %d (err %v), want 0 — no hand finished", take, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The deadlock canary.
|
||||
//
|
||||
// SQLite runs at MaxOpenConns(1), so the connection is a global mutex. A bare
|
||||
// Get().Exec inside an open transaction waits for the one connection that the
|
||||
// transaction is holding, and waits forever — it is not an error, it is a hung
|
||||
// process, and because the news app shares the pool it hangs that too.
|
||||
//
|
||||
// So: if anybody ever reaches for Award or RecordHand (rather than award/
|
||||
// recordHand) from inside CommitHand, this test stops returning. It does not
|
||||
// fail with a nice message; it hangs, and the timeout is the message. That is
|
||||
// exactly the failure mode in production, which is the point of catching it here.
|
||||
func TestTheSettleDoesNotDeadlockAgainstItsOwnConnection(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
if err := Stake(player, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- CommitHand(player, Commit{
|
||||
Live: liveBlob("hangman"), Fresh: true, Stake: 100,
|
||||
Done: true, Payout: 234,
|
||||
Audit: Hand{MatrixUser: player, Game: "hangman", Bet: 100, Payout: 234, Rake: 12, Outcome: "won"},
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("CommitHand did not return: something inside the transaction is waiting " +
|
||||
"for the connection the transaction is holding. Look for a Get().Exec that " +
|
||||
"should be a tx.Exec.")
|
||||
}
|
||||
|
||||
if c := chipsOf(t, player); c != 1134 {
|
||||
t.Fatalf("chips = %d, want 1134", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Being paid moves the idle clock, so the reaper leaves a player who is
|
||||
// mid-session alone. Touch used to be a separate statement after the settle; it
|
||||
// is inside it now, and this is what would notice if it got dropped on the way.
|
||||
func TestSettlingKeepsTheReaperAway(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
|
||||
// Backdate the session well past the reaper's patience.
|
||||
if _, err := Get().Exec(
|
||||
`UPDATE game_chips SET last_played = ? WHERE matrix_user = ?`,
|
||||
nowUnix()-int64((2 * SessionIdleAfter).Seconds()), player,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := Stake(player, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CommitHand(player, Commit{
|
||||
Live: liveBlob("trivia"), Fresh: true, Stake: 100,
|
||||
Done: true, Payout: 150,
|
||||
Audit: Hand{MatrixUser: player, Game: "trivia", Bet: 100, Payout: 150, Outcome: "won"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stacks, _, err := IdleStacks(SessionIdleAfter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stacks) != 0 {
|
||||
t.Fatalf("the reaper found %d idle stacks; a player who just settled a hand is not idle", len(stacks))
|
||||
}
|
||||
}
|
||||
149
internal/storage/sourcehealth.go
Normal file
149
internal/storage/sourcehealth.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// SourceHealth is one source's persisted poll health, as written by the poller.
|
||||
type SourceHealth struct {
|
||||
Source string
|
||||
LastPollAt int64 // 0 = never polled
|
||||
LastSuccessAt int64 // 0 = never succeeded
|
||||
LastError string
|
||||
ConsecutiveFailures int
|
||||
LastItemCount int
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// SourceContentStat is per-source content derived from the stories table (plus
|
||||
// post_log for last-posted), independent of poll health.
|
||||
type SourceContentStat struct {
|
||||
Source string
|
||||
Total int // stories currently retained for this source
|
||||
Classified int // of those, how many are classified
|
||||
Paywalled int // of those, how many are gated
|
||||
LastSeenAt int64 // MAX(seen_at); 0 = none
|
||||
LastPostedAt int64 // MAX(post_log.posted_at) joined by guid; 0 = never posted
|
||||
}
|
||||
|
||||
// RecordPollResult upserts a source's health row after a poll attempt. On
|
||||
// success it clears the error and failure counter and records the item count;
|
||||
// on failure it bumps consecutive_failures and stores the message while
|
||||
// preserving the last successful timestamp and item count. Fire-and-forget:
|
||||
// a failure here must never disrupt polling, so errors are logged and swallowed.
|
||||
func RecordPollResult(source string, ok bool, itemCount int, pollErr error) {
|
||||
now := nowUnix()
|
||||
if ok {
|
||||
exec("record poll success",
|
||||
`INSERT INTO source_health
|
||||
(source, last_poll_at, last_success_at, last_error, consecutive_failures, last_item_count, updated_at)
|
||||
VALUES (?, ?, ?, '', 0, ?, ?)
|
||||
ON CONFLICT(source) DO UPDATE SET
|
||||
last_poll_at = excluded.last_poll_at,
|
||||
last_success_at = excluded.last_success_at,
|
||||
last_error = '',
|
||||
consecutive_failures = 0,
|
||||
last_item_count = excluded.last_item_count,
|
||||
updated_at = excluded.updated_at`,
|
||||
source, now, now, itemCount, now)
|
||||
return
|
||||
}
|
||||
msg := ""
|
||||
if pollErr != nil {
|
||||
msg = pollErr.Error()
|
||||
}
|
||||
exec("record poll failure",
|
||||
`INSERT INTO source_health
|
||||
(source, last_poll_at, last_success_at, last_error, consecutive_failures, last_item_count, updated_at)
|
||||
VALUES (?, ?, NULL, ?, 1, 0, ?)
|
||||
ON CONFLICT(source) DO UPDATE SET
|
||||
last_poll_at = excluded.last_poll_at,
|
||||
last_error = excluded.last_error,
|
||||
consecutive_failures = source_health.consecutive_failures + 1,
|
||||
updated_at = excluded.updated_at`,
|
||||
source, now, msg, now)
|
||||
}
|
||||
|
||||
// ListSourceHealth returns the poll-health row for every source that has been
|
||||
// polled at least once, keyed by source name.
|
||||
func ListSourceHealth() (map[string]SourceHealth, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT source, last_poll_at, last_success_at, last_error,
|
||||
consecutive_failures, last_item_count, updated_at
|
||||
FROM source_health`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list source health: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]SourceHealth)
|
||||
for rows.Next() {
|
||||
var h SourceHealth
|
||||
var lastPoll, lastSuccess sql.NullInt64
|
||||
var lastErr sql.NullString
|
||||
if err := rows.Scan(&h.Source, &lastPoll, &lastSuccess, &lastErr,
|
||||
&h.ConsecutiveFailures, &h.LastItemCount, &h.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.LastPollAt = lastPoll.Int64
|
||||
h.LastSuccessAt = lastSuccess.Int64
|
||||
h.LastError = lastErr.String
|
||||
out[h.Source] = h
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SourceContentStats derives per-source content counts from the stories table,
|
||||
// with last-posted joined from post_log by guid. Keyed by source name. Only
|
||||
// sources with at least one retained story appear; the caller pads out the rest
|
||||
// from its configured source list.
|
||||
func SourceContentStats() (map[string]SourceContentStat, error) {
|
||||
out := make(map[string]SourceContentStat)
|
||||
|
||||
rows, err := Get().Query(
|
||||
`SELECT source,
|
||||
COUNT(*),
|
||||
COALESCE(SUM(classified), 0),
|
||||
COALESCE(SUM(paywalled), 0),
|
||||
COALESCE(MAX(seen_at), 0)
|
||||
FROM stories
|
||||
GROUP BY source`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("source content stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var st SourceContentStat
|
||||
if err := rows.Scan(&st.Source, &st.Total, &st.Classified, &st.Paywalled, &st.LastSeenAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[st.Source] = st
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Last-posted per source, joined by guid. Kept separate so sources with
|
||||
// stories but no posts still appear above with a zero last-posted.
|
||||
prows, err := Get().Query(
|
||||
`SELECT s.source, MAX(p.posted_at)
|
||||
FROM post_log p
|
||||
JOIN stories s ON s.guid = p.guid
|
||||
GROUP BY s.source`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("source last-posted: %w", err)
|
||||
}
|
||||
defer prows.Close()
|
||||
for prows.Next() {
|
||||
var source string
|
||||
var lastPosted int64
|
||||
if err := prows.Scan(&source, &lastPosted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := out[source]
|
||||
st.Source = source
|
||||
st.LastPostedAt = lastPosted
|
||||
out[source] = st
|
||||
}
|
||||
return out, prows.Err()
|
||||
}
|
||||
104
internal/storage/sourcehealth_test.go
Normal file
104
internal/storage/sourcehealth_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRecordPollResult_SuccessThenFailure(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
// First a successful poll returning 7 items.
|
||||
RecordPollResult("Feed A", true, 7, nil)
|
||||
h, err := ListSourceHealth()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, ok := h["Feed A"]
|
||||
if !ok {
|
||||
t.Fatal("expected a health row for Feed A")
|
||||
}
|
||||
if a.LastItemCount != 7 || a.ConsecutiveFailures != 0 || a.LastError != "" {
|
||||
t.Errorf("after success: items=%d fails=%d err=%q", a.LastItemCount, a.ConsecutiveFailures, a.LastError)
|
||||
}
|
||||
if a.LastPollAt == 0 || a.LastSuccessAt == 0 {
|
||||
t.Errorf("after success: last_poll=%d last_success=%d, want both set", a.LastPollAt, a.LastSuccessAt)
|
||||
}
|
||||
successTS := a.LastSuccessAt
|
||||
|
||||
// Two failures in a row: failures accumulate, but the last-success timestamp
|
||||
// and item count are preserved so the dashboard still shows when it last worked.
|
||||
RecordPollResult("Feed A", false, 0, errors.New("dial tcp: timeout"))
|
||||
RecordPollResult("Feed A", false, 0, errors.New("502 bad gateway"))
|
||||
h, _ = ListSourceHealth()
|
||||
a = h["Feed A"]
|
||||
if a.ConsecutiveFailures != 2 {
|
||||
t.Errorf("consecutive failures = %d, want 2", a.ConsecutiveFailures)
|
||||
}
|
||||
if a.LastError != "502 bad gateway" {
|
||||
t.Errorf("last error = %q, want %q", a.LastError, "502 bad gateway")
|
||||
}
|
||||
if a.LastSuccessAt != successTS {
|
||||
t.Errorf("last success = %d, want preserved %d", a.LastSuccessAt, successTS)
|
||||
}
|
||||
if a.LastItemCount != 7 {
|
||||
t.Errorf("last item count = %d, want preserved 7", a.LastItemCount)
|
||||
}
|
||||
|
||||
// Recovery clears the error and resets the counter.
|
||||
RecordPollResult("Feed A", true, 3, nil)
|
||||
h, _ = ListSourceHealth()
|
||||
a = h["Feed A"]
|
||||
if a.ConsecutiveFailures != 0 || a.LastError != "" || a.LastItemCount != 3 {
|
||||
t.Errorf("after recovery: fails=%d err=%q items=%d", a.ConsecutiveFailures, a.LastError, a.LastItemCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceContentStats(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
// Source X: two classified stories, one paywalled; one posted.
|
||||
InsertStory(&Story{GUID: "x1", Headline: "X one", ArticleURL: "https://x.com/1", Source: "X", Channel: "tech", Classified: true, SeenAt: 100})
|
||||
InsertStory(&Story{GUID: "x2", Headline: "X two", ArticleURL: "https://x.com/2", Source: "X", Channel: "tech", Classified: true, Paywalled: true, SeenAt: 200})
|
||||
// Source Y: one unclassified story, never posted.
|
||||
InsertStory(&Story{GUID: "y1", Headline: "Y one", ArticleURL: "https://y.com/1", Source: "Y", SeenAt: 150})
|
||||
|
||||
InsertPostLog("x1", "tech", "$e1", "", false)
|
||||
// Backdate/forward the post so we can assert MAX(posted_at).
|
||||
Get().Exec(`UPDATE post_log SET posted_at = ? WHERE guid = ?`, int64(500), "x1")
|
||||
|
||||
stats, err := SourceContentStats()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
x := stats["X"]
|
||||
if x.Total != 2 || x.Classified != 2 || x.Paywalled != 1 {
|
||||
t.Errorf("X: total=%d classified=%d paywalled=%d, want 2/2/1", x.Total, x.Classified, x.Paywalled)
|
||||
}
|
||||
if x.LastSeenAt != 200 {
|
||||
t.Errorf("X last seen = %d, want 200", x.LastSeenAt)
|
||||
}
|
||||
if x.LastPostedAt != 500 {
|
||||
t.Errorf("X last posted = %d, want 500", x.LastPostedAt)
|
||||
}
|
||||
|
||||
y := stats["Y"]
|
||||
if y.Total != 1 || y.Classified != 0 || y.Paywalled != 0 {
|
||||
t.Errorf("Y: total=%d classified=%d paywalled=%d, want 1/0/0", y.Total, y.Classified, y.Paywalled)
|
||||
}
|
||||
if y.LastPostedAt != 0 {
|
||||
t.Errorf("Y last posted = %d, want 0 (never posted)", y.LastPostedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSourceHealth_Empty(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h, err := ListSourceHealth()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(h) != 0 {
|
||||
t.Errorf("expected no health rows, got %d", len(h))
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,85 @@ func TestInsertStoryAndGUIDSeen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStoryReaderText(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
// Reader text is only served for classified, non-sentinel stories — the same
|
||||
// filter the public /api/article endpoint applies so discarded/unclassified
|
||||
// bodies can't be pulled by id enumeration.
|
||||
s := &Story{
|
||||
GUID: "reader-guid",
|
||||
Headline: "Reader Headline",
|
||||
Lede: "Short lede.",
|
||||
Content: "First paragraph.\n\nSecond paragraph.",
|
||||
ArticleURL: "https://example.com/reader",
|
||||
Source: "Src",
|
||||
Channel: "tech",
|
||||
Classified: true,
|
||||
SeenAt: 1700000000,
|
||||
}
|
||||
if err := InsertStory(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var id int64
|
||||
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, s.GUID).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, lede, found, err := GetStoryReaderText(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected story to be found")
|
||||
}
|
||||
if content != s.Content {
|
||||
t.Errorf("content = %q, want %q", content, s.Content)
|
||||
}
|
||||
if lede != s.Lede {
|
||||
t.Errorf("lede = %q, want %q", lede, s.Lede)
|
||||
}
|
||||
|
||||
// A story ingested before content capture (no content) is still found, with
|
||||
// an empty content string so the reader falls back to the lede.
|
||||
bare := &Story{GUID: "bare-guid", Headline: "H", Lede: "Only a lede.", ArticleURL: "https://a.com", Source: "S", Channel: "tech", Classified: true, SeenAt: 1}
|
||||
if err := InsertStory(bare); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var bareID int64
|
||||
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, bare.GUID).Scan(&bareID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, lede, found, err = GetStoryReaderText(bareID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("bare story: found=%v err=%v", found, err)
|
||||
}
|
||||
if content != "" {
|
||||
t.Errorf("bare content = %q, want empty", content)
|
||||
}
|
||||
if lede != "Only a lede." {
|
||||
t.Errorf("bare lede = %q", lede)
|
||||
}
|
||||
|
||||
// Unknown id reports not-found rather than erroring.
|
||||
if _, _, found, err = GetStoryReaderText(999999); err != nil || found {
|
||||
t.Errorf("unknown id: found=%v err=%v, want found=false err=nil", found, err)
|
||||
}
|
||||
|
||||
// A discarded story must not be readable via /api/article, even by direct id.
|
||||
discarded := &Story{GUID: "disc-guid", Headline: "H", Lede: "hidden", Content: "secret body", ArticleURL: "https://d.com", Source: "S", Channel: "_discarded", Classified: true, SeenAt: 1}
|
||||
if err := InsertStory(discarded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var discID int64
|
||||
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, discarded.GUID).Scan(&discID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, found, err = GetStoryReaderText(discID); err != nil || found {
|
||||
t.Errorf("discarded story: found=%v err=%v, want found=false", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertStoryDuplicateGUID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
@@ -98,8 +177,8 @@ func TestMarkClassified(t *testing.T) {
|
||||
func TestPostLogAndLookup(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
InsertPostLog("story-1", "tech", "$event1:example.org", "")
|
||||
InsertPostLog("story-2", "politics", "$event2:example.org", "")
|
||||
InsertPostLog("story-1", "tech", "$event1:example.org", "", false)
|
||||
InsertPostLog("story-2", "politics", "$event2:example.org", "", false)
|
||||
|
||||
guid, channel, found := LookupPostGUID("$event1:example.org")
|
||||
if !found {
|
||||
@@ -193,9 +272,9 @@ func TestMarshalUnmarshalPlatforms(t *testing.T) {
|
||||
func TestInsertPostLog_DuplicateIgnored(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
InsertPostLog("story-1", "tech", "$event1:example.org", "")
|
||||
InsertPostLog("story-1", "tech", "$event1:example.org", "", false)
|
||||
// Same guid+channel should be silently ignored
|
||||
InsertPostLog("story-1", "tech", "$event1-retry:example.org", "")
|
||||
InsertPostLog("story-1", "tech", "$event1-retry:example.org", "", false)
|
||||
|
||||
var count int
|
||||
Get().QueryRow(`SELECT COUNT(*) FROM post_log WHERE guid = ? AND channel = ?`, "story-1", "tech").Scan(&count)
|
||||
@@ -204,7 +283,7 @@ func TestInsertPostLog_DuplicateIgnored(t *testing.T) {
|
||||
}
|
||||
|
||||
// Different channel should be allowed
|
||||
InsertPostLog("story-1", "politics", "$event2:example.org", "")
|
||||
InsertPostLog("story-1", "politics", "$event2:example.org", "", false)
|
||||
Get().QueryRow(`SELECT COUNT(*) FROM post_log WHERE guid = ?`, "story-1").Scan(&count)
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 post_log entries across channels, got %d", count)
|
||||
|
||||
91
internal/storage/story_views_test.go
Normal file
91
internal/storage/story_views_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
// insertClassified is a tiny helper to seed a visible story and return its id.
|
||||
func insertClassified(t *testing.T, guid, headline, content string) int64 {
|
||||
t.Helper()
|
||||
s := &Story{
|
||||
GUID: guid,
|
||||
Headline: headline,
|
||||
Content: content,
|
||||
ArticleURL: "https://example.com/" + guid,
|
||||
Source: "Example Wire",
|
||||
Channel: "tech",
|
||||
Classified: true,
|
||||
SeenAt: nowUnix(),
|
||||
}
|
||||
if err := InsertStory(s); err != nil {
|
||||
t.Fatalf("insert %s: %v", guid, err)
|
||||
}
|
||||
var id int64
|
||||
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, guid).Scan(&id); err != nil {
|
||||
t.Fatalf("lookup %s: %v", guid, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestStoryViews_TotalsAndTrending(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
a := insertClassified(t, "s-a", "Story A", "some body text here")
|
||||
b := insertClassified(t, "s-b", "Story B", "")
|
||||
c := insertClassified(t, "s-c", "Story C", "another body")
|
||||
|
||||
// A read three times, C twice, B never.
|
||||
RecordStoryView(a)
|
||||
RecordStoryView(a)
|
||||
RecordStoryView(a)
|
||||
RecordStoryView(c)
|
||||
RecordStoryView(c)
|
||||
|
||||
totals := StoryViewTotals([]int64{a, b, c})
|
||||
if totals[a] != 3 {
|
||||
t.Errorf("totals[a] = %d, want 3", totals[a])
|
||||
}
|
||||
if _, ok := totals[b]; ok {
|
||||
t.Errorf("totals[b] present = %v, want absent (zero views)", totals[b])
|
||||
}
|
||||
if totals[c] != 2 {
|
||||
t.Errorf("totals[c] = %d, want 2", totals[c])
|
||||
}
|
||||
|
||||
// Trending over the last week: A (3) before C (2); B is absent (no views).
|
||||
trend, err := TrendingStories(10, unixDay()-6)
|
||||
if err != nil {
|
||||
t.Fatalf("trending: %v", err)
|
||||
}
|
||||
if len(trend) != 2 {
|
||||
t.Fatalf("trending len = %d, want 2 (B has no views)", len(trend))
|
||||
}
|
||||
if trend[0].ID != a || trend[1].ID != c {
|
||||
t.Errorf("trending order = [%d, %d], want [%d, %d]", trend[0].ID, trend[1].ID, a, c)
|
||||
}
|
||||
|
||||
// A window that starts after today excludes everything.
|
||||
future, err := TrendingStories(10, unixDay()+1)
|
||||
if err != nil {
|
||||
t.Fatalf("trending future: %v", err)
|
||||
}
|
||||
if len(future) != 0 {
|
||||
t.Errorf("trending (future window) len = %d, want 0", len(future))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoryContentLengths(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
a := insertClassified(t, "c-a", "Has body", "hello world body")
|
||||
b := insertClassified(t, "c-b", "No body", "")
|
||||
|
||||
lengths := StoryContentLengths([]int64{a, b})
|
||||
if lengths[a] != len("hello world body") {
|
||||
t.Errorf("lengths[a] = %d, want %d", lengths[a], len("hello world body"))
|
||||
}
|
||||
if _, ok := lengths[b]; ok {
|
||||
t.Errorf("lengths[b] present, want absent (empty content)")
|
||||
}
|
||||
if got := StoryContentLengths(nil); len(got) != 0 {
|
||||
t.Errorf("StoryContentLengths(nil) = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
907
internal/storage/tables.go
Normal file
907
internal/storage/tables.go
Normal file
@@ -0,0 +1,907 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Shared tables: the casino with more than one person at it.
|
||||
//
|
||||
// The money model is the thing to understand first, because everything else
|
||||
// follows from it, and it is *not* the one the plan sketched.
|
||||
//
|
||||
// **Chips cross into a table when you sit down, and back out when you get up.
|
||||
// Nothing in between touches game_chips at all.** Your buy-in leaves your stack
|
||||
// and becomes a stack inside the engine's own state; antes, bets, pots and
|
||||
// payouts are all moves within that blob; and getting up is the single write
|
||||
// that turns what is in front of you back into chips.
|
||||
//
|
||||
// This is how hold'em already worked as a solo session, and generalising it is
|
||||
// what makes a pot safe. The obvious alternative — settle each hand by crediting
|
||||
// every winner's game_chips row — puts a real money write on the end of every
|
||||
// hand, and a crash between the credit and the state write pays the winner twice.
|
||||
// Here a settle credits nobody: it is a state write, conditional on the version,
|
||||
// and a replay of it affects zero rows and rolls back. The pot cannot be paid
|
||||
// twice because it is never *paid* at all, only moved.
|
||||
//
|
||||
// So the money invariant across a table's whole life is:
|
||||
//
|
||||
// sum(seat stacks in the blob) + pot == sum(game_seats.staked) - rake taken
|
||||
//
|
||||
// and the only two statements that move chips across the border are the stake in
|
||||
// SitDown and the award in LeaveTable — each of them one transaction, each of
|
||||
// them carrying the state write that justifies it.
|
||||
//
|
||||
// The other half is concurrency. A table has two writers where a solo game had
|
||||
// one: an HTTP move, and a turn clock acting for whoever walked away. The version
|
||||
// column is the authority — every state write is conditional on the version its
|
||||
// writer read — and the striped mutex in the web layer is only an optimisation on
|
||||
// top. Correctness has to live in the database, because a mutex does not survive
|
||||
// a redeploy: during a drain, two processes hold two different mutexes over the
|
||||
// same row and both of them believe they are alone.
|
||||
|
||||
var (
|
||||
// ErrStaleTable means somebody else wrote the table first. The caller's read is
|
||||
// out of date; it must reload and decide again. This is not an error condition
|
||||
// so much as the normal outcome of a race, and it is what a 409 is made of.
|
||||
ErrStaleTable = errors.New("games: the table moved on")
|
||||
// ErrNoSuchTable means there is no table with that id.
|
||||
ErrNoSuchTable = errors.New("games: no such table")
|
||||
// ErrSeatTaken means somebody sat down there between the read and the write.
|
||||
ErrSeatTaken = errors.New("games: that seat is taken")
|
||||
)
|
||||
|
||||
// Table is a felt other people can sit at.
|
||||
//
|
||||
// State is the engine's State, serialized whole — the same blob game_live_hands
|
||||
// holds for a solo game, and for the same reason: the deck is in it, so it never
|
||||
// leaves the server, and a hand survives a redeploy.
|
||||
type Table struct {
|
||||
ID string
|
||||
Game string
|
||||
Tier string
|
||||
State []byte
|
||||
Seed1 uint64
|
||||
Seed2 uint64
|
||||
Phase string
|
||||
// HandNo, with the id, is the identity of one hand. It is what the audit trail
|
||||
// keys on now that a seed no longer reproduces a hand: at a shared table the
|
||||
// cards fall the way they do because of the order the others acted, not just
|
||||
// the seed, so "deal it again from seed1/seed2" stopped being a true story the
|
||||
// moment there was a second player.
|
||||
HandNo int64
|
||||
// Version is the concurrency authority. Read it, write against it, and a write
|
||||
// that finds it moved is a write that lost the race.
|
||||
Version int64
|
||||
// Deadline is the unix second by which the seat to act has to act, or 0 for no
|
||||
// clock at all. Only a *human* to act sets one: bots resolve inside ApplyMove
|
||||
// and there is nobody to wait for.
|
||||
Deadline int64
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// Seat is one chair. A seat with no MatrixUser is a bot, which is what makes solo
|
||||
// play just "a table nobody else has joined yet" rather than a second mode.
|
||||
type Seat struct {
|
||||
Seat int
|
||||
MatrixUser string // "" for a bot
|
||||
Name string
|
||||
// Staked is what this player brought and has not yet taken home. The chips are
|
||||
// off their game_chips stack and inside the table blob, where the idle reaper
|
||||
// cannot see them — so this is the row that says they exist.
|
||||
Staked int64
|
||||
Away bool
|
||||
LastSeen int64
|
||||
}
|
||||
|
||||
// Bot reports whether nobody is sitting here.
|
||||
func (s Seat) Bot() bool { return s.MatrixUser == "" }
|
||||
|
||||
// NewTableID mints a table id. Short enough to put in a URL, random enough that
|
||||
// nobody guesses their way onto somebody else's felt.
|
||||
func NewTableID() (string, error) {
|
||||
b := make([]byte, 9)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("games: mint table id: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// OpenTable creates a table and seats it — bots in every chair nobody has taken.
|
||||
func OpenTable(t Table, seats []Seat) error {
|
||||
now := nowUnix()
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin open table: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_tables (id, game, tier, state, seed1, seed2, phase, hand_no, version, deadline, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`,
|
||||
t.ID, t.Game, t.Tier, string(t.State), int64(t.Seed1), int64(t.Seed2),
|
||||
t.Phase, t.HandNo, t.Deadline, now, now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: open table: %w", err)
|
||||
}
|
||||
for _, s := range seats {
|
||||
if err := upsertSeat(tx, t.ID, s, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit open table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenSoloTable opens a table with the player already sitting at it — the "solo
|
||||
// is just a table nobody else has joined yet" path. It is SitDown and OpenTable
|
||||
// fused into one transaction: stake the buy-in, claim the occupancy row, create
|
||||
// the table, and seat everyone (the human, and the bots filling the rest of the
|
||||
// ring). Any step failing rolls the buy-in back with it, so a crash never leaves
|
||||
// a player charged for a felt that does not exist.
|
||||
//
|
||||
// The occupancy claim is the same primary key that stops a second solo hand, so a
|
||||
// player already at a table (or in another game) is refused here with
|
||||
// ErrHandInProgress and their buy-in returned untouched.
|
||||
func OpenSoloTable(t Table, seats []Seat, buyIn int64) error {
|
||||
if buyIn <= 0 {
|
||||
return ErrBadAmount
|
||||
}
|
||||
// The human seat is the one row that is not a bot; its player claims the table.
|
||||
var user, name string
|
||||
for _, s := range seats {
|
||||
if !s.Bot() {
|
||||
user, name = s.MatrixUser, s.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if user == "" {
|
||||
return ErrBadAmount // a solo table with no human is a bug, not a table
|
||||
}
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin open solo: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
res, err := tx.Exec(
|
||||
`UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ?
|
||||
WHERE matrix_user = ? AND chips >= ?`,
|
||||
buyIn, now, now, user, buyIn,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: stake solo buy-in: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrInsufficientChips
|
||||
}
|
||||
|
||||
res, err = tx.Exec(
|
||||
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, table_id, updated_at)
|
||||
VALUES (?, ?, '', 0, 0, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO NOTHING`,
|
||||
user, t.Game, t.ID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: claim solo seat: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrHandInProgress
|
||||
}
|
||||
_ = name
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_tables (id, game, tier, state, seed1, seed2, phase, hand_no, version, deadline, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`,
|
||||
t.ID, t.Game, t.Tier, string(t.State), int64(t.Seed1), int64(t.Seed2),
|
||||
t.Phase, t.HandNo, t.Deadline, now, now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: open solo table: %w", err)
|
||||
}
|
||||
for _, sc := range seats {
|
||||
if err := upsertSeat(tx, t.ID, sc, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit open solo: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertSeat writes a seat row inside an open transaction, bots included.
|
||||
//
|
||||
// last_seen is the caller's if they set one, and only falls back to now when they
|
||||
// did not. That distinction is load-bearing: the turn clock rewrites a seat to
|
||||
// mark it away, and it must carry the seat's *existing* last_seen through
|
||||
// unchanged — otherwise every auto-fold refreshes the away player's clock, and
|
||||
// the abandoned-table reaper (which keys on how long ago a human last acted for
|
||||
// themselves) could never fire.
|
||||
func upsertSeat(tx *sql.Tx, tableID string, s Seat, now int64) error {
|
||||
var user any
|
||||
if s.MatrixUser != "" {
|
||||
user = s.MatrixUser
|
||||
}
|
||||
seen := s.LastSeen
|
||||
if seen == 0 {
|
||||
seen = now
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_seats (table_id, seat, matrix_user, name, staked, away, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(table_id, seat) DO UPDATE SET
|
||||
matrix_user = excluded.matrix_user, name = excluded.name,
|
||||
staked = excluded.staked, away = excluded.away, last_seen = excluded.last_seen`,
|
||||
tableID, s.Seat, user, s.Name, s.Staked, boolInt(s.Away), seen,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: seat: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolInt(b bool) int64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// LoadTable reads a table and everyone at it.
|
||||
func LoadTable(id string) (Table, []Seat, error) {
|
||||
var t Table
|
||||
var state string
|
||||
var s1, s2 int64
|
||||
err := Get().QueryRow(
|
||||
`SELECT id, game, tier, state, seed1, seed2, phase, hand_no, version, deadline, created_at, updated_at
|
||||
FROM game_tables WHERE id = ?`, id,
|
||||
).Scan(&t.ID, &t.Game, &t.Tier, &state, &s1, &s2, &t.Phase, &t.HandNo, &t.Version, &t.Deadline, &t.CreatedAt, &t.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Table{}, nil, ErrNoSuchTable
|
||||
}
|
||||
if err != nil {
|
||||
return Table{}, nil, fmt.Errorf("games: load table: %w", err)
|
||||
}
|
||||
t.State, t.Seed1, t.Seed2 = []byte(state), uint64(s1), uint64(s2)
|
||||
|
||||
seats, err := tableSeats(id)
|
||||
if err != nil {
|
||||
return Table{}, nil, err
|
||||
}
|
||||
return t, seats, nil
|
||||
}
|
||||
|
||||
// tableSeats reads the chairs, in seat order.
|
||||
func tableSeats(id string) ([]Seat, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT seat, matrix_user, name, staked, away, last_seen
|
||||
FROM game_seats WHERE table_id = ? ORDER BY seat`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("games: table seats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Seat
|
||||
for rows.Next() {
|
||||
var s Seat
|
||||
var user sql.NullString
|
||||
var away int64
|
||||
if err := rows.Scan(&s.Seat, &user, &s.Name, &s.Staked, &away, &s.LastSeen); err != nil {
|
||||
return nil, fmt.Errorf("games: scan seat: %w", err)
|
||||
}
|
||||
s.MatrixUser, s.Away = user.String, away != 0
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TableSummary is a table as the lobby lists it: enough to decide whether to sit
|
||||
// down, and nothing that would give away a card.
|
||||
type TableSummary struct {
|
||||
ID string `json:"id"`
|
||||
Game string `json:"game"`
|
||||
Tier string `json:"tier"`
|
||||
Phase string `json:"phase"`
|
||||
Humans int `json:"humans"`
|
||||
Seats int `json:"seats"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// LobbyTables lists the live tables, most recently played first. A game of "" is
|
||||
// all of them.
|
||||
func LobbyTables(game string, limit int) ([]TableSummary, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
q := `SELECT t.id, t.game, t.tier, t.phase, t.updated_at,
|
||||
COUNT(s.seat),
|
||||
COUNT(s.matrix_user)
|
||||
FROM game_tables t
|
||||
LEFT JOIN game_seats s ON s.table_id = t.id`
|
||||
args := []any{}
|
||||
if game != "" {
|
||||
q += ` WHERE t.game = ?`
|
||||
args = append(args, game)
|
||||
}
|
||||
q += ` GROUP BY t.id ORDER BY t.updated_at DESC LIMIT ?`
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := Get().Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("games: lobby: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []TableSummary
|
||||
for rows.Next() {
|
||||
var s TableSummary
|
||||
if err := rows.Scan(&s.ID, &s.Game, &s.Tier, &s.Phase, &s.UpdatedAt, &s.Seats, &s.Humans); err != nil {
|
||||
return nil, fmt.Errorf("games: scan lobby row: %w", err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---- writing a table back --------------------------------------------------
|
||||
|
||||
// TableCommit is one write-back of a shared table.
|
||||
//
|
||||
// There is no payout field, and its absence is the design. A hand ending at a
|
||||
// shared table moves chips *within* the blob — the pot becomes somebody's stack —
|
||||
// so settling one credits nobody and mints nothing. What it writes is the state,
|
||||
// the audit rows, and whatever the seats now look like. The version makes it
|
||||
// exactly-once: a settle that runs twice loses the race with itself.
|
||||
type TableCommit struct {
|
||||
// Table carries the new state and the version that was *read*. The write is
|
||||
// conditional on that version and bumps it.
|
||||
Table Table
|
||||
// Seats to rewrite — a stack that changed hands, a seat that came back from
|
||||
// away. Seats not named here are left alone.
|
||||
Seats []Seat
|
||||
// Audit is the per-seat record of a hand that just ended. Empty mid-hand.
|
||||
Audit []Hand
|
||||
}
|
||||
|
||||
// CommitTable writes a table back, and the hand it just finished with it, in one
|
||||
// transaction.
|
||||
//
|
||||
// The version check is the whole safety property, and it is why this can be
|
||||
// called by the turn clock and an HTTP move at the same instant without either
|
||||
// having to trust the other. Whoever gets there first bumps the version; the
|
||||
// loser's UPDATE matches zero rows, the transaction rolls back, and it comes back
|
||||
// ErrStaleTable with nothing written — no half-settled hand, no audit row for a
|
||||
// hand that did not happen.
|
||||
//
|
||||
// Nothing in here may call Get().Exec. The pool runs at MaxOpenConns(1), so a
|
||||
// bare Exec inside an open transaction waits forever for the connection that this
|
||||
// transaction is holding — and takes the news app down with it. See CommitHand.
|
||||
func CommitTable(c TableCommit) error {
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin commit table: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
if err := saveTable(tx, c.Table, now); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range c.Seats {
|
||||
if err := upsertSeat(tx, c.Table.ID, s, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, h := range c.Audit {
|
||||
if err := recordHand(tx, h, now); err != nil {
|
||||
return err
|
||||
}
|
||||
// Playing a hand is the most deliberate thing a player does. Keep the reaper
|
||||
// off them — their chips are inside the table blob, where it cannot see them
|
||||
// anyway, but their game_chips row is what it reads.
|
||||
if h.MatrixUser == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
|
||||
now, now, h.MatrixUser,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: touch session: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveTable is the conditional state write: it lands only if the version is still
|
||||
// the one the caller read.
|
||||
func saveTable(tx *sql.Tx, t Table, now int64) error {
|
||||
res, err := tx.Exec(
|
||||
`UPDATE game_tables SET state = ?, phase = ?, hand_no = ?, seed1 = ?, seed2 = ?,
|
||||
deadline = ?, version = version + 1, updated_at = ?
|
||||
WHERE id = ? AND version = ?`,
|
||||
string(t.State), t.Phase, t.HandNo, int64(t.Seed1), int64(t.Seed2),
|
||||
t.Deadline, now, t.ID, t.Version,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: save table: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrStaleTable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- sitting down and getting up -------------------------------------------
|
||||
|
||||
// Sit is one player taking one chair, with the table state that has them in it.
|
||||
type Sit struct {
|
||||
Table Table // the new state, and the version that was read
|
||||
Seat Seat // MatrixUser, Name and the chair; Staked is the buy-in
|
||||
BuyIn int64
|
||||
}
|
||||
|
||||
// SitDown moves a player's chips onto a table and puts them in a seat — the first
|
||||
// of the only two statements in the casino that cross the chip/table border.
|
||||
//
|
||||
// It is one transaction and every step of it can refuse:
|
||||
//
|
||||
// - the chips leave in the same statement that checks they are there, so two
|
||||
// joins fired at once cannot spend the same chip;
|
||||
// - the occupancy claim is game_live_hands' primary key, exactly as it is for a
|
||||
// solo hand, so a player cannot be at two tables (or at a table and in a solo
|
||||
// game) at once, and a double-clicked Join is a 409;
|
||||
// - the seat is taken only if a bot is sitting in it, so two players racing for
|
||||
// the last chair cannot both win;
|
||||
// - and the state write is conditional on the version, so the engine's idea of
|
||||
// who is at the table cannot drift from the seat rows.
|
||||
//
|
||||
// Any of those failing rolls back the buy-in with it.
|
||||
func SitDown(s Sit) error {
|
||||
if s.BuyIn <= 0 {
|
||||
return ErrBadAmount
|
||||
}
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin sit: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
res, err := tx.Exec(
|
||||
`UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ?
|
||||
WHERE matrix_user = ? AND chips >= ?`,
|
||||
s.BuyIn, now, now, s.Seat.MatrixUser, s.BuyIn,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: stake buy-in: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrInsufficientChips
|
||||
}
|
||||
|
||||
// The occupancy claim. The state column is empty on purpose: the cards live in
|
||||
// game_tables, and this row exists to be a primary key.
|
||||
res, err = tx.Exec(
|
||||
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, table_id, updated_at)
|
||||
VALUES (?, ?, '', 0, 0, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO NOTHING`,
|
||||
s.Seat.MatrixUser, s.Table.Game, s.Table.ID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: claim seat: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrHandInProgress
|
||||
}
|
||||
|
||||
// Take the chair, but only out of a bot's hands.
|
||||
res, err = tx.Exec(
|
||||
`UPDATE game_seats SET matrix_user = ?, name = ?, staked = ?, away = 0, last_seen = ?
|
||||
WHERE table_id = ? AND seat = ? AND matrix_user IS NULL`,
|
||||
s.Seat.MatrixUser, s.Seat.Name, s.BuyIn, now, s.Table.ID, s.Seat.Seat,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: take seat: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrSeatTaken
|
||||
}
|
||||
|
||||
if err := saveTable(tx, s.Table, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit sit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Leave is one player getting up, with the table state that no longer has them.
|
||||
type Leave struct {
|
||||
Table Table // the new state, and the version that was read
|
||||
Seat int
|
||||
MatrixUser string
|
||||
// Bot is who takes the chair over. A table always has a full complement, so
|
||||
// getting up hands the seat back to the house rather than leaving a hole.
|
||||
Bot string
|
||||
// Amount is what is in front of them: everything they are taking home. It may
|
||||
// be more than they brought, or nothing at all.
|
||||
Amount int64
|
||||
// Audit, if the leaving itself settles something worth recording.
|
||||
Audit []Hand
|
||||
}
|
||||
|
||||
// LeaveTable turns what is in front of a player back into chips — the second and
|
||||
// last statement that crosses the chip/table border.
|
||||
//
|
||||
// One transaction, and the state write is in it. As two statements this is a
|
||||
// double-pay waiting to happen: award 1,240 chips, fail the state write, and the
|
||||
// player reloads to find their seat still there with 1,240 in front of it. They
|
||||
// get up again, and again.
|
||||
func LeaveTable(l Leave) error {
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin leave: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
if err := saveTable(tx, l.Table, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertSeat(tx, l.Table.ID, Seat{Seat: l.Seat, Name: l.Bot}, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM game_live_hands WHERE matrix_user = ? AND table_id = ?`,
|
||||
l.MatrixUser, l.Table.ID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: release seat claim: %w", err)
|
||||
}
|
||||
if err := award(tx, l.MatrixUser, l.Amount, now); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, h := range l.Audit {
|
||||
if err := recordHand(tx, h, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit leave: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlayerSeat reports the table and chair a player is sitting at. It reads the
|
||||
// seat row, which sit and leave keep in lockstep with the occupancy claim in one
|
||||
// transaction, so a row here means a live-hand row there and vice versa.
|
||||
func PlayerSeat(user string) (tableID string, seat int, err error) {
|
||||
err = Get().QueryRow(
|
||||
`SELECT table_id, seat FROM game_seats WHERE matrix_user = ?`, user,
|
||||
).Scan(&tableID, &seat)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", 0, ErrNoLiveHand
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("games: player seat: %w", err)
|
||||
}
|
||||
return tableID, seat, nil
|
||||
}
|
||||
|
||||
// TableOf reports which table a player is sitting at, if any. Read off the
|
||||
// occupancy claim, so it agrees with the cash-out check by construction.
|
||||
func TableOf(user string) (string, error) {
|
||||
var id sql.NullString
|
||||
err := Get().QueryRow(
|
||||
`SELECT table_id FROM game_live_hands WHERE matrix_user = ?`, user,
|
||||
).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNoLiveHand
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("games: table of: %w", err)
|
||||
}
|
||||
return id.String, nil
|
||||
}
|
||||
|
||||
// CloseTable deletes a table nobody is sitting at. Called when the last human
|
||||
// gets up: a felt with six bots on it and nobody watching is not a game, it is a
|
||||
// row that the lobby would advertise forever.
|
||||
func CloseTable(id string) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin close table: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
var humans int
|
||||
if err := tx.QueryRow(
|
||||
`SELECT COUNT(*) FROM game_seats WHERE table_id = ? AND matrix_user IS NOT NULL`, id,
|
||||
).Scan(&humans); err != nil {
|
||||
return fmt.Errorf("games: count humans: %w", err)
|
||||
}
|
||||
if humans > 0 {
|
||||
return nil // somebody is still playing; the table stays
|
||||
}
|
||||
for _, q := range []string{
|
||||
`DELETE FROM game_seats WHERE table_id = ?`,
|
||||
`DELETE FROM game_chat WHERE table_id = ?`,
|
||||
`DELETE FROM game_tables WHERE id = ?`,
|
||||
} {
|
||||
if _, err := tx.Exec(q, id); err != nil {
|
||||
return fmt.Errorf("games: close table: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit close table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AbandonedTables lists tables everyone walked away from: every human seat is
|
||||
// away, and the most recent one acted for themselves longer ago than the cutoff.
|
||||
//
|
||||
// It is the seated-player half of the reaper. The session reaper cashes out loose
|
||||
// chips on a game_chips stack; it cannot see a player whose chips are inside a
|
||||
// table blob, and those are exactly the chips a walked-away poker player has. So
|
||||
// this finds the tables where nobody is coming back and hands them to ReapTable.
|
||||
//
|
||||
// A table with a live hand is never abandoned in this sense — the turn clock is
|
||||
// still folding it forward — so only tables parked between hands qualify. Like
|
||||
// DueTables it closes its rows before returning, because the caller is about to
|
||||
// take a lock and open a transaction against the one connection.
|
||||
func AbandonedTables(cutoff int64) ([]TableRef, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT t.id, t.version FROM game_tables t
|
||||
WHERE t.phase = 'handover'
|
||||
AND EXISTS (SELECT 1 FROM game_seats s
|
||||
WHERE s.table_id = t.id AND s.matrix_user IS NOT NULL)
|
||||
AND NOT EXISTS (SELECT 1 FROM game_seats s
|
||||
WHERE s.table_id = t.id AND s.matrix_user IS NOT NULL
|
||||
AND (s.away = 0 OR s.last_seen >= ?))`,
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("games: abandoned tables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []TableRef
|
||||
for rows.Next() {
|
||||
var r TableRef
|
||||
if err := rows.Scan(&r.ID, &r.Version); err != nil {
|
||||
return nil, fmt.Errorf("games: scan abandoned table: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Reap is one abandoned table being cashed out and closed. Stacks is what each
|
||||
// human seat has in front of it, read from the engine blob by the caller — the
|
||||
// only game-specific fact the reaper needs, since the chips-home number lives
|
||||
// inside a state only the engine can decode.
|
||||
type Reap struct {
|
||||
TableID string
|
||||
Version int64
|
||||
// Humans is the seats to cash out, each paired with the stack going home. A
|
||||
// seat's Amount may be zero (they busted and never got up), which still has to
|
||||
// close their occupancy row so they can play again.
|
||||
Humans []ReapSeat
|
||||
}
|
||||
|
||||
// ReapSeat is one human being sent home from an abandoned table.
|
||||
type ReapSeat struct {
|
||||
Seat int
|
||||
MatrixUser string
|
||||
Amount int64
|
||||
}
|
||||
|
||||
// ReapTable cashes out every human at an abandoned table and deletes it, in one
|
||||
// transaction, conditional on the version so it cannot race a player who came
|
||||
// back to the felt in the same instant.
|
||||
//
|
||||
// It is LeaveTable and CloseTable fused: award each stack, release each occupancy
|
||||
// claim, then drop the seats, chat and table. The version guard is what makes it
|
||||
// safe against a returning player — if their sit or move bumped the version
|
||||
// between the scan and here, every row matches zero and the whole thing rolls
|
||||
// back, leaving the table exactly as the returning player left it.
|
||||
func ReapTable(r Reap) error {
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin reap: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
// Bump the version first, and refuse if it moved. Nothing below is conditional,
|
||||
// so this one check has to stand for the whole reap.
|
||||
res, err := tx.Exec(
|
||||
`UPDATE game_tables SET version = version + 1, updated_at = ? WHERE id = ? AND version = ?`,
|
||||
now, r.TableID, r.Version,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: reap bump version: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrStaleTable
|
||||
}
|
||||
|
||||
for _, h := range r.Humans {
|
||||
if h.Amount > 0 {
|
||||
if err := award(tx, h.MatrixUser, h.Amount, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM game_live_hands WHERE matrix_user = ? AND table_id = ?`,
|
||||
h.MatrixUser, r.TableID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: reap release claim: %w", err)
|
||||
}
|
||||
}
|
||||
for _, q := range []string{
|
||||
`DELETE FROM game_seats WHERE table_id = ?`,
|
||||
`DELETE FROM game_chat WHERE table_id = ?`,
|
||||
`DELETE FROM game_tables WHERE id = ?`,
|
||||
} {
|
||||
if _, err := tx.Exec(q, r.TableID); err != nil {
|
||||
return fmt.Errorf("games: reap close: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit reap: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- the turn clock --------------------------------------------------------
|
||||
|
||||
// TableRef is a table the clock has found expired: which one, and at what version
|
||||
// it was seen.
|
||||
//
|
||||
// The version is the point. The clock acts only if it is *still* that version by
|
||||
// the time it takes the lock, because otherwise: Bob's raise lands in the same
|
||||
// second his clock expires, the action passes to Cara, and the clock — still
|
||||
// holding its scan-time belief that the seat to act has run out of time — folds
|
||||
// Cara, who had twenty-five seconds left. That is a one-second window that recurs
|
||||
// on every single turn of every hand.
|
||||
type TableRef struct {
|
||||
ID string
|
||||
Version int64
|
||||
}
|
||||
|
||||
// DueTables lists the tables whose clock has run out.
|
||||
//
|
||||
// It closes the rows before returning, and it must: the caller is about to take a
|
||||
// table lock and open a transaction, and holding a *sql.Rows across that means
|
||||
// holding the only connection in the pool while waiting for it. That is not a
|
||||
// slow query, it is a deadlock.
|
||||
func DueTables(now int64) ([]TableRef, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT id, version FROM game_tables WHERE deadline > 0 AND deadline <= ?`, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("games: due tables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []TableRef
|
||||
for rows.Next() {
|
||||
var r TableRef
|
||||
if err := rows.Scan(&r.ID, &r.Version); err != nil {
|
||||
return nil, fmt.Errorf("games: scan due table: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PushDeadlines shoves every live clock out by a grace period. Called once on
|
||||
// boot: a deploy takes a table's clock with it, and without this the first tick
|
||||
// after a restart wakes up to find every deadline in the casino already expired
|
||||
// and auto-folds all of them at once.
|
||||
func PushDeadlines(grace int64) error {
|
||||
if _, err := Get().Exec(
|
||||
`UPDATE game_tables SET deadline = ? WHERE deadline > 0 AND deadline < ?`,
|
||||
nowUnix()+grace, nowUnix()+grace,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: push deadlines: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- chat ------------------------------------------------------------------
|
||||
|
||||
// ChatLine is one thing somebody said at the felt.
|
||||
type ChatLine struct {
|
||||
ID int64 `json:"id"`
|
||||
HandNo int64 `json:"hand_no"`
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
SaidAt int64 `json:"said_at"`
|
||||
// Mine is filled in by the web layer, per reader. It is not in the database.
|
||||
Mine bool `json:"mine,omitempty"`
|
||||
}
|
||||
|
||||
// MaxChatLen is where a message stops. Long enough for a table read, short enough
|
||||
// that nobody pastes a novel onto the felt.
|
||||
const MaxChatLen = 240
|
||||
|
||||
// Say records a line of chat and returns it. Its hand_no is stamped from the
|
||||
// table, which is what makes the log answer the only question chat at a money
|
||||
// table ever really raises: what was said, during which hand.
|
||||
func Say(tableID, user, name, body string) (ChatLine, error) {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return ChatLine{}, ErrBadAmount
|
||||
}
|
||||
if len(body) > MaxChatLen {
|
||||
body = body[:MaxChatLen]
|
||||
}
|
||||
var handNo int64
|
||||
if err := Get().QueryRow(`SELECT hand_no FROM game_tables WHERE id = ?`, tableID).Scan(&handNo); errors.Is(err, sql.ErrNoRows) {
|
||||
return ChatLine{}, ErrNoSuchTable
|
||||
} else if err != nil {
|
||||
return ChatLine{}, fmt.Errorf("games: chat hand no: %w", err)
|
||||
}
|
||||
|
||||
now := nowUnix()
|
||||
res, err := Get().Exec(
|
||||
`INSERT INTO game_chat (table_id, hand_no, matrix_user, name, body, said_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
tableID, handNo, user, name, body, now,
|
||||
)
|
||||
if err != nil {
|
||||
return ChatLine{}, fmt.Errorf("games: say: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return ChatLine{ID: id, HandNo: handNo, Name: name, Body: body, SaidAt: now}, nil
|
||||
}
|
||||
|
||||
// Chat reads the last few lines said at a table, oldest first.
|
||||
func Chat(tableID string, limit int) ([]ChatLine, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT id, hand_no, name, body, said_at FROM game_chat
|
||||
WHERE table_id = ? ORDER BY id DESC LIMIT ?`, tableID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("games: chat: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ChatLine
|
||||
for rows.Next() {
|
||||
var c ChatLine
|
||||
if err := rows.Scan(&c.ID, &c.HandNo, &c.Name, &c.Body, &c.SaidAt); err != nil {
|
||||
return nil, fmt.Errorf("games: scan chat: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Read newest-first so the LIMIT takes the right end; hand them back in the
|
||||
// order they were said.
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
271
internal/storage/tables_test.go
Normal file
271
internal/storage/tables_test.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// openTestTable stands up a table with a full ring of bots and returns it. Six
|
||||
// seats, because that is hold'em's ring and the most seats any game here has.
|
||||
func openTestTable(t *testing.T, id, game string) Table {
|
||||
t.Helper()
|
||||
tbl := Table{
|
||||
ID: id, Game: game, Tier: "1-2", State: []byte(`{}`),
|
||||
Seed1: 1, Seed2: 2, Phase: "betting", HandNo: 1,
|
||||
}
|
||||
seats := make([]Seat, 6)
|
||||
for i := range seats {
|
||||
seats[i] = Seat{Seat: i, Name: "bot"}
|
||||
}
|
||||
if err := OpenTable(tbl, seats); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tbl
|
||||
}
|
||||
|
||||
// reload reads a table back and fails the test if it is gone.
|
||||
func reload(t *testing.T, id string) (Table, []Seat) {
|
||||
t.Helper()
|
||||
tbl, seats, err := LoadTable(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tbl, seats
|
||||
}
|
||||
|
||||
func TestOpenTable_SeatsAreAllBots(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
openTestTable(t, "t1", "holdem")
|
||||
|
||||
_, seats := reload(t, "t1")
|
||||
if len(seats) != 6 {
|
||||
t.Fatalf("want 6 seats, got %d", len(seats))
|
||||
}
|
||||
for _, s := range seats {
|
||||
if !s.Bot() {
|
||||
t.Errorf("seat %d should be a bot", s.Seat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSitDown_MovesChipsOntoTheTable(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
tbl := openTestTable(t, "t1", "holdem")
|
||||
fund(t, player, 5000)
|
||||
|
||||
if err := SitDown(Sit{
|
||||
Table: tbl,
|
||||
Seat: Seat{Seat: 2, MatrixUser: player, Name: "reala"},
|
||||
BuyIn: 1000,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The chips are off the stack...
|
||||
if got := chipsOf(t, player); got != 4000 {
|
||||
t.Errorf("stack: want 4000, got %d", got)
|
||||
}
|
||||
// ...and onto the seat.
|
||||
_, seats := reload(t, "t1")
|
||||
seat := seats[2]
|
||||
if seat.MatrixUser != player || seat.Staked != 1000 {
|
||||
t.Errorf("seat 2: want reala staked 1000, got %q staked %d", seat.MatrixUser, seat.Staked)
|
||||
}
|
||||
// The occupancy claim points at the table.
|
||||
id, err := TableOf(player)
|
||||
if err != nil || id != "t1" {
|
||||
t.Errorf("TableOf: want t1, got %q err %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSitDown_CannotTakeATakenSeat(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
tbl := openTestTable(t, "t1", "holdem")
|
||||
fund(t, player, 5000)
|
||||
fund(t, "@bob:parodia.dev", 5000)
|
||||
|
||||
if err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 2, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tbl2, _ := reload(t, "t1")
|
||||
err := SitDown(Sit{Table: tbl2, Seat: Seat{Seat: 2, MatrixUser: "@bob:parodia.dev", Name: "bob"}, BuyIn: 1000})
|
||||
if !errors.Is(err, ErrSeatTaken) {
|
||||
t.Fatalf("want ErrSeatTaken, got %v", err)
|
||||
}
|
||||
// Bob's chips did not move.
|
||||
if got := chipsOf(t, "@bob:parodia.dev"); got != 5000 {
|
||||
t.Errorf("bob's stack should be untouched, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSitDown_CannotSitAtTwoTables(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
tbl1 := openTestTable(t, "t1", "holdem")
|
||||
tbl2 := openTestTable(t, "t2", "holdem")
|
||||
fund(t, player, 5000)
|
||||
|
||||
if err := SitDown(Sit{Table: tbl1, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := SitDown(Sit{Table: tbl2, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000})
|
||||
if !errors.Is(err, ErrHandInProgress) {
|
||||
t.Fatalf("want ErrHandInProgress, got %v", err)
|
||||
}
|
||||
// The buy-in for the second table rolled back.
|
||||
if got := chipsOf(t, player); got != 4000 {
|
||||
t.Errorf("only the first buy-in should have left the stack, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSitDown_InsufficientChipsRollsBack(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
tbl := openTestTable(t, "t1", "holdem")
|
||||
fund(t, player, 500)
|
||||
|
||||
err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000})
|
||||
if !errors.Is(err, ErrInsufficientChips) {
|
||||
t.Fatalf("want ErrInsufficientChips, got %v", err)
|
||||
}
|
||||
if _, err := TableOf(player); !errors.Is(err, ErrNoLiveHand) {
|
||||
t.Errorf("no seat should have been claimed, got %v", err)
|
||||
}
|
||||
_, seats := reload(t, "t1")
|
||||
if seats[0].MatrixUser != "" {
|
||||
t.Errorf("seat should still be a bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaveTable_BringsChipsHome(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
tbl := openTestTable(t, "t1", "holdem")
|
||||
fund(t, player, 5000)
|
||||
if err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tbl2, _ := reload(t, "t1")
|
||||
// Got up with 1,240 — up on the session.
|
||||
if err := LeaveTable(Leave{Table: tbl2, Seat: 0, MatrixUser: player, Bot: "bot", Amount: 1240}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := chipsOf(t, player); got != 5240 {
|
||||
t.Errorf("want 5240 back on the stack, got %d", got)
|
||||
}
|
||||
if _, err := TableOf(player); !errors.Is(err, ErrNoLiveHand) {
|
||||
t.Errorf("seat claim should be gone, got %v", err)
|
||||
}
|
||||
_, seats := reload(t, "t1")
|
||||
if seats[0].MatrixUser != "" {
|
||||
t.Errorf("a bot should have taken the empty chair")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveTable_VersionGuardsTheWrite(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
openTestTable(t, "t1", "holdem")
|
||||
|
||||
a, _ := reload(t, "t1") // both read version 0
|
||||
b, _ := reload(t, "t1")
|
||||
|
||||
a.State = []byte(`{"a":1}`)
|
||||
if err := CommitTable(TableCommit{Table: a}); err != nil {
|
||||
t.Fatalf("first write should win: %v", err)
|
||||
}
|
||||
b.State = []byte(`{"b":2}`)
|
||||
if err := CommitTable(TableCommit{Table: b}); !errors.Is(err, ErrStaleTable) {
|
||||
t.Fatalf("second write should be stale, got %v", err)
|
||||
}
|
||||
|
||||
after, _ := reload(t, "t1")
|
||||
if string(after.State) != `{"a":1}` {
|
||||
t.Errorf("the winning write should stand, got %s", after.State)
|
||||
}
|
||||
if after.Version != 1 {
|
||||
t.Errorf("version should have bumped once, got %d", after.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueTables_OnlyExpiredClocks(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
now := nowUnix()
|
||||
|
||||
past := openTestTable(t, "past", "holdem")
|
||||
past.Deadline = now - 5
|
||||
if err := CommitTable(TableCommit{Table: past}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
future := openTestTable(t, "future", "holdem")
|
||||
future.Deadline = now + 60
|
||||
if err := CommitTable(TableCommit{Table: future}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
openTestTable(t, "noclock", "holdem") // deadline 0
|
||||
|
||||
due, err := DueTables(now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(due) != 1 || due[0].ID != "past" {
|
||||
t.Fatalf("only the past-due table should show, got %+v", due)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_KeepsTheHandItWasSaidDuring(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
openTestTable(t, "t1", "holdem") // hand_no 1
|
||||
|
||||
if _, err := Say("t1", player, "reala", "nice hand"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The table moves to the next hand.
|
||||
tbl2, _ := reload(t, "t1")
|
||||
tbl2.HandNo = 2
|
||||
if err := CommitTable(TableCommit{Table: tbl2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Say("t1", player, "reala", "and another"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lines, err := Chat("t1", 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("want 2 lines, got %d", len(lines))
|
||||
}
|
||||
if lines[0].Body != "nice hand" || lines[0].HandNo != 1 {
|
||||
t.Errorf("first line should be hand 1: %+v", lines[0])
|
||||
}
|
||||
if lines[1].HandNo != 2 {
|
||||
t.Errorf("second line should be hand 2: %+v", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseTable_KeepsATableWithAHumanAtIt(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
tbl := openTestTable(t, "t1", "holdem")
|
||||
fund(t, player, 5000)
|
||||
if err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := CloseTable("t1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := LoadTable("t1"); err != nil {
|
||||
t.Errorf("a table with a human should survive close, got %v", err)
|
||||
}
|
||||
|
||||
// Everyone leaves; now it goes.
|
||||
tbl2, _ := reload(t, "t1")
|
||||
if err := LeaveTable(Leave{Table: tbl2, Seat: 0, MatrixUser: player, Bot: "bot", Amount: 1000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CloseTable("t1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := LoadTable("t1"); !errors.Is(err, ErrNoSuchTable) {
|
||||
t.Errorf("an all-bot table should close, got %v", err)
|
||||
}
|
||||
}
|
||||
147
internal/storage/trivia.go
Normal file
147
internal/storage/trivia.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/trivia"
|
||||
)
|
||||
|
||||
// The trivia bank.
|
||||
//
|
||||
// Questions are pulled from OpenTDB in the background (internal/opentdb) and
|
||||
// drawn from here when a ladder is built. Nothing in a player's round ever
|
||||
// touches the network.
|
||||
|
||||
// ErrBankEmpty means the bank hasn't got enough questions of that difficulty to
|
||||
// build a ladder. It is a real state, not a bug: a fresh database has an empty
|
||||
// bank until the refill loop has been round a few times.
|
||||
var ErrBankEmpty = fmt.Errorf("trivia: the bank is short of questions")
|
||||
|
||||
// AddTriviaQuestions files a fetched batch. Questions already in the bank are
|
||||
// ignored rather than replaced — OpenTDB hands back overlapping batches, and the
|
||||
// UNIQUE on the text is what stops the bank becoming forty questions deep.
|
||||
// Returns how many were actually new, which is what the refill loop logs.
|
||||
func AddTriviaQuestions(difficulty string, qs []trivia.Question) (int, error) {
|
||||
if len(qs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("trivia: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
stmt, err := tx.Prepare(
|
||||
`INSERT OR IGNORE INTO trivia_questions
|
||||
(difficulty, category, question, correct, incorrect, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("trivia: prepare: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
now := time.Now().Unix()
|
||||
added := 0
|
||||
for _, q := range qs {
|
||||
if len(q.Answers) < 2 || q.Correct < 0 || q.Correct >= len(q.Answers) {
|
||||
continue
|
||||
}
|
||||
correct := q.Answers[q.Correct]
|
||||
wrong := make([]string, 0, len(q.Answers)-1)
|
||||
for i, a := range q.Answers {
|
||||
if i != q.Correct {
|
||||
wrong = append(wrong, a)
|
||||
}
|
||||
}
|
||||
blob, err := json.Marshal(wrong)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
res, err := stmt.Exec(difficulty, q.Category, q.Text, correct, string(blob), now)
|
||||
if err != nil {
|
||||
return added, fmt.Errorf("trivia: insert: %w", err)
|
||||
}
|
||||
if n, err := res.RowsAffected(); err == nil {
|
||||
added += int(n)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("trivia: commit: %w", err)
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
|
||||
// CountTrivia is how many questions of a difficulty the bank holds. The refill
|
||||
// loop reads it to decide whether to bother.
|
||||
func CountTrivia(difficulty string) (int, error) {
|
||||
var n int
|
||||
if err := Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM trivia_questions WHERE difficulty = ?`, difficulty,
|
||||
).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("trivia: count: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// DrawTrivia deals a ladder: n distinct questions of one difficulty, chosen with
|
||||
// the game's own rng.
|
||||
//
|
||||
// The choice is made in Go rather than with ORDER BY RANDOM() so that the seed
|
||||
// in the audit log means something: the same seed against the same bank deals
|
||||
// the same ladder, which is what lets a disputed game be replayed. It reads the
|
||||
// ids first and picks from them, so a bank of a few thousand questions costs one
|
||||
// small scan rather than a sort of the whole table.
|
||||
func DrawTrivia(difficulty string, n int, rng *rand.Rand) ([]trivia.Question, error) {
|
||||
if n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT id FROM trivia_questions WHERE difficulty = ? ORDER BY id`, difficulty)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trivia: draw ids: %w", err)
|
||||
}
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("trivia: scan id: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("trivia: draw ids: %w", err)
|
||||
}
|
||||
if len(ids) < n {
|
||||
return nil, ErrBankEmpty
|
||||
}
|
||||
|
||||
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
|
||||
pick := ids[:n]
|
||||
|
||||
out := make([]trivia.Question, 0, n)
|
||||
for _, id := range pick {
|
||||
var q trivia.Question
|
||||
var correct, blob string
|
||||
if err := Get().QueryRow(
|
||||
`SELECT category, question, correct, incorrect FROM trivia_questions WHERE id = ?`, id,
|
||||
).Scan(&q.Category, &q.Text, &correct, &blob); err != nil {
|
||||
return nil, fmt.Errorf("trivia: load question: %w", err)
|
||||
}
|
||||
var wrong []string
|
||||
if err := json.Unmarshal([]byte(blob), &wrong); err != nil {
|
||||
return nil, fmt.Errorf("trivia: unreadable answers: %w", err)
|
||||
}
|
||||
// Correct: 0 is a convention the engine immediately destroys — New()
|
||||
// reshuffles every question against the game's seed. Nothing that reaches a
|
||||
// player depends on the order they come out of the table in.
|
||||
q.Answers = append([]string{correct}, wrong...)
|
||||
q.Correct = 0
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -6,6 +6,7 @@ type Story struct {
|
||||
GUID string
|
||||
Headline string
|
||||
Lede string
|
||||
Content string // full article text for reader mode (feed content:encoded or scraped body); "" when unavailable
|
||||
ImageURL string
|
||||
ArticleURL string
|
||||
URLCanonical string
|
||||
@@ -16,5 +17,6 @@ type Story struct {
|
||||
Classified bool
|
||||
Paywalled bool // source article is gated; ArticleURL may be an archive snapshot
|
||||
SeenAt int64
|
||||
Posted bool // true if this story has been posted to Matrix (joined from post_log)
|
||||
PublishedAt int64 // from RSS pubDate/Atom published; 0 if missing. Future dates are clamped to SeenAt at ingest.
|
||||
Posted bool // true if this story has been posted to Matrix (joined from post_log)
|
||||
}
|
||||
|
||||
145
internal/storage/userstate.go
Normal file
145
internal/storage/userstate.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SetRead marks (read=true) or clears (read=false) the read flag for one story
|
||||
// and one signed-in user (keyed by OIDC subject). Read and bookmark state share
|
||||
// a row; clearing the last remaining flag removes the row.
|
||||
func SetRead(sub string, storyID int64, read bool) error {
|
||||
var ts any
|
||||
if read {
|
||||
ts = nowUnix()
|
||||
}
|
||||
_, err := Get().Exec(`
|
||||
INSERT INTO user_story_state (user_sub, story_id, read_at, bookmarked_at)
|
||||
VALUES (?, ?, ?, NULL)
|
||||
ON CONFLICT(user_sub, story_id) DO UPDATE SET read_at = excluded.read_at`,
|
||||
sub, storyID, ts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set read: %w", err)
|
||||
}
|
||||
return pruneEmptyState(sub, storyID)
|
||||
}
|
||||
|
||||
// SetBookmark adds (on=true) or removes (on=false) a bookmark for one story and
|
||||
// one signed-in user. See SetRead for the shared-row semantics.
|
||||
func SetBookmark(sub string, storyID int64, on bool) error {
|
||||
var ts any
|
||||
if on {
|
||||
ts = nowUnix()
|
||||
}
|
||||
_, err := Get().Exec(`
|
||||
INSERT INTO user_story_state (user_sub, story_id, read_at, bookmarked_at)
|
||||
VALUES (?, ?, NULL, ?)
|
||||
ON CONFLICT(user_sub, story_id) DO UPDATE SET bookmarked_at = excluded.bookmarked_at`,
|
||||
sub, storyID, ts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set bookmark: %w", err)
|
||||
}
|
||||
return pruneEmptyState(sub, storyID)
|
||||
}
|
||||
|
||||
// pruneEmptyState drops a row once neither flag is set, keeping the table to
|
||||
// only meaningful state.
|
||||
func pruneEmptyState(sub string, storyID int64) error {
|
||||
_, err := Get().Exec(
|
||||
`DELETE FROM user_story_state
|
||||
WHERE user_sub = ? AND story_id = ? AND read_at IS NULL AND bookmarked_at IS NULL`,
|
||||
sub, storyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prune user state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserStoryState reports, for the given story ids, which are read and which are
|
||||
// bookmarked by this user. Both maps contain only ids whose flag is set, so a
|
||||
// missing key means false. An empty sub or id list returns empty maps.
|
||||
func UserStoryState(sub string, ids []int64) (read, bookmarked map[int64]bool, err error) {
|
||||
read = make(map[int64]bool)
|
||||
bookmarked = make(map[int64]bool)
|
||||
if sub == "" || len(ids) == 0 {
|
||||
return read, bookmarked, nil
|
||||
}
|
||||
q := `SELECT story_id, read_at, bookmarked_at FROM user_story_state
|
||||
WHERE user_sub = ? AND story_id IN (` + placeholders(len(ids)) + `)`
|
||||
args := make([]any, 0, len(ids)+1)
|
||||
args = append(args, sub)
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := Get().Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("user story state: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var r, b sql.NullInt64
|
||||
if err := rows.Scan(&id, &r, &b); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if r.Valid {
|
||||
read[id] = true
|
||||
}
|
||||
if b.Valid {
|
||||
bookmarked[id] = true
|
||||
}
|
||||
}
|
||||
return read, bookmarked, rows.Err()
|
||||
}
|
||||
|
||||
// ListBookmarks returns the user's bookmarked stories, most recently bookmarked
|
||||
// first, restricted to still-classified stories.
|
||||
func ListBookmarks(sub string, limit, offset int) ([]Story, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
|
||||
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
|
||||
FROM user_story_state u
|
||||
JOIN stories s ON s.id = u.story_id
|
||||
WHERE u.user_sub = ?
|
||||
AND u.bookmarked_at IS NOT NULL
|
||||
AND s.classified = 1
|
||||
AND s.channel NOT IN ('_discarded', '_duplicate')
|
||||
ORDER BY u.bookmarked_at DESC, u.story_id DESC
|
||||
LIMIT ? OFFSET ?`, sub, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountBookmarks returns how many bookmarked stories the user would see on the
|
||||
// bookmarks page. It applies the same classified filter as ListBookmarks so the
|
||||
// "N saved" header can't exceed the number of rendered cards.
|
||||
func CountBookmarks(sub string) (int, error) {
|
||||
var n int
|
||||
err := Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM user_story_state u
|
||||
JOIN stories s ON s.id = u.story_id
|
||||
WHERE u.user_sub = ? AND u.bookmarked_at IS NOT NULL AND s.classified = 1
|
||||
AND s.channel NOT IN ('_discarded', '_duplicate')`,
|
||||
sub).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// placeholders returns "?, ?, …" with n slots for an IN clause.
|
||||
func placeholders(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Repeat("?, ", n-1) + "?"
|
||||
}
|
||||
126
internal/storage/userstate_test.go
Normal file
126
internal/storage/userstate_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
func seedStory(t *testing.T, guid string) int64 {
|
||||
t.Helper()
|
||||
s := &Story{
|
||||
GUID: guid,
|
||||
Headline: "Headline " + guid,
|
||||
Lede: "Lede.",
|
||||
ArticleURL: "https://example.com/" + guid,
|
||||
Source: "Test Source",
|
||||
Channel: "tech",
|
||||
Classified: true,
|
||||
SeenAt: 1700000000,
|
||||
}
|
||||
if err := InsertStory(s); err != nil {
|
||||
t.Fatalf("insert story %s: %v", guid, err)
|
||||
}
|
||||
var id int64
|
||||
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, guid).Scan(&id); err != nil {
|
||||
t.Fatalf("lookup id %s: %v", guid, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestUserStoryState_ReadAndBookmark(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
id1 := seedStory(t, "s1")
|
||||
id2 := seedStory(t, "s2")
|
||||
const sub = "ak-sub-1"
|
||||
|
||||
// Nothing set yet.
|
||||
read, marks, err := UserStoryState(sub, []int64{id1, id2})
|
||||
if err != nil {
|
||||
t.Fatalf("initial state: %v", err)
|
||||
}
|
||||
if len(read) != 0 || len(marks) != 0 {
|
||||
t.Fatalf("expected empty state, got read=%v bookmarked=%v", read, marks)
|
||||
}
|
||||
|
||||
// Mark id1 read, bookmark id1 and id2.
|
||||
if err := SetRead(sub, id1, true); err != nil {
|
||||
t.Fatalf("set read: %v", err)
|
||||
}
|
||||
if err := SetBookmark(sub, id1, true); err != nil {
|
||||
t.Fatalf("set bookmark id1: %v", err)
|
||||
}
|
||||
if err := SetBookmark(sub, id2, true); err != nil {
|
||||
t.Fatalf("set bookmark id2: %v", err)
|
||||
}
|
||||
read, marks, _ = UserStoryState(sub, []int64{id1, id2})
|
||||
if !read[id1] || read[id2] {
|
||||
t.Fatalf("read map wrong: %v", read)
|
||||
}
|
||||
if !marks[id1] || !marks[id2] {
|
||||
t.Fatalf("bookmark map wrong: %v", marks)
|
||||
}
|
||||
// id1 carries both flags in a single row.
|
||||
if !read[id1] || !marks[id1] {
|
||||
t.Fatalf("expected id1 read+bookmarked, read=%v marks=%v", read, marks)
|
||||
}
|
||||
|
||||
// ListBookmarks orders newest bookmark first, tiebreaking on story id so the
|
||||
// result is deterministic within a single second.
|
||||
bm, err := ListBookmarks(sub, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list bookmarks: %v", err)
|
||||
}
|
||||
if len(bm) != 2 {
|
||||
t.Fatalf("expected 2 bookmarks, got %d", len(bm))
|
||||
}
|
||||
if bm[0].ID != id2 || bm[1].ID != id1 {
|
||||
t.Fatalf("bookmark order wrong: %d then %d", bm[0].ID, bm[1].ID)
|
||||
}
|
||||
if n, _ := CountBookmarks(sub); n != 2 {
|
||||
t.Fatalf("count bookmarks = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserStoryState_ClearRemovesRow(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
id := seedStory(t, "s1")
|
||||
const sub = "ak-sub-1"
|
||||
|
||||
if err := SetRead(sub, id, true); err != nil {
|
||||
t.Fatalf("set read: %v", err)
|
||||
}
|
||||
if err := SetRead(sub, id, false); err != nil {
|
||||
t.Fatalf("unset read: %v", err)
|
||||
}
|
||||
// Row should be gone since neither flag is set.
|
||||
var n int
|
||||
if err := Get().QueryRow(`SELECT COUNT(*) FROM user_story_state WHERE user_sub = ?`, sub).Scan(&n); err != nil {
|
||||
t.Fatalf("count rows: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("expected row pruned, found %d", n)
|
||||
}
|
||||
|
||||
// Setting read then bookmark then clearing only read must keep the row.
|
||||
_ = SetRead(sub, id, true)
|
||||
_ = SetBookmark(sub, id, true)
|
||||
_ = SetRead(sub, id, false)
|
||||
read, marks, _ := UserStoryState(sub, []int64{id})
|
||||
if read[id] {
|
||||
t.Fatalf("expected not read")
|
||||
}
|
||||
if !marks[id] {
|
||||
t.Fatalf("expected still bookmarked after clearing read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserStoryState_ScopedBySub(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
id := seedStory(t, "s1")
|
||||
_ = SetBookmark("user-a", id, true)
|
||||
|
||||
read, marks, _ := UserStoryState("user-b", []int64{id})
|
||||
if len(read) != 0 || len(marks) != 0 {
|
||||
t.Fatalf("user-b should see no state, got read=%v marks=%v", read, marks)
|
||||
}
|
||||
if n, _ := CountBookmarks("user-b"); n != 0 {
|
||||
t.Fatalf("user-b count = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
467
internal/web/adventure.go
Normal file
467
internal/web/adventure.go
Normal file
@@ -0,0 +1,467 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// AdvFact is the game-event fact gogobee POSTs to Pete. It mirrors the contract
|
||||
// in pete_adventure_news_voice.md. Names are character names only (never Matrix
|
||||
// handles) and Actors is the allow-list of the only names permitted to appear in
|
||||
// rendered output.
|
||||
type AdvFact struct {
|
||||
GUID string `json:"guid"`
|
||||
EventType string `json:"event_type"`
|
||||
Tier string `json:"tier"` // "priority" | "bulletin"
|
||||
Actors []string `json:"actors"`
|
||||
Subject string `json:"subject"`
|
||||
Opponent string `json:"opponent"`
|
||||
Boss string `json:"boss"`
|
||||
Zone string `json:"zone"`
|
||||
Region string `json:"region"`
|
||||
Level int `json:"level"`
|
||||
Count int `json:"count"`
|
||||
Outcome string `json:"outcome"`
|
||||
Stakes string `json:"stakes"`
|
||||
ClassRace string `json:"class_race"`
|
||||
Milestone string `json:"milestone"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
NoPush bool `json:"no_push"`
|
||||
}
|
||||
|
||||
// AdvPost is a priority adventure item to post live to Matrix. Kept minimal and
|
||||
// web-local so the web package needs no dependency on internal/poster.
|
||||
type AdvPost struct {
|
||||
GUID string
|
||||
Headline string
|
||||
Lede string
|
||||
ImageURL string
|
||||
ArticleURL string
|
||||
Source string
|
||||
Channel string
|
||||
}
|
||||
|
||||
// PriorityPoster posts a priority adventure item to Matrix immediately. main
|
||||
// adapts *poster.Queue.PostNow to this; nil in web-only/local modes.
|
||||
type PriorityPoster func(AdvPost)
|
||||
|
||||
const advSource = "Pete"
|
||||
|
||||
// advBackfillEvent is the synthetic post_log event id used to retire a no_push
|
||||
// (cold-start backfill) dispatch against the daily digest. It never went to
|
||||
// Matrix; the row exists only so the digest skips it.
|
||||
const advBackfillEvent = "adv-backfill"
|
||||
|
||||
// handleAdventureIngest receives a game-event fact from gogobee, templates it
|
||||
// into a deterministic story, publishes it to the /adventure section, and posts
|
||||
// PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID.
|
||||
func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var f AdvFact
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&f); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if f.GUID == "" || f.EventType == "" {
|
||||
http.Error(w, "guid and event_type are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Fact-guard: any player name we render must be in the actors allow-list.
|
||||
// gogobee pre-sanitizes, but Pete never trusts the channel — this is the
|
||||
// last line before a name reaches a public page.
|
||||
if !factGuard(f) {
|
||||
slog.Warn("adventure ingest: fact-guard rejected", "guid", f.GUID, "event_type", f.EventType)
|
||||
http.Error(w, "fact-guard: subject/opponent not in actors", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
headline, lede, ok := renderAdventure(f)
|
||||
if !ok {
|
||||
http.Error(w, "unknown event_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success.
|
||||
if storage.IsGUIDSeen(f.GUID) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("duplicate"))
|
||||
return
|
||||
}
|
||||
|
||||
// A fact with no occurred_at would otherwise be stored at the Unix epoch:
|
||||
// dated 1970 on the permalink, pinned to the bottom of the section, and
|
||||
// outside every digest window. Treat "missing" as "now".
|
||||
occurredAt := f.OccurredAt
|
||||
if occurredAt <= 0 {
|
||||
occurredAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
articleURL := s.advPermalink(f.GUID)
|
||||
imageURL := advArtURL(f.EventType)
|
||||
if err := storage.InsertStory(&storage.Story{
|
||||
GUID: f.GUID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ImageURL: imageURL,
|
||||
ArticleURL: articleURL,
|
||||
Source: advSource,
|
||||
Channel: "adventure",
|
||||
Classified: true,
|
||||
SeenAt: occurredAt,
|
||||
PublishedAt: occurredAt,
|
||||
}); err != nil {
|
||||
slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err)
|
||||
http.Error(w, "insert failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
|
||||
|
||||
// NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only
|
||||
// the live post isn't enough: the digest collects adventure rows that carry no
|
||||
// post_log entry, so a backfilled bulletin would still be swept into the next
|
||||
// roundup — the back-catalogue dump NoPush exists to prevent. Retire the guid
|
||||
// against the digest up front instead.
|
||||
if f.NoPush {
|
||||
storage.MarkAdventureDigested([]string{f.GUID}, advBackfillEvent)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
|
||||
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
|
||||
// digest. Website section always gets the row above regardless of tier.
|
||||
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
|
||||
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
||||
// media), and the link's og:image carries the preview instead.
|
||||
// No Source: the source tag exists to credit an outlet Pete is relaying
|
||||
// (`ars technica`). On his own reporting it renders as him signing his
|
||||
// own name under his own message.
|
||||
s.advPost(AdvPost{
|
||||
GUID: f.GUID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ArticleURL: articleURL,
|
||||
Channel: s.adv.Channel,
|
||||
})
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// advEventMeta maps an event_type to a short display label and emoji used by the
|
||||
// permalink page (and, later, OG art selection). Unknown types fall back to a
|
||||
// neutral dispatch label so a new gogobee event never renders blank.
|
||||
func advEventMeta(eventType string) (label, emoji string) {
|
||||
switch eventType {
|
||||
case "siege_start", "siege_win", "siege_loss":
|
||||
return "The Siege", "🏰"
|
||||
case "boss_first", "boss_kill":
|
||||
return "Boss down", "🐉"
|
||||
case "zone_first":
|
||||
return "First clear", "🗺️"
|
||||
case "zone_clear":
|
||||
return "Zone cleared", "🗺️"
|
||||
case "death":
|
||||
return "In memoriam", "🪦"
|
||||
case "arrival":
|
||||
return "New arrival", "👋"
|
||||
case "standings", "rival_result":
|
||||
return "The rival board", "⚔️"
|
||||
case "pete_duel_loss", "pete_duel_win":
|
||||
return "Pete's duels", "🤝"
|
||||
case "milestone":
|
||||
return "Milestone", "🏅"
|
||||
case "retreat":
|
||||
return "Pulled out", "🎒"
|
||||
case "departure":
|
||||
return "Wandered off", "🚪"
|
||||
case "mischief_contract":
|
||||
return "Coin on their head", "😈"
|
||||
case "mischief_survived":
|
||||
return "They walked away", "🛡️"
|
||||
case "mischief_downed":
|
||||
return "The contract landed", "💀"
|
||||
case "mischief_fizzled":
|
||||
return "Nobody home", "🚪"
|
||||
}
|
||||
return "Dispatch", "📣"
|
||||
}
|
||||
|
||||
// advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by
|
||||
// handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses
|
||||
// the external-image thumbnailer.
|
||||
func advArtURL(eventType string) string {
|
||||
return "/adventure/art/" + eventType + ".svg"
|
||||
}
|
||||
|
||||
// handleAdventureArt renders the themed emblem for an event type — an adventure
|
||||
// gradient with the event's emoji and label. Deterministic and dependency-free
|
||||
// (no external asset), so every dispatch card has visual identity instead of the
|
||||
// blank placeholder that made the section look broken next to RSS cards.
|
||||
func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
eventType := strings.TrimSuffix(r.PathValue("type"), ".svg")
|
||||
label, emoji := advEventMeta(eventType)
|
||||
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = fmt.Fprintf(w, advArtSVG, template.HTMLEscapeString(emoji), template.HTMLEscapeString(strings.ToUpper(label)))
|
||||
}
|
||||
|
||||
// advArtSVG is the emblem template: %s = emoji, %s = label. 1200×630 (the OG
|
||||
// card ratio) so the same image works as a link-preview image.
|
||||
const advArtSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#7c5ce8"/>
|
||||
<stop offset="1" stop-color="#5836b8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="630" fill="url(#g)"/>
|
||||
<text x="600" y="300" font-size="260" text-anchor="middle" dominant-baseline="central">%s</text>
|
||||
<text x="600" y="500" font-size="64" font-family="Fredoka, Nunito, system-ui, sans-serif" font-weight="700" fill="#ffffff" text-anchor="middle" letter-spacing="6" opacity="0.92">%s</text>
|
||||
</svg>`
|
||||
|
||||
// advStoryPage is the per-story permalink view. It reuses the shared layout so a
|
||||
// dispatch reads like the rest of the site, with an adventure-themed hero.
|
||||
type advStoryPage struct {
|
||||
pageData
|
||||
EventLabel string
|
||||
Emoji string
|
||||
Headline string
|
||||
Body string
|
||||
Region string
|
||||
When string
|
||||
Permalink string
|
||||
}
|
||||
|
||||
// handleAdventureStory serves the server-rendered permalink for one dispatch
|
||||
// (the article_url every ingested story points at). Public and cacheable; 404s
|
||||
// when the section is disabled or the guid is unknown. Character names in the
|
||||
// stored headline/body already passed the ingest fact-guard, so nothing
|
||||
// player-controlled reaches here unchecked.
|
||||
func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
guid := r.PathValue("guid")
|
||||
st, err := storage.GetStoryByGUID(guid)
|
||||
if err != nil || st == nil || st.Channel != "adventure" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.track(r, "adventure")
|
||||
|
||||
// event_type is encoded in the guid prefix (e.g. "death:<hash>:<ts>"); fall
|
||||
// back to the neutral dispatch meta when it isn't a known type.
|
||||
eventType, _, _ := strings.Cut(guid, ":")
|
||||
label, emoji := advEventMeta(eventType)
|
||||
|
||||
body := st.Content
|
||||
if strings.TrimSpace(body) == "" {
|
||||
body = st.Lede // template-only dispatches carry the write-up in the lede
|
||||
}
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = "adventure"
|
||||
base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
|
||||
if abs := strings.TrimRight(s.cfg.BaseURL, "/"); abs != "" {
|
||||
base.OGImage = abs + advArtURL(eventType) // emblem for link unfurls
|
||||
}
|
||||
s.render(w, "story", advStoryPage{
|
||||
pageData: base,
|
||||
EventLabel: label,
|
||||
Emoji: emoji,
|
||||
Headline: st.Headline,
|
||||
Body: body,
|
||||
Region: "", // reserved: region isn't stored on the row yet
|
||||
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
|
||||
Permalink: s.advPermalink(guid),
|
||||
})
|
||||
}
|
||||
|
||||
// bearerOK checks the Authorization: Bearer header against the configured ingest
|
||||
// token in constant time.
|
||||
func (s *Server) bearerOK(r *http.Request) bool {
|
||||
const prefix = "Bearer "
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, prefix) || s.adv.IngestToken == "" {
|
||||
return false
|
||||
}
|
||||
got := strings.TrimPrefix(h, prefix)
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(s.adv.IngestToken)) == 1
|
||||
}
|
||||
|
||||
// siteURL makes a root-relative path absolute against BaseURL. Links that go out
|
||||
// to Matrix need the absolute form to survive safeHref; when BaseURL isn't
|
||||
// configured the relative form is all we have, and it's still fine on-site.
|
||||
func (s *Server) siteURL(path string) string {
|
||||
return strings.TrimRight(s.cfg.BaseURL, "/") + path
|
||||
}
|
||||
|
||||
// advPermalink builds the per-story Pete permalink used as article_url (the card
|
||||
// link + Matrix link). The guid is path-escaped: it's ingest-supplied, and a
|
||||
// stray "/" or "?" would otherwise produce a link that routes somewhere else.
|
||||
func (s *Server) advPermalink(guid string) string {
|
||||
return s.siteURL("/adventure/" + url.PathEscape(guid))
|
||||
}
|
||||
|
||||
// factGuard verifies every player-name field we might render is present in the
|
||||
// actors allow-list. Boss/zone/region/milestone are game-authored content, not
|
||||
// player-controlled, so they are not guarded.
|
||||
func factGuard(f AdvFact) bool {
|
||||
allow := make(map[string]bool, len(f.Actors))
|
||||
for _, a := range f.Actors {
|
||||
if a != "" {
|
||||
allow[a] = true
|
||||
}
|
||||
}
|
||||
if f.Subject != "" && !allow[f.Subject] {
|
||||
return false
|
||||
}
|
||||
if f.Opponent != "" && !allow[f.Opponent] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// renderAdventure returns the deterministic headline + lede for a fact. Copied
|
||||
// verbatim from the voice spec (pete_adventure_news_voice.md). Template-only —
|
||||
// no LLM — so output is safe and reproducible. ok is false for an unknown type.
|
||||
func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
|
||||
atLevel := ""
|
||||
if f.Level > 0 {
|
||||
atLevel = fmt.Sprintf(", at level %d", f.Level)
|
||||
}
|
||||
switch f.EventType {
|
||||
case "siege_start":
|
||||
return fmt.Sprintf("Breaking: %s is marching on the town.", f.Boss),
|
||||
fmt.Sprintf("Folks, this is the big one — %s has camped outside the gates and the whole community's needed to turn it back. You've got %s. Let's rally.", f.Boss, f.Stakes), true
|
||||
case "siege_win":
|
||||
return fmt.Sprintf("The town holds! %s turned back.", f.Boss),
|
||||
fmt.Sprintf("What a turnout — %d defenders stood shoulder to shoulder and sent %s packing. Spoils are going out now. Proud of you all.", f.Count, f.Boss), true
|
||||
case "siege_loss":
|
||||
return fmt.Sprintf("Heavy news: %s broke through.", f.Boss),
|
||||
fmt.Sprintf("We gave it everything, but %s got past the gates and took its tribute. We'll be ready next time — heads up, everyone.", f.Boss), true
|
||||
case "boss_first":
|
||||
return fmt.Sprintf("First ever: %s brings down %s.", f.Subject, f.Boss),
|
||||
fmt.Sprintf("History in %s today — %s is the first anyone's seen clear %s. Nobody had done it before. Hats off.", f.Region, f.Subject, f.Boss), true
|
||||
case "boss_kill":
|
||||
return fmt.Sprintf("%s takes down %s again.", f.Subject, f.Boss),
|
||||
fmt.Sprintf("Another clean run in %s today. Routine for %s by now — but still worth a nod.", f.Zone, f.Subject), true
|
||||
case "zone_first", "zone_clear":
|
||||
// gogobee splits the realm's first-ever clear (zone_first, priority) from a
|
||||
// later repeat (zone_clear, bulletin); they share a lede but differ in
|
||||
// headline. Fall back to the tier for a legacy zone_first that predates the
|
||||
// split.
|
||||
if f.EventType == "zone_first" || f.Tier == "priority" {
|
||||
headline = fmt.Sprintf("%s cleared for the very first time.", f.Zone)
|
||||
} else {
|
||||
headline = fmt.Sprintf("%s clears %s.", f.Subject, f.Zone)
|
||||
}
|
||||
inRegion := ""
|
||||
if f.Region != "" {
|
||||
inRegion = " in " + f.Region
|
||||
}
|
||||
return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true
|
||||
case "death":
|
||||
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
|
||||
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
|
||||
case "retreat":
|
||||
// An expedition that came apart without killing anyone. Until gogobee
|
||||
// started sending these, the feed had no way to say "it went badly and
|
||||
// everyone lived" — so it never said it, and the classes that retreat
|
||||
// often simply never appeared. Warm, not a failure notice: everyone came
|
||||
// home, and that is the part Pete leads with.
|
||||
howFar := "barely a day in"
|
||||
if f.Count > 1 {
|
||||
howFar = fmt.Sprintf("%d days in", f.Count)
|
||||
}
|
||||
return fmt.Sprintf("%s backed out of %s.", f.Subject, f.Zone),
|
||||
fmt.Sprintf("%s turned around %s — %s got the better of them this time%s, and they made the call to walk out rather than push it. Everybody came home breathing, which is the bit that counts. That dungeon'll still be there next week.",
|
||||
f.Subject, howFar, f.Zone, atLevel), true
|
||||
case "departure":
|
||||
// A bored adventurer let themselves out. Nobody sent them — they got
|
||||
// restless waiting on a player who wasn't coming, took the cheap supplies
|
||||
// they could afford, and went. Pete plays it straight and a little fond;
|
||||
// the joke tells itself, and the player it's about may well be reading.
|
||||
return fmt.Sprintf("%s got bored and left without waiting.", f.Subject),
|
||||
fmt.Sprintf("No orders, no escort, no fuss — %s packed the cheapest kit on the shelf and set off into %s%s. Nobody told them to. Nobody talked them out of it either. We'll let you know how it goes.", f.Subject, f.Zone, atLevel), true
|
||||
case "mischief_contract":
|
||||
// Somebody paid to have a monster sent after an adventurer who is out in a
|
||||
// dungeon right now. Anonymous unless the buyer paid extra to sign it, and
|
||||
// the anonymity is the story: Pete reports the money, not the name he
|
||||
// doesn't have. Opponent carries the buyer only when it's public.
|
||||
if f.Opponent != "" {
|
||||
return fmt.Sprintf("%s has put %s on %s's head.", f.Opponent, f.Stakes, f.Subject),
|
||||
fmt.Sprintf("No secret about it — %s paid for a %s to go find %s out in whatever hole they're currently down, and signed the thing. It's out there looking right now. If %s comes back breathing, they keep a cut of that money.",
|
||||
f.Opponent, strings.ToLower(f.Boss), f.Subject, f.Subject), true
|
||||
}
|
||||
return fmt.Sprintf("Someone's put %s on %s's head.", f.Stakes, f.Subject),
|
||||
fmt.Sprintf("Word came in quiet: %s has been paid for a %s to go looking for %s, and whoever paid it isn't saying so. It's already out there. Survive it and the money's theirs — and we all find out who signed the cheque.",
|
||||
f.Stakes, strings.ToLower(f.Boss), f.Subject), true
|
||||
case "mischief_survived":
|
||||
// The unseal. A survival is the only thing that names an anonymous buyer,
|
||||
// and it is the whole brake on casual griefing — so Pete leads with it.
|
||||
return fmt.Sprintf("%s walked away from it. It was %s who paid.", f.Subject, f.Opponent),
|
||||
fmt.Sprintf("%s came for %s, and %s is the one still standing — %s richer for the trouble. The contract's been opened up, and the name inside it is %s. Make of that what you will, folks.",
|
||||
f.Boss, f.Subject, f.Subject, f.Stakes, f.Opponent), true
|
||||
case "mischief_downed":
|
||||
who := "Nobody's saying who paid for it"
|
||||
if f.Opponent != "" {
|
||||
who = fmt.Sprintf("%s paid for it, and put their name on it", f.Opponent)
|
||||
}
|
||||
return fmt.Sprintf("%s didn't walk away.", f.Subject),
|
||||
fmt.Sprintf("A %s found %s mid-run%s and put them on the floor. They're being carried home — alive, which is more than the contract asked for, but that expedition's finished. %s.",
|
||||
f.Boss, f.Subject, atLevel, who), true
|
||||
case "mischief_fizzled":
|
||||
return fmt.Sprintf("The monster sent for %s arrived to an empty dungeon.", f.Subject),
|
||||
fmt.Sprintf("Somebody spent good money to have %s ambushed, and %s had already gone home. It wandered the halls for a bit and left. Most of the fee's been refunded. The rest, the town's keeping.",
|
||||
f.Subject, f.Subject), true
|
||||
case "arrival":
|
||||
return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
|
||||
fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true
|
||||
case "standings":
|
||||
return "The rival board's been shaken up.",
|
||||
fmt.Sprintf("%s is on the move — here's where the standings sit today.", f.Subject), true
|
||||
case "rival_result":
|
||||
return fmt.Sprintf("%s settles the score with %s.", f.Subject, f.Opponent),
|
||||
fmt.Sprintf("Their duel went %s's way today, and the board reflects it. Good match, you two.", f.Subject), true
|
||||
case "pete_duel_loss":
|
||||
if f.Tier == "priority" {
|
||||
headline = fmt.Sprintf("You got me, %s.", f.Subject)
|
||||
} else {
|
||||
headline = fmt.Sprintf("%s got the better of me again.", f.Subject)
|
||||
}
|
||||
return headline, fmt.Sprintf("Credit where it's due — %s beat me fair and square. Good duel. I'll want a rematch when you're ready.", f.Subject), true
|
||||
case "pete_duel_win":
|
||||
return fmt.Sprintf("Held my ground against %s today.", f.Subject),
|
||||
fmt.Sprintf("Closer than the record will show, honestly — %s pushed me. Rematch whenever you like.", f.Subject), true
|
||||
case "milestone":
|
||||
return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
|
||||
fmt.Sprintf("One for the books — %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
154
internal/web/adventure_digest.go
Normal file
154
internal/web/adventure_digest.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The BULLETIN digest is the batched counterpart to the live PRIORITY beats.
|
||||
// Once a day (Adventure.DigestHour, UTC) Pete collects the adventure dispatches
|
||||
// that were seen since the last digest but never posted live — exactly the
|
||||
// bulletins — and posts a single warm roundup to the adventure channel. The
|
||||
// website already carries each one as its own card; the digest is the Matrix-only
|
||||
// nudge so quiet-but-real activity surfaces without one ping per event.
|
||||
|
||||
const (
|
||||
// digestWindow bounds how far back a digest looks. Wider than a day so a
|
||||
// missed run (process down over a digest hour) still sweeps up the gap;
|
||||
// re-collection is prevented by MarkAdventureDigested, not by the window.
|
||||
digestWindow = 48 * time.Hour
|
||||
// digestCap bounds a single digest. Far past the observed volume (a dormant
|
||||
// community, per the voice spec); a runaway just truncates with a log line.
|
||||
digestCap = 40
|
||||
// digestPreview is how many headlines the roundup lede lists by name before
|
||||
// collapsing the rest to "…and N more".
|
||||
digestPreview = 4
|
||||
)
|
||||
|
||||
// StartAdventureDigest launches the daily bulletin-digest loop. No-op unless the
|
||||
// section is enabled AND there's a live Matrix poster AND a channel to post to —
|
||||
// i.e. website-only and local modes never post a digest.
|
||||
func (s *Server) StartAdventureDigest(ctx context.Context) {
|
||||
if !s.adv.Enabled || s.advPost == nil || s.adv.Channel == "" {
|
||||
return
|
||||
}
|
||||
go s.runAdventureDigest(ctx)
|
||||
}
|
||||
|
||||
// runAdventureDigest sleeps until the next DigestHour, posts, then repeats every
|
||||
// 24h. Sleeping to a wall-clock hour (not a fixed ticker from boot) keeps the
|
||||
// digest at a predictable time of day across restarts.
|
||||
func (s *Server) runAdventureDigest(ctx context.Context) {
|
||||
hour := s.adv.DigestHourOrDefault()
|
||||
slog.Info("web: adventure digest scheduler started", "digest_hour_utc", hour, "channel", s.adv.Channel)
|
||||
for {
|
||||
wait := durUntilNextHour(time.Now().UTC(), hour)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(wait):
|
||||
s.postDailyDigest(time.Now().UTC())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// durUntilNextHour returns the duration from now to the next occurrence of the
|
||||
// given UTC hour. If it's exactly the hour now, it targets tomorrow so a restart
|
||||
// at the hour doesn't double-fire.
|
||||
func durUntilNextHour(now time.Time, hour int) time.Duration {
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC)
|
||||
if !next.After(now) {
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
return next.Sub(now)
|
||||
}
|
||||
|
||||
// postDailyDigest collects the window's un-posted bulletins, posts one roundup,
|
||||
// and marks them digested so they don't recur. Silent when there's nothing new —
|
||||
// a dormant realm should stay quiet, not ship an empty digest.
|
||||
func (s *Server) postDailyDigest(now time.Time) {
|
||||
since := now.Add(-digestWindow).Unix()
|
||||
bulletins, total, err := storage.UnpostedAdventureSince(since, digestCap)
|
||||
if err != nil {
|
||||
slog.Error("adventure digest: query failed", "err", err)
|
||||
return
|
||||
}
|
||||
if len(bulletins) == 0 {
|
||||
return
|
||||
}
|
||||
if total > len(bulletins) {
|
||||
slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap, "total", total)
|
||||
}
|
||||
|
||||
date := now.Format("2006-01-02")
|
||||
eventID := "adv-digest:" + date
|
||||
headline, lede := buildDigest(bulletins, total)
|
||||
|
||||
s.advPost(AdvPost{
|
||||
GUID: eventID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ArticleURL: s.digestURL(date),
|
||||
Channel: s.adv.Channel,
|
||||
})
|
||||
|
||||
guids := make([]string, len(bulletins))
|
||||
for i, b := range bulletins {
|
||||
guids[i] = b.GUID
|
||||
}
|
||||
storage.MarkAdventureDigested(guids, eventID)
|
||||
slog.Info("adventure digest: posted", "date", date, "items", len(bulletins))
|
||||
}
|
||||
|
||||
// buildDigest renders the roundup headline + lede in Pete's warm reporter voice.
|
||||
// total is how many bulletins the window actually holds, which is larger than
|
||||
// len(bulletins) when the cap truncated the fetch — the counts quoted to readers
|
||||
// have to describe the realm, not the slice. Headlines come from stored,
|
||||
// fact-guarded rows, so no player-controlled text is introduced here.
|
||||
func buildDigest(bulletins []storage.Story, total int) (headline, lede string) {
|
||||
if total == 1 {
|
||||
return "Today in the realm: one dispatch.",
|
||||
fmt.Sprintf("Quiet day out there, but one worth noting — %s Full story on the board.", trimHeadline(bulletins[0].Headline))
|
||||
}
|
||||
headline = fmt.Sprintf("Today in the realm: %d dispatches.", total)
|
||||
|
||||
shown := bulletins
|
||||
if len(shown) > digestPreview {
|
||||
shown = shown[:digestPreview]
|
||||
}
|
||||
parts := make([]string, len(shown))
|
||||
for i, b := range shown {
|
||||
parts[i] = trimHeadline(b.Headline)
|
||||
}
|
||||
list := strings.Join(parts, " ")
|
||||
more := ""
|
||||
if total > len(shown) {
|
||||
more = fmt.Sprintf(" …and %d more.", total-len(shown))
|
||||
}
|
||||
return headline, fmt.Sprintf("Here's what came across the wire today. %s%s The full board's on the site.", list, more)
|
||||
}
|
||||
|
||||
// trimHeadline ensures a headline ends with sentence punctuation so the joined
|
||||
// lede reads as prose rather than a run-on.
|
||||
func trimHeadline(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
if last := h[len(h)-1]; last != '.' && last != '!' && last != '?' {
|
||||
h += "."
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// digestURL points the roundup at the section, with a per-day query param so the
|
||||
// canonical-URL dedup treats each day's digest as distinct (it strips only
|
||||
// tracking params, keeping ?digest=).
|
||||
func (s *Server) digestURL(date string) string {
|
||||
return s.siteURL("/adventure?digest=" + date)
|
||||
}
|
||||
55
internal/web/adventure_retreat_test.go
Normal file
55
internal/web/adventure_retreat_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The retreat bulletin — gogobee's newest event type.
|
||||
//
|
||||
// The failure this guards against is silent and total: handleAdventureIngest
|
||||
// answers an unrecognized event_type with 400, and gogobee's sender treats any
|
||||
// non-2xx as a failure, retries eight times over ~two hours, and then PARKS the
|
||||
// row forever. So a gogobee that emits `retreat` against a Pete that doesn't
|
||||
// know the word doesn't log an error anyone reads — it just quietly drops every
|
||||
// retreat the realm ever files. Pete has to learn the word first, and this test
|
||||
// is what says he has.
|
||||
func TestAdventureIngest_AcceptsRetreat(t *testing.T) {
|
||||
const token = "s3cret-token"
|
||||
s, posted := newAdvServer(t, token)
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "retreat:abc:1000", EventType: "retreat", Tier: "bulletin",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Level: 12, Count: 3, Outcome: "retreated",
|
||||
OccurredAt: 1000,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d body=%s — Pete rejected a retreat, so gogobee will "+
|
||||
"retry it eight times and park it forever", rw.Code, rw.Body.String())
|
||||
}
|
||||
|
||||
got, err := storage.GetStoryByGUID("retreat:abc:1000")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("retreat was accepted but not stored: %v", err)
|
||||
}
|
||||
// It has to actually say what happened, in Pete's voice, using only the
|
||||
// supplied facts.
|
||||
body := got.Headline + " " + got.Lede
|
||||
for _, want := range []string{"Brannigan", "the Underforge"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("rendered retreat is missing %q: %q", want, body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got.Lede, "3 days in") {
|
||||
t.Errorf("the day count never made it into the copy: %q", got.Lede)
|
||||
}
|
||||
// Bulletin, not priority: a retreat goes in the daily digest, it does not
|
||||
// interrupt the room. Pete announcing every failed run live would be a
|
||||
// firehose — and an unkind one.
|
||||
if len(*posted) != 0 {
|
||||
t.Errorf("a bulletin was posted live: %+v", *posted)
|
||||
}
|
||||
}
|
||||
371
internal/web/adventure_test.go
Normal file
371
internal/web/adventure_test.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// newAdvServer builds a web server with the adventure seam enabled and a
|
||||
// capturing priority poster, backed by a fresh temp DB.
|
||||
func newAdvServer(t *testing.T, token string) (*Server, *[]AdvPost) {
|
||||
t.Helper()
|
||||
storage.Close()
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "adv.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
|
||||
var posted []AdvPost
|
||||
adv := config.AdventureConfig{Enabled: true, IngestToken: token, Channel: "adventure"}
|
||||
// Mirror the production poster's side effect: queue.PostNow records a
|
||||
// post_log row, which is exactly what marks a beat "posted" and thus
|
||||
// excludes it from the bulletin digest.
|
||||
poster := func(p AdvPost) {
|
||||
posted = append(posted, p)
|
||||
storage.InsertPostLog(p.GUID, "adventure", p.GUID, "", false)
|
||||
}
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example"},
|
||||
nil, true, adv, poster)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, &posted
|
||||
}
|
||||
|
||||
func postFact(t *testing.T, s *Server, token string, f AdvFact) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(f)
|
||||
req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleAdventureIngest(rw, req)
|
||||
return rw
|
||||
}
|
||||
|
||||
// TestAdventureIngestEndToEnd covers the seam: a priority death fact is
|
||||
// bearer-accepted, templated, stored as an adventure story, and posted live.
|
||||
func TestAdventureIngestEndToEnd(t *testing.T) {
|
||||
const token = "s3cret-token"
|
||||
s, posted := newAdvServer(t, token)
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String())
|
||||
}
|
||||
|
||||
got, err := storage.GetStoryByGUID("death:abc:1000")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("story not stored: %v", err)
|
||||
}
|
||||
if got.Channel != "adventure" || got.Source != advSource {
|
||||
t.Errorf("channel/source = %q/%q", got.Channel, got.Source)
|
||||
}
|
||||
if got.Headline != "We lost Brannigan in the Underforge." {
|
||||
t.Errorf("headline = %q", got.Headline)
|
||||
}
|
||||
if got.ArticleURL != "https://news.example/adventure/death:abc:1000" {
|
||||
t.Errorf("article_url = %q", got.ArticleURL)
|
||||
}
|
||||
if len(*posted) != 1 || (*posted)[0].GUID != f.GUID {
|
||||
t.Fatalf("priority post not delivered: %+v", *posted)
|
||||
}
|
||||
|
||||
// Idempotent re-delivery: no error, no second post.
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("dup ingest status = %d", rw.Code)
|
||||
}
|
||||
if len(*posted) != 1 {
|
||||
t.Errorf("duplicate fact re-posted: %d posts", len(*posted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventurePermalink renders the per-story page the article_url points at:
|
||||
// an ingested dispatch must be fetchable at /adventure/{guid} with its headline
|
||||
// and body, and an unknown guid must 404.
|
||||
func TestAdventurePermalink(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
f := AdvFact{
|
||||
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
|
||||
req.SetPathValue("guid", "death:abc:1000")
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleAdventureStory(rw, req)
|
||||
if rw.Code != 200 {
|
||||
t.Fatalf("permalink status = %d body=%s", rw.Code, rw.Body.String())
|
||||
}
|
||||
body := rw.Body.String()
|
||||
if !bytes.Contains([]byte(body), []byte("We lost Brannigan in the Underforge.")) {
|
||||
t.Errorf("permalink missing headline; body=%s", body)
|
||||
}
|
||||
if !bytes.Contains([]byte(body), []byte("In memoriam")) {
|
||||
t.Errorf("permalink missing event label; body=%s", body)
|
||||
}
|
||||
|
||||
// Unknown guid 404s.
|
||||
req2 := httptest.NewRequest("GET", "/adventure/nope:1", nil)
|
||||
req2.SetPathValue("guid", "nope:1")
|
||||
rw2 := httptest.NewRecorder()
|
||||
s.handleAdventureStory(rw2, req2)
|
||||
if rw2.Code != 404 {
|
||||
t.Errorf("unknown guid status = %d, want 404", rw2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDurUntilNextHour targets the next UTC occurrence of the digest hour and
|
||||
// rolls to tomorrow when it's already that hour (so a restart can't double-fire).
|
||||
func TestDurUntilNextHour(t *testing.T) {
|
||||
// 14:30 UTC, targeting 17:00 → 2h30m today.
|
||||
now := time.Date(2026, 7, 11, 14, 30, 0, 0, time.UTC)
|
||||
if got := durUntilNextHour(now, 17); got != 2*time.Hour+30*time.Minute {
|
||||
t.Errorf("before hour: got %v", got)
|
||||
}
|
||||
// 17:00 exactly → tomorrow's 17:00 (24h), not zero.
|
||||
now = time.Date(2026, 7, 11, 17, 0, 0, 0, time.UTC)
|
||||
if got := durUntilNextHour(now, 17); got != 24*time.Hour {
|
||||
t.Errorf("at hour: got %v", got)
|
||||
}
|
||||
// 20:00, targeting 17:00 → tomorrow, 21h.
|
||||
now = time.Date(2026, 7, 11, 20, 0, 0, 0, time.UTC)
|
||||
if got := durUntilNextHour(now, 17); got != 21*time.Hour {
|
||||
t.Errorf("after hour: got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureDigest covers the batched path: bulletins (no live post) are
|
||||
// collected into one roundup, priority beats are excluded (they already posted),
|
||||
// digested bulletins don't recur, and an empty window stays silent.
|
||||
func TestAdventureDigest(t *testing.T) {
|
||||
const token = "t"
|
||||
s, posted := newAdvServer(t, token)
|
||||
now := time.Now()
|
||||
|
||||
// Two bulletins + one priority (which posts live and must be excluded).
|
||||
postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
|
||||
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
|
||||
postFact(t, s, token, AdvFact{GUID: "rival:b:2", EventType: "rival_result", Tier: "bulletin",
|
||||
Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()})
|
||||
postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
|
||||
if len(*posted) != 1 {
|
||||
t.Fatalf("setup: priority posts = %d, want 1", len(*posted))
|
||||
}
|
||||
|
||||
s.postDailyDigest(now.UTC())
|
||||
if len(*posted) != 2 {
|
||||
t.Fatalf("digest not posted: total posts = %d, want 2", len(*posted))
|
||||
}
|
||||
dg := (*posted)[1]
|
||||
if !strings.Contains(dg.Headline, "2 dispatches") {
|
||||
t.Errorf("digest headline = %q", dg.Headline)
|
||||
}
|
||||
if !strings.HasPrefix(dg.GUID, "adv-digest:") {
|
||||
t.Errorf("digest guid = %q", dg.GUID)
|
||||
}
|
||||
|
||||
// Re-running finds nothing new (bulletins marked digested).
|
||||
s.postDailyDigest(now.UTC())
|
||||
if len(*posted) != 2 {
|
||||
t.Errorf("digest re-posted: total = %d, want 2", len(*posted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
|
||||
// returns a themed SVG, ingested cards carry its local path, and the permalink
|
||||
// page is noindex with an og:image.
|
||||
// TestRenderZoneTaxonomy: a realm-first (zone_first) reads as first-ever; a
|
||||
// repeat (zone_clear) reads as a personal clear, not a mis-labeled "first".
|
||||
func TestRenderZoneTaxonomy(t *testing.T) {
|
||||
first := AdvFact{EventType: "zone_first", Tier: "priority", Subject: "Brannigan",
|
||||
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
|
||||
hl, _, ok := renderAdventure(first)
|
||||
if !ok || !strings.Contains(hl, "very first time") {
|
||||
t.Errorf("zone_first headline = %q (ok=%v)", hl, ok)
|
||||
}
|
||||
|
||||
repeat := AdvFact{EventType: "zone_clear", Tier: "bulletin", Subject: "Brannigan",
|
||||
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
|
||||
hl2, _, ok := renderAdventure(repeat)
|
||||
if !ok || !strings.Contains(hl2, "Brannigan clears") || strings.Contains(hl2, "first") {
|
||||
t.Errorf("zone_clear headline = %q (ok=%v)", hl2, ok)
|
||||
}
|
||||
|
||||
// The permalink label distinguishes the two, so a repeat's page isn't stamped
|
||||
// "First clear".
|
||||
if lbl, _ := advEventMeta("zone_clear"); lbl == "First clear" {
|
||||
t.Errorf("zone_clear meta label = %q, want distinct from first clear", lbl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdventureArtAndMeta(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
|
||||
// Emblem endpoint returns SVG with the event's emoji.
|
||||
areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
|
||||
areq.SetPathValue("type", "death.svg")
|
||||
arw := httptest.NewRecorder()
|
||||
s.handleAdventureArt(arw, areq)
|
||||
if arw.Code != 200 {
|
||||
t.Fatalf("art status = %d", arw.Code)
|
||||
}
|
||||
if ct := arw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/svg+xml") {
|
||||
t.Errorf("art content-type = %q", ct)
|
||||
}
|
||||
if b := arw.Body.String(); !strings.Contains(b, "🪦") || !strings.Contains(b, "<svg") {
|
||||
t.Errorf("art body missing emblem: %s", b)
|
||||
}
|
||||
|
||||
// Ingest sets the card image to the local emblem path.
|
||||
f := AdvFact{GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 4, OccurredAt: 1000}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
got, _ := storage.GetStoryByGUID("death:abc:1000")
|
||||
if got == nil || got.ImageURL != "/adventure/art/death.svg" {
|
||||
t.Errorf("story image = %q", got.ImageURL)
|
||||
}
|
||||
|
||||
// Permalink page is noindex with an og:image.
|
||||
preq := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
|
||||
preq.SetPathValue("guid", "death:abc:1000")
|
||||
prw := httptest.NewRecorder()
|
||||
s.handleAdventureStory(prw, preq)
|
||||
body := prw.Body.String()
|
||||
if !strings.Contains(body, `name="robots" content="noindex"`) {
|
||||
t.Error("permalink not noindex")
|
||||
}
|
||||
if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death.svg"`) {
|
||||
t.Errorf("permalink missing og:image; body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureIngestBearer rejects missing/wrong tokens.
|
||||
func TestAdventureIngestBearer(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "right")
|
||||
f := AdvFact{GUID: "arrival:x:1", EventType: "arrival", Tier: "bulletin", OccurredAt: 1}
|
||||
if rw := postFact(t, s, "", f); rw.Code != 401 {
|
||||
t.Errorf("no token: status = %d, want 401", rw.Code)
|
||||
}
|
||||
if rw := postFact(t, s, "wrong", f); rw.Code != 401 {
|
||||
t.Errorf("wrong token: status = %d, want 401", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureFactGuard rejects a subject not present in the actors allow-list,
|
||||
// so a name that slipped the source can't reach a public page.
|
||||
func TestAdventureFactGuard(t *testing.T) {
|
||||
const token = "t"
|
||||
s, posted := newAdvServer(t, token)
|
||||
f := AdvFact{
|
||||
GUID: "death:evil:1", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Kif", // Kif not in actors
|
||||
Zone: "the Underforge", Level: 3, OccurredAt: 1,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 400 {
|
||||
t.Errorf("fact-guard: status = %d, want 400", rw.Code)
|
||||
}
|
||||
if storage.IsGUIDSeen("death:evil:1") {
|
||||
t.Error("guarded fact was stored")
|
||||
}
|
||||
if len(*posted) != 0 {
|
||||
t.Error("guarded fact was posted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureDisabled 404s when the seam is off.
|
||||
func TestAdventureDisabled(t *testing.T) {
|
||||
storage.Close()
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "off.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := AdvFact{GUID: "x", EventType: "arrival", OccurredAt: 1}
|
||||
if rw := postFact(t, s, "anything", f); rw.Code != 404 {
|
||||
t.Errorf("disabled: status = %d, want 404", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderMischief: gogobee's four mischief event types must all render. An
|
||||
// unknown event_type is a 400 at ingest, which gogobee retries and then parks
|
||||
// forever — so "Pete deploys first" only helps if Pete actually knows the types.
|
||||
//
|
||||
// It also pins the anonymity contract, which is the feature's whole social
|
||||
// engine: an unsigned contract must not name the buyer, and a survival must.
|
||||
func TestRenderMischief(t *testing.T) {
|
||||
anon := AdvFact{EventType: "mischief_contract", Tier: "priority",
|
||||
Subject: "Josie", Boss: "Elite", Stakes: "€350", Level: 14}
|
||||
hl, lede, ok := renderAdventure(anon)
|
||||
if !ok {
|
||||
t.Fatal("mischief_contract did not render — ingest would 400")
|
||||
}
|
||||
if strings.Contains(hl+lede, "Brannigan") {
|
||||
t.Error("anonymous contract leaked a buyer name")
|
||||
}
|
||||
if !strings.Contains(hl, "€350") {
|
||||
t.Errorf("contract headline lost the stakes: %q", hl)
|
||||
}
|
||||
|
||||
signed := anon
|
||||
signed.Opponent = "Brannigan"
|
||||
hl, _, ok = renderAdventure(signed)
|
||||
if !ok || !strings.Contains(hl, "Brannigan") {
|
||||
t.Errorf("signed contract should name the buyer: %q (ok=%v)", hl, ok)
|
||||
}
|
||||
|
||||
// The unseal: a survival names the buyer whether or not they signed.
|
||||
survived := AdvFact{EventType: "mischief_survived", Tier: "priority",
|
||||
Subject: "Josie", Opponent: "Brannigan", Boss: "Bone Colossus", Stakes: "€228"}
|
||||
hl, _, ok = renderAdventure(survived)
|
||||
if !ok || !strings.Contains(hl, "Brannigan") {
|
||||
t.Errorf("survival must unseal the buyer: %q (ok=%v)", hl, ok)
|
||||
}
|
||||
|
||||
// A downed target with an anonymous buyer stays anonymous — being maimed
|
||||
// doesn't buy you the name.
|
||||
downed := AdvFact{EventType: "mischief_downed", Tier: "priority",
|
||||
Subject: "Josie", Boss: "Bone Colossus", Level: 14}
|
||||
hl, lede, ok = renderAdventure(downed)
|
||||
if !ok {
|
||||
t.Fatal("mischief_downed did not render")
|
||||
}
|
||||
if strings.Contains(hl+lede, "Brannigan") {
|
||||
t.Error("anonymous buyer named on a downed contract")
|
||||
}
|
||||
|
||||
if _, _, ok := renderAdventure(AdvFact{EventType: "mischief_fizzled", Subject: "Josie", Stakes: "€315"}); !ok {
|
||||
t.Error("mischief_fizzled did not render")
|
||||
}
|
||||
|
||||
for _, et := range []string{"mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled"} {
|
||||
if lbl, _ := advEventMeta(et); lbl == "Dispatch" {
|
||||
t.Errorf("%s has no permalink label", et)
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
internal/web/assets/fonts/Fredoka-SemiBold.ttf
Normal file
BIN
internal/web/assets/fonts/Fredoka-SemiBold.ttf
Normal file
Binary file not shown.
8
internal/web/assets/fonts/LICENSE.txt
Normal file
8
internal/web/assets/fonts/LICENSE.txt
Normal 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.
|
||||
342
internal/web/auth.go
Normal file
342
internal/web/auth.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"pete/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "pete_session"
|
||||
oauthCookie = "pete_oauth"
|
||||
sessionTTL = 30 * 24 * time.Hour // stay signed in for a month
|
||||
oauthTTL = 10 * time.Minute // login round-trip window
|
||||
)
|
||||
|
||||
// Authenticator holds the OIDC plumbing for optional sign-in via Authentik.
|
||||
type Authenticator struct {
|
||||
oauth *oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
secret []byte
|
||||
domain string // cookie Domain; empty means host-only
|
||||
}
|
||||
|
||||
// SessionUser is the identity carried in the signed session cookie.
|
||||
type SessionUser struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
// Username is the Authentik preferred_username, which MAS imported as the
|
||||
// Matrix localpart — so it is also who this person is in the game economy.
|
||||
// Sessions signed before games existed don't carry it; MatrixUser returns
|
||||
// "" for those and the caller sends them back through sign-in.
|
||||
Username string `json:"username,omitempty"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
// MatrixUser maps the session to a Matrix ID on the given server name, e.g.
|
||||
// "reala" on "parodia.dev" -> "@reala:parodia.dev". Empty if either half is
|
||||
// missing, which callers must treat as "not identified in the economy".
|
||||
func (u *SessionUser) MatrixUser(serverName string) string {
|
||||
name := strings.ToLower(strings.TrimSpace(u.Username))
|
||||
if name == "" || serverName == "" {
|
||||
return ""
|
||||
}
|
||||
return "@" + name + ":" + serverName
|
||||
}
|
||||
|
||||
// Display is the friendly name shown in the header (name, else email, else sub).
|
||||
func (u *SessionUser) Display() string {
|
||||
if u.Name != "" {
|
||||
return u.Name
|
||||
}
|
||||
if u.Email != "" {
|
||||
return u.Email
|
||||
}
|
||||
return u.Sub
|
||||
}
|
||||
|
||||
// Initial is the single uppercase glyph for the avatar bubble.
|
||||
func (u *SessionUser) Initial() string {
|
||||
d := strings.TrimSpace(u.Display())
|
||||
if d == "" {
|
||||
return "?"
|
||||
}
|
||||
return strings.ToUpper(d[:1])
|
||||
}
|
||||
|
||||
// oauthState is the short-lived CSRF/nonce envelope for the login round-trip.
|
||||
type oauthState struct {
|
||||
State string `json:"s"`
|
||||
Nonce string `json:"n"`
|
||||
Next string `json:"r"`
|
||||
Exp int64 `json:"e"`
|
||||
}
|
||||
|
||||
// newAuthenticator performs OIDC discovery against the configured issuer. It is
|
||||
// non-fatal at the call site: if discovery fails (provider down at boot), the
|
||||
// site still serves anonymously.
|
||||
func newAuthenticator(ctx context.Context, cfg config.AuthConfig) (*Authenticator, error) {
|
||||
provider, err := oidc.NewProvider(ctx, strings.TrimRight(cfg.Issuer, "/")+"/")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc discovery: %w", err)
|
||||
}
|
||||
return &Authenticator{
|
||||
oauth: &oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
},
|
||||
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
||||
secret: []byte(cfg.SessionSecret),
|
||||
domain: strings.TrimSpace(cfg.CookieDomain),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- signed-cookie helpers ------------------------------------------------
|
||||
|
||||
// sign returns base64url(payload).base64url(hmac) — a compact stateless token.
|
||||
func (a *Authenticator) sign(payload []byte) string {
|
||||
mac := hmac.New(sha256.New, a.secret)
|
||||
mac.Write(payload)
|
||||
b64 := base64.RawURLEncoding
|
||||
return b64.EncodeToString(payload) + "." + b64.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// verify checks the HMAC and returns the raw payload bytes.
|
||||
func (a *Authenticator) verify(token string) ([]byte, bool) {
|
||||
parts := strings.SplitN(token, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, false
|
||||
}
|
||||
b64 := base64.RawURLEncoding
|
||||
payload, err := b64.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
want, err := b64.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
mac := hmac.New(sha256.New, a.secret)
|
||||
mac.Write(payload)
|
||||
if subtle.ConstantTimeCompare(want, mac.Sum(nil)) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func randToken() string {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// userFromRequest returns the signed-in user, or nil for anonymous visitors.
|
||||
func (a *Authenticator) userFromRequest(r *http.Request) *SessionUser {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
payload, ok := a.verify(c.Value)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var u SessionUser
|
||||
if json.Unmarshal(payload, &u) != nil {
|
||||
return nil
|
||||
}
|
||||
if u.Sub == "" || time.Now().Unix() > u.Exp {
|
||||
return nil
|
||||
}
|
||||
return &u
|
||||
}
|
||||
|
||||
// cookieDomain is the Domain attribute for a given cookie. Only the session
|
||||
// cookie is widened: the OAuth round-trip cookie stays host-only, because it
|
||||
// pairs with a redirect back to the host that started the login and has no
|
||||
// business being readable from anywhere else.
|
||||
func (a *Authenticator) cookieDomain(name string) string {
|
||||
if name == sessionCookie {
|
||||
return a.domain
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
Domain: a.cookieDomain(name),
|
||||
Expires: time.Now().Add(ttl),
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Authenticator) clearCookie(w http.ResponseWriter, name string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name, Value: "", Path: "/", MaxAge: -1,
|
||||
Domain: a.cookieDomain(name),
|
||||
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// oauthFor returns the OAuth config to use for this request. The configured
|
||||
// redirect_url names one host (news), but a login that starts on games has to
|
||||
// come back to games — otherwise the browser is dumped on the news site with
|
||||
// its "next" path pointing at a page that lives elsewhere. So when the request
|
||||
// arrives on a host inside the shared cookie domain, keep the redirect on that
|
||||
// host, reusing the configured URL's scheme and path. Every host used this way
|
||||
// must be registered as a redirect URI in Authentik.
|
||||
func (a *Authenticator) oauthFor(r *http.Request) *oauth2.Config {
|
||||
if a.domain == "" || !hostInDomain(r.Host, a.domain) {
|
||||
return a.oauth
|
||||
}
|
||||
u, err := url.Parse(a.oauth.RedirectURL)
|
||||
if err != nil || u.Host == r.Host {
|
||||
return a.oauth
|
||||
}
|
||||
cfg := *a.oauth
|
||||
cfg.RedirectURL = u.Scheme + "://" + r.Host + u.Path
|
||||
return &cfg
|
||||
}
|
||||
|
||||
// hostInDomain reports whether host sits inside a cookie domain like
|
||||
// ".parodia.dev" (which also covers the bare "parodia.dev").
|
||||
func hostInDomain(host, domain string) bool {
|
||||
if i := strings.IndexByte(host, ':'); i >= 0 {
|
||||
host = host[:i] // strip port
|
||||
}
|
||||
host = strings.ToLower(host)
|
||||
bare := strings.TrimPrefix(strings.ToLower(domain), ".")
|
||||
return host == bare || strings.HasSuffix(host, "."+bare)
|
||||
}
|
||||
|
||||
// ---- handlers -------------------------------------------------------------
|
||||
|
||||
// handleLogin starts the OIDC authorization-code flow.
|
||||
func (a *Authenticator) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
st := oauthState{
|
||||
State: randToken(),
|
||||
Nonce: randToken(),
|
||||
Next: safeNext(r.URL.Query().Get("next")),
|
||||
Exp: time.Now().Add(oauthTTL).Unix(),
|
||||
}
|
||||
payload, _ := json.Marshal(st)
|
||||
a.setCookie(w, oauthCookie, a.sign(payload), oauthTTL)
|
||||
http.Redirect(w, r, a.oauthFor(r).AuthCodeURL(st.State, oidc.Nonce(st.Nonce)), http.StatusFound)
|
||||
}
|
||||
|
||||
// handleCallback completes the flow: validates state, exchanges the code,
|
||||
// verifies the ID token, and issues a session cookie.
|
||||
func (a *Authenticator) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(oauthCookie)
|
||||
if err != nil {
|
||||
http.Error(w, "login expired, please try again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a.clearCookie(w, oauthCookie)
|
||||
payload, ok := a.verify(c.Value)
|
||||
if !ok {
|
||||
http.Error(w, "invalid login state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var st oauthState
|
||||
if json.Unmarshal(payload, &st) != nil || time.Now().Unix() > st.Exp {
|
||||
http.Error(w, "login expired, please try again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(st.State), []byte(r.URL.Query().Get("state"))) != 1 {
|
||||
http.Error(w, "state mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tok, err := a.oauthFor(r).Exchange(ctx, r.URL.Query().Get("code"))
|
||||
if err != nil {
|
||||
slog.Error("auth: code exchange failed", "err", err)
|
||||
http.Error(w, "sign-in failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
rawID, ok := tok.Extra("id_token").(string)
|
||||
if !ok {
|
||||
http.Error(w, "no id_token in response", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
idToken, err := a.verifier.Verify(ctx, rawID)
|
||||
if err != nil {
|
||||
slog.Error("auth: id_token verify failed", "err", err)
|
||||
http.Error(w, "sign-in failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if idToken.Nonce != st.Nonce {
|
||||
http.Error(w, "nonce mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
http.Error(w, "sign-in failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
name := claims.Name
|
||||
if name == "" {
|
||||
name = claims.PreferredUsername
|
||||
}
|
||||
u := SessionUser{
|
||||
Sub: claims.Sub,
|
||||
Name: name,
|
||||
Email: claims.Email,
|
||||
Username: claims.PreferredUsername,
|
||||
Exp: time.Now().Add(sessionTTL).Unix(),
|
||||
}
|
||||
sess, _ := json.Marshal(u)
|
||||
a.setCookie(w, sessionCookie, a.sign(sess), sessionTTL)
|
||||
slog.Info("auth: user signed in", "sub", claims.Sub, "name", name)
|
||||
http.Redirect(w, r, st.Next, http.StatusFound)
|
||||
}
|
||||
|
||||
// handleLogout clears the local session. It does not end the upstream Authentik
|
||||
// SSO session (that stays the user's choice).
|
||||
func (a *Authenticator) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
a.clearCookie(w, sessionCookie)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
// safeNext keeps post-login redirects on-site (no open redirects). It rejects
|
||||
// "//host" and "/\host" — browsers normalize the backslash to a slash, so the
|
||||
// latter would otherwise resolve to a protocol-relative off-site URL.
|
||||
func safeNext(next string) string {
|
||||
if next == "" || !strings.HasPrefix(next, "/") ||
|
||||
strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
|
||||
return "/"
|
||||
}
|
||||
return next
|
||||
}
|
||||
153
internal/web/auth_test.go
Normal file
153
internal/web/auth_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestSignVerifyRoundTrip(t *testing.T) {
|
||||
a := &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
payload := []byte(`{"sub":"abc","exp":123}`)
|
||||
tok := a.sign(payload)
|
||||
|
||||
got, ok := a.verify(tok)
|
||||
if !ok {
|
||||
t.Fatal("verify rejected a freshly signed token")
|
||||
}
|
||||
if string(got) != string(payload) {
|
||||
t.Fatalf("payload mismatch: got %q want %q", got, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsTamperAndForeignKey(t *testing.T) {
|
||||
a := &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
tok := a.sign([]byte(`{"sub":"abc"}`))
|
||||
|
||||
// Flip a byte in the payload segment.
|
||||
bad := []byte(tok)
|
||||
bad[0] ^= 0x01
|
||||
if _, ok := a.verify(string(bad)); ok {
|
||||
t.Error("verify accepted a tampered token")
|
||||
}
|
||||
|
||||
// A different key must not validate the same token.
|
||||
other := &Authenticator{secret: []byte("a-totally-different-key-16")}
|
||||
if _, ok := other.verify(tok); ok {
|
||||
t.Error("verify accepted a token signed with a different key")
|
||||
}
|
||||
|
||||
if _, ok := a.verify("not-a-valid-token"); ok {
|
||||
t.Error("verify accepted a malformed token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeNext(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "/",
|
||||
"/tech": "/tech",
|
||||
"/tech?page=2": "/tech?page=2",
|
||||
"//evil.com": "/", // protocol-relative open redirect
|
||||
"https://evil.com": "/", // absolute URL
|
||||
"javascript:alert": "/", // not a path
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := safeNext(in); got != want {
|
||||
t.Errorf("safeNext(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatrixUser(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
u SessionUser
|
||||
want string
|
||||
}{
|
||||
{"lowercased", SessionUser{Username: "Reala"}, "@reala:parodia.dev"},
|
||||
{"trimmed", SessionUser{Username: " reala "}, "@reala:parodia.dev"},
|
||||
{"old session with no username", SessionUser{Sub: "abc", Name: "Reala"}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.u.MatrixUser("parodia.dev"); got != tc.want {
|
||||
t.Fatalf("MatrixUser = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := (&SessionUser{Username: "reala"}).MatrixUser(""); got != "" {
|
||||
t.Fatalf("no server name should yield no identity, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostInDomain(t *testing.T) {
|
||||
cases := []struct {
|
||||
host, domain string
|
||||
want bool
|
||||
}{
|
||||
{"games.parodia.dev", ".parodia.dev", true},
|
||||
{"news.parodia.dev:8080", ".parodia.dev", true},
|
||||
{"parodia.dev", ".parodia.dev", true},
|
||||
{"GAMES.PARODIA.DEV", ".parodia.dev", true},
|
||||
{"evil.com", ".parodia.dev", false},
|
||||
// The suffix check must not match a domain that merely ends in the
|
||||
// same letters: notparodia.dev is a different site entirely.
|
||||
{"notparodia.dev", ".parodia.dev", false},
|
||||
{"games.parodia.dev.evil.com", ".parodia.dev", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := hostInDomain(tc.host, tc.domain); got != tc.want {
|
||||
t.Errorf("hostInDomain(%q, %q) = %v, want %v", tc.host, tc.domain, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthForKeepsLoginOnTheHostItStartedOn(t *testing.T) {
|
||||
base := &oauth2.Config{RedirectURL: "https://news.parodia.dev/auth/callback"}
|
||||
a := &Authenticator{oauth: base, domain: ".parodia.dev"}
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/login", nil)
|
||||
req.Host = "games.parodia.dev"
|
||||
if got := a.oauthFor(req).RedirectURL; got != "https://games.parodia.dev/auth/callback" {
|
||||
t.Fatalf("games login should come back to games, got %q", got)
|
||||
}
|
||||
|
||||
req.Host = "news.parodia.dev"
|
||||
if got := a.oauthFor(req).RedirectURL; got != base.RedirectURL {
|
||||
t.Fatalf("news login should use the configured URL, got %q", got)
|
||||
}
|
||||
|
||||
// A Host we don't own must never be echoed back into a redirect URI.
|
||||
req.Host = "evil.com"
|
||||
if got := a.oauthFor(req).RedirectURL; got != base.RedirectURL {
|
||||
t.Fatalf("foreign host must fall back to the configured URL, got %q", got)
|
||||
}
|
||||
|
||||
// With no cookie domain configured there is nothing to share, so the
|
||||
// configured redirect stands whatever the Host header says.
|
||||
off := &Authenticator{oauth: base}
|
||||
req.Host = "games.parodia.dev"
|
||||
if got := off.oauthFor(req).RedirectURL; got != base.RedirectURL {
|
||||
t.Fatalf("host-only mode must not rewrite the redirect, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCookieIsSharedButOAuthCookieIsNot(t *testing.T) {
|
||||
a := &Authenticator{secret: []byte("test-secret-key-at-least-16"), domain: ".parodia.dev"}
|
||||
rec := httptest.NewRecorder()
|
||||
a.setCookie(rec, sessionCookie, "v", time.Minute)
|
||||
a.setCookie(rec, oauthCookie, "v", time.Minute)
|
||||
|
||||
got := map[string]string{}
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
got[c.Name] = c.Domain
|
||||
}
|
||||
if got[sessionCookie] != "parodia.dev" {
|
||||
t.Fatalf("session cookie domain = %q, want it shared across parodia.dev", got[sessionCookie])
|
||||
}
|
||||
if got[oauthCookie] != "" {
|
||||
t.Fatalf("oauth cookie must stay host-only, got domain %q", got[oauthCookie])
|
||||
}
|
||||
}
|
||||
128
internal/web/devcasino_test.go
Normal file
128
internal/web/devcasino_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/trivia"
|
||||
"pete/internal/opentdb"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// TestDevCasino is not a test. It is the casino, running, on a port, with one
|
||||
// signed-in player who has chips — so the table can be driven in a real browser,
|
||||
// which is the only honest way to review an animation.
|
||||
//
|
||||
// Skipped unless you ask for it:
|
||||
//
|
||||
// PETE_DEV_CASINO=:7788 go test ./internal/web -run TestDevCasino -timeout 0
|
||||
//
|
||||
// It prints the session cookie to plant. The routes are wired here rather than
|
||||
// taken from New(), because New() decides whether the casino exists at the
|
||||
// moment it builds the mux, and the test rig only signs the player in afterwards.
|
||||
func TestDevCasino(t *testing.T) {
|
||||
addr := os.Getenv("PETE_DEV_CASINO")
|
||||
if addr == "" {
|
||||
t.Skip("set PETE_DEV_CASINO=:port to run the casino for a browser")
|
||||
}
|
||||
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
fundUser(t, bobPlayer, 5000)
|
||||
seedTriviaBank(t)
|
||||
|
||||
// The full table runtime, so the turn clock and the reaper are live under the
|
||||
// browser exactly as in production.
|
||||
s.StartTableClock(context.Background())
|
||||
|
||||
cookie := devCookie(s, "reala", "Reala")
|
||||
// A second player, so a shared table can be reviewed — hold'em is multiplayer
|
||||
// now, and one browser cannot see two people at the felt. Plant this cookie in
|
||||
// a second browser profile (or a private window) to sit down as Bob.
|
||||
bobCookie := devCookie(s, "bob", "Bob")
|
||||
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
||||
s.casinoRoutes(mux)
|
||||
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Written to a file, not printed: `go test` buffers stdout, and the browser
|
||||
// driver needs the cookie while the server is still running. The second cookie
|
||||
// rides alongside it, newline-separated, for the second browser.
|
||||
if out := os.Getenv("PETE_DEV_COOKIE_FILE"); out != "" {
|
||||
if err := os.WriteFile(out, []byte(cookie+"\n"+bobCookie), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nCASINO http://localhost%s/games\nCOOKIE %s=%s\nBOB %s=%s\n\n",
|
||||
addr, sessionCookie, cookie, sessionCookie, bobCookie)
|
||||
|
||||
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
t.Cleanup(func() { _ = srv.Close() })
|
||||
_ = srv.Serve(ln)
|
||||
}
|
||||
|
||||
// devCookie mints a signed session for a player the rig has funded, so the felt
|
||||
// can be driven as them.
|
||||
func devCookie(s *Server, username, name string) string {
|
||||
payload, _ := json.Marshal(SessionUser{
|
||||
Sub: "sub-" + username, Username: username, Name: name,
|
||||
Exp: time.Now().Add(24 * time.Hour).Unix(),
|
||||
})
|
||||
return s.auth.sign(payload)
|
||||
}
|
||||
|
||||
// seedTriviaBank puts enough questions in the bank to deal a ladder of each
|
||||
// difficulty.
|
||||
//
|
||||
// The rig does not run StartTriviaBank — a dev casino that spends its first two
|
||||
// minutes dripping four hundred questions per difficulty out of a free API is a
|
||||
// dev casino you cannot use. But a fresh database has an empty bank, and an
|
||||
// empty bank means every start 503s, so the rig would be unable to show you the
|
||||
// one game it exists to show you.
|
||||
//
|
||||
// One real batch per difficulty, through the real client: fifty questions is
|
||||
// four ladders' worth, and it means what the browser renders came out of OpenTDB
|
||||
// and through the same decode-and-store path production uses, entities and all.
|
||||
func seedTriviaBank(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
client := opentdb.New()
|
||||
|
||||
for i, tier := range trivia.Tiers {
|
||||
have, err := storage.CountTrivia(tier.Difficulty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if have >= trivia.Rungs {
|
||||
continue
|
||||
}
|
||||
if i > 0 {
|
||||
time.Sleep(opentdb.Politeness) // the API asks; asking faster earns nothing
|
||||
}
|
||||
qs, err := client.Fetch(ctx, tier.Difficulty, opentdb.Batch)
|
||||
if err != nil {
|
||||
t.Fatalf("seeding the trivia bank (%s): %v", tier.Difficulty, err)
|
||||
}
|
||||
added, err := storage.AddTriviaQuestions(tier.Difficulty, qs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf("BANK %-6s %d questions\n", tier.Difficulty, added)
|
||||
}
|
||||
}
|
||||
292
internal/web/feed.go
Normal file
292
internal/web/feed.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"html"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// feedItemLimit caps how many stories each outbound feed carries.
|
||||
const feedItemLimit = 50
|
||||
|
||||
// feedCacheControl is a short cache window; feeds refresh a few times an hour.
|
||||
const feedCacheControl = "public, max-age=300"
|
||||
|
||||
const (
|
||||
rssContentNS = "http://purl.org/rss/1.0/modules/content/"
|
||||
rssAtomNS = "http://www.w3.org/2005/Atom"
|
||||
jsonFeedVer = "https://jsonfeed.org/version/1.1"
|
||||
)
|
||||
|
||||
// --- RSS 2.0 ---
|
||||
|
||||
type rssFeedXML struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
ContentNS string `xml:"xmlns:content,attr"`
|
||||
AtomNS string `xml:"xmlns:atom,attr"`
|
||||
Channel rssChannel `xml:"channel"`
|
||||
}
|
||||
|
||||
type rssChannel struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
Language string `xml:"language,omitempty"`
|
||||
LastBuildDate string `xml:"lastBuildDate,omitempty"`
|
||||
Generator string `xml:"generator,omitempty"`
|
||||
AtomLink rssAtomLink `xml:"atom:link"`
|
||||
Items []rssItem `xml:"item"`
|
||||
}
|
||||
|
||||
type rssAtomLink struct {
|
||||
Href string `xml:"href,attr"`
|
||||
Rel string `xml:"rel,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
}
|
||||
|
||||
type rssItem struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
GUID rssGUID `xml:"guid"`
|
||||
PubDate string `xml:"pubDate,omitempty"`
|
||||
Category string `xml:"category,omitempty"`
|
||||
Description string `xml:"description"`
|
||||
Content *rssContent `xml:"content:encoded,omitempty"`
|
||||
}
|
||||
|
||||
type rssGUID struct {
|
||||
IsPermaLink string `xml:"isPermaLink,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type rssContent struct {
|
||||
Value string `xml:",cdata"`
|
||||
}
|
||||
|
||||
// --- JSON Feed 1.1 ---
|
||||
|
||||
type jsonFeed struct {
|
||||
Version string `json:"version"`
|
||||
Title string `json:"title"`
|
||||
HomePageURL string `json:"home_page_url"`
|
||||
FeedURL string `json:"feed_url"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Items []jsonFeedItem `json:"items"`
|
||||
}
|
||||
|
||||
type jsonFeedItem struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
ContentText string `json:"content_text,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
DatePublished string `json:"date_published,omitempty"`
|
||||
Authors []jsonFeedAuthor `json:"authors,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
type jsonFeedAuthor struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// handleFeedXML serves an RSS 2.0 feed for the given channel ("" = all channels).
|
||||
func (s *Server) handleFeedXML(w http.ResponseWriter, r *http.Request, channel string) {
|
||||
stories, err := storage.ListForFeed(channel, feedItemLimit)
|
||||
if err != nil {
|
||||
slog.Error("web: feed query failed", "channel", channel, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
base := s.publicBase(r)
|
||||
title, homeURL, desc := s.feedMeta(channel, base)
|
||||
selfURL := base + feedPath(channel, "xml")
|
||||
|
||||
feed := rssFeedXML{
|
||||
Version: "2.0",
|
||||
ContentNS: rssContentNS,
|
||||
AtomNS: rssAtomNS,
|
||||
Channel: rssChannel{
|
||||
Title: title,
|
||||
Link: homeURL,
|
||||
Description: desc,
|
||||
Language: "en",
|
||||
Generator: "Pete",
|
||||
AtomLink: rssAtomLink{Href: selfURL, Rel: "self", Type: "application/rss+xml"},
|
||||
Items: make([]rssItem, 0, len(stories)),
|
||||
},
|
||||
}
|
||||
if len(stories) > 0 {
|
||||
feed.Channel.LastBuildDate = feedTime(stories[0]).UTC().Format(time.RFC1123Z)
|
||||
}
|
||||
for _, st := range stories {
|
||||
link := storyLink(st)
|
||||
item := rssItem{
|
||||
Title: st.Headline,
|
||||
Link: link,
|
||||
GUID: rssGUID{IsPermaLink: "false", Value: st.GUID},
|
||||
PubDate: feedTime(st).UTC().Format(time.RFC1123Z),
|
||||
Category: st.Channel,
|
||||
Description: st.Lede,
|
||||
}
|
||||
if html := contentToHTML(st.Content); html != "" {
|
||||
item.Content = &rssContent{Value: html}
|
||||
}
|
||||
feed.Channel.Items = append(feed.Channel.Items, item)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", feedCacheControl)
|
||||
if _, err := w.Write([]byte(xml.Header)); err != nil {
|
||||
return
|
||||
}
|
||||
enc := xml.NewEncoder(w)
|
||||
enc.Indent("", " ")
|
||||
if err := enc.Encode(feed); err != nil {
|
||||
slog.Error("web: rss encode failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleFeedJSON serves a JSON Feed 1.1 for the given channel ("" = all).
|
||||
func (s *Server) handleFeedJSON(w http.ResponseWriter, r *http.Request, channel string) {
|
||||
stories, err := storage.ListForFeed(channel, feedItemLimit)
|
||||
if err != nil {
|
||||
slog.Error("web: feed query failed", "channel", channel, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
base := s.publicBase(r)
|
||||
title, homeURL, desc := s.feedMeta(channel, base)
|
||||
|
||||
feed := jsonFeed{
|
||||
Version: jsonFeedVer,
|
||||
Title: title,
|
||||
HomePageURL: homeURL,
|
||||
FeedURL: base + feedPath(channel, "json"),
|
||||
Description: desc,
|
||||
Items: make([]jsonFeedItem, 0, len(stories)),
|
||||
}
|
||||
for _, st := range stories {
|
||||
item := jsonFeedItem{
|
||||
ID: st.GUID,
|
||||
URL: storyLink(st),
|
||||
Title: st.Headline,
|
||||
ContentText: strings.TrimSpace(st.Content),
|
||||
Summary: st.Lede,
|
||||
Image: st.ImageURL,
|
||||
DatePublished: feedTime(st).UTC().Format(time.RFC3339),
|
||||
Tags: []string{st.Channel},
|
||||
}
|
||||
if item.ContentText == "" {
|
||||
item.ContentText = st.Lede
|
||||
}
|
||||
if st.Source != "" {
|
||||
item.Authors = []jsonFeedAuthor{{Name: st.Source}}
|
||||
}
|
||||
feed.Items = append(feed.Items, item)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/feed+json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", feedCacheControl)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(feed); err != nil {
|
||||
slog.Error("web: json feed encode failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// feedMeta returns the title, home-page URL, and description for a feed. An
|
||||
// empty channel is the site-wide feed; a slug scopes to that channel.
|
||||
func (s *Server) feedMeta(channel, base string) (title, homeURL, desc string) {
|
||||
site := s.cfg.SiteTitle
|
||||
if site == "" {
|
||||
site = "Pete"
|
||||
}
|
||||
if channel == "" {
|
||||
return site, base + "/", "The latest across every channel."
|
||||
}
|
||||
ch := channelBySlug(channel)
|
||||
return site + " — " + ch.Title, base + "/" + channel, ch.Blurb
|
||||
}
|
||||
|
||||
// feedPath is the site-relative path of a feed, e.g. "/feed.xml" or
|
||||
// "/gaming/feed.json".
|
||||
func feedPath(channel, ext string) string {
|
||||
if channel == "" {
|
||||
return "/feed." + ext
|
||||
}
|
||||
return "/" + channel + "/feed." + ext
|
||||
}
|
||||
|
||||
// storyLink is the canonical outbound link for a story: its canonical URL when
|
||||
// known, otherwise the raw article URL.
|
||||
func storyLink(s storage.Story) string {
|
||||
if s.URLCanonical != "" {
|
||||
return s.URLCanonical
|
||||
}
|
||||
return s.ArticleURL
|
||||
}
|
||||
|
||||
// feedTime picks the best timestamp for a story: its published date when
|
||||
// present, otherwise when Pete first saw it.
|
||||
func feedTime(s storage.Story) time.Time {
|
||||
if s.PublishedAt > 0 {
|
||||
return time.Unix(s.PublishedAt, 0)
|
||||
}
|
||||
return time.Unix(s.SeenAt, 0)
|
||||
}
|
||||
|
||||
// channelBySlug looks up a channel by slug, falling back to a bare title-cased
|
||||
// entry so an unknown slug never panics.
|
||||
func channelBySlug(slug string) Channel {
|
||||
for _, ch := range channels {
|
||||
if ch.Slug == slug {
|
||||
return ch
|
||||
}
|
||||
}
|
||||
return Channel{Slug: slug, Title: slug, Theme: slug}
|
||||
}
|
||||
|
||||
// publicBase returns the site's public origin (scheme://host, no trailing
|
||||
// slash). It prefers the configured base_url and falls back to the request's
|
||||
// host so feeds still carry absolute URLs when base_url is unset.
|
||||
func (s *Server) publicBase(r *http.Request) string {
|
||||
if b := strings.TrimRight(s.cfg.BaseURL, "/"); b != "" {
|
||||
return b
|
||||
}
|
||||
scheme := "http"
|
||||
if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
// contentToHTML turns Pete's stored plain-text article body (paragraphs split by
|
||||
// blank lines, soft line breaks inside) into simple, escaped HTML for RSS
|
||||
// content:encoded. Returns "" when there is no body.
|
||||
func contentToHTML(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, para := range strings.Split(text, "\n\n") {
|
||||
para = strings.TrimSpace(para)
|
||||
if para == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("<p>")
|
||||
b.WriteString(strings.ReplaceAll(html.EscapeString(para), "\n", "<br>"))
|
||||
b.WriteString("</p>")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
210
internal/web/feed_test.go
Normal file
210
internal/web/feed_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// TestFeeds exercises the outbound RSS and JSON feeds end to end: a classified
|
||||
// story appears in both formats, carrying its canonical link, full content, and
|
||||
// a real pubDate; a channel-scoped feed excludes stories from other channels.
|
||||
func TestFeeds(t *testing.T) {
|
||||
storage.Close()
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "feed.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
|
||||
pub := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC).Unix()
|
||||
tech := &storage.Story{
|
||||
GUID: "feed-tech-1",
|
||||
Headline: "Chips & Dips: A Tech Tale",
|
||||
Lede: "The lede for the tech story.",
|
||||
Content: "First paragraph.\n\nSecond paragraph with <html> & symbols.",
|
||||
ArticleURL: "https://example.com/tech/story?utm=x",
|
||||
URLCanonical: "https://example.com/tech/story",
|
||||
Source: "Example Wire",
|
||||
Channel: "tech",
|
||||
Classified: true,
|
||||
SeenAt: time.Now().Unix(),
|
||||
PublishedAt: pub,
|
||||
}
|
||||
gaming := &storage.Story{
|
||||
GUID: "feed-gaming-1",
|
||||
Headline: "A Gaming Headline",
|
||||
Lede: "Gaming lede.",
|
||||
ArticleURL: "https://example.com/gaming/story",
|
||||
Source: "Play Wire",
|
||||
Channel: "gaming",
|
||||
Classified: true,
|
||||
SeenAt: time.Now().Unix(),
|
||||
}
|
||||
for _, st := range []*storage.Story{tech, gaming} {
|
||||
if err := storage.InsertStory(st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// --- Site-wide RSS ---
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleFeedXML(rw, httptest.NewRequest("GET", "/feed.xml", nil), "")
|
||||
if rw.Code != 200 {
|
||||
t.Fatalf("rss status = %d", rw.Code)
|
||||
}
|
||||
if ct := rw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/rss+xml") {
|
||||
t.Errorf("rss content-type = %q", ct)
|
||||
}
|
||||
var rss struct {
|
||||
Channel struct {
|
||||
Title string `xml:"title"`
|
||||
AtomLink struct {
|
||||
Href string `xml:"href,attr"`
|
||||
} `xml:"link"`
|
||||
Items []struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
GUID string `xml:"guid"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Content string `xml:"encoded"` // content:encoded
|
||||
} `xml:"item"`
|
||||
} `xml:"channel"`
|
||||
}
|
||||
if err := xml.Unmarshal(rw.Body.Bytes(), &rss); err != nil {
|
||||
t.Fatalf("rss did not parse as XML: %v\n%s", err, rw.Body.String())
|
||||
}
|
||||
if len(rss.Channel.Items) != 2 {
|
||||
t.Fatalf("rss items = %d, want 2", len(rss.Channel.Items))
|
||||
}
|
||||
// Newest-first: PublishedAt March 2026 vs gaming's seen_at (now) — gaming is
|
||||
// newer, so it sorts first. Find the tech item to assert on.
|
||||
var techItem *struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
GUID string `xml:"guid"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Content string `xml:"encoded"`
|
||||
}
|
||||
for i := range rss.Channel.Items {
|
||||
if rss.Channel.Items[i].GUID == "feed-tech-1" {
|
||||
techItem = &rss.Channel.Items[i]
|
||||
}
|
||||
}
|
||||
if techItem == nil {
|
||||
t.Fatal("tech item missing from rss")
|
||||
}
|
||||
if techItem.Link != "https://example.com/tech/story" {
|
||||
t.Errorf("rss link = %q, want canonical", techItem.Link)
|
||||
}
|
||||
if techItem.Title != "Chips & Dips: A Tech Tale" {
|
||||
t.Errorf("rss title = %q", techItem.Title)
|
||||
}
|
||||
if !strings.Contains(techItem.Content, "<p>First paragraph.</p>") {
|
||||
t.Errorf("rss content missing paragraph markup: %q", techItem.Content)
|
||||
}
|
||||
if !strings.Contains(techItem.Content, "<html>") {
|
||||
t.Errorf("rss content did not escape embedded markup: %q", techItem.Content)
|
||||
}
|
||||
if !strings.Contains(techItem.PubDate, "2026") {
|
||||
t.Errorf("rss pubDate = %q, want the published date", techItem.PubDate)
|
||||
}
|
||||
if !strings.Contains(rw.Body.String(), `href="https://news.example/feed.xml"`) {
|
||||
t.Errorf("rss missing atom self link with base_url")
|
||||
}
|
||||
|
||||
// --- Site-wide JSON Feed ---
|
||||
rw = httptest.NewRecorder()
|
||||
s.handleFeedJSON(rw, httptest.NewRequest("GET", "/feed.json", nil), "")
|
||||
if rw.Code != 200 {
|
||||
t.Fatalf("json status = %d", rw.Code)
|
||||
}
|
||||
if ct := rw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/feed+json") {
|
||||
t.Errorf("json content-type = %q", ct)
|
||||
}
|
||||
var jf struct {
|
||||
Version string `json:"version"`
|
||||
FeedURL string `json:"feed_url"`
|
||||
HomePageURL string `json:"home_page_url"`
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
ContentText string `json:"content_text"`
|
||||
Tags []string `json:"tags"`
|
||||
Authors []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"authors"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(rw.Body.Bytes(), &jf); err != nil {
|
||||
t.Fatalf("json feed did not parse: %v", err)
|
||||
}
|
||||
if !strings.Contains(jf.Version, "jsonfeed.org") {
|
||||
t.Errorf("json version = %q", jf.Version)
|
||||
}
|
||||
if jf.FeedURL != "https://news.example/feed.json" {
|
||||
t.Errorf("json feed_url = %q", jf.FeedURL)
|
||||
}
|
||||
if len(jf.Items) != 2 {
|
||||
t.Fatalf("json items = %d, want 2", len(jf.Items))
|
||||
}
|
||||
var jTech *struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
ContentText string `json:"content_text"`
|
||||
Tags []string `json:"tags"`
|
||||
Authors []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"authors"`
|
||||
}
|
||||
for i := range jf.Items {
|
||||
if jf.Items[i].ID == "feed-tech-1" {
|
||||
jTech = &jf.Items[i]
|
||||
}
|
||||
}
|
||||
if jTech == nil {
|
||||
t.Fatal("tech item missing from json feed")
|
||||
}
|
||||
if jTech.URL != "https://example.com/tech/story" {
|
||||
t.Errorf("json url = %q, want canonical", jTech.URL)
|
||||
}
|
||||
if !strings.Contains(jTech.ContentText, "Second paragraph") {
|
||||
t.Errorf("json content_text = %q", jTech.ContentText)
|
||||
}
|
||||
if len(jTech.Tags) != 1 || jTech.Tags[0] != "tech" {
|
||||
t.Errorf("json tags = %v", jTech.Tags)
|
||||
}
|
||||
if len(jTech.Authors) != 1 || jTech.Authors[0].Name != "Example Wire" {
|
||||
t.Errorf("json authors = %v", jTech.Authors)
|
||||
}
|
||||
|
||||
// --- Channel-scoped RSS excludes other channels ---
|
||||
rw = httptest.NewRecorder()
|
||||
s.handleFeedXML(rw, httptest.NewRequest("GET", "/tech/feed.xml", nil), "tech")
|
||||
rss.Channel.Items = nil // xml.Unmarshal appends; clear the site-wide items
|
||||
if err := xml.Unmarshal(rw.Body.Bytes(), &rss); err != nil {
|
||||
t.Fatalf("channel rss parse: %v", err)
|
||||
}
|
||||
if len(rss.Channel.Items) != 1 {
|
||||
t.Fatalf("channel rss items = %d, want 1", len(rss.Channel.Items))
|
||||
}
|
||||
if rss.Channel.Items[0].GUID != "feed-tech-1" {
|
||||
t.Errorf("channel rss wrong item: %q", rss.Channel.Items[0].GUID)
|
||||
}
|
||||
if !strings.Contains(rss.Channel.Title, "Tech") {
|
||||
t.Errorf("channel rss title = %q, want channel name", rss.Channel.Title)
|
||||
}
|
||||
}
|
||||
151
internal/web/games.go
Normal file
151
internal/web/games.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The euro/chip wire.
|
||||
//
|
||||
// gogobee owns the euros and has no inbound API, so it is the only initiator:
|
||||
// it polls this endpoint for border crossings, moves the money on its side, and
|
||||
// pushes the verdict back through the durable queue it already uses for
|
||||
// adventure facts. Pete never calls gogobee. That direction of travel is a
|
||||
// standing rule of the seam, not an implementation detail — see roster.go.
|
||||
//
|
||||
// All three endpoints are bearer-authed with the same ingest token as the
|
||||
// adventure seam, and all three are idempotent, because the thing on the other
|
||||
// end of them is a retrying queue and the thing they move is money.
|
||||
//
|
||||
// The storage layer under this (internal/storage/games.go) is where the actual
|
||||
// invariant lives: chips exist only once gogobee confirms it took the euros.
|
||||
// These handlers are transport, and deliberately nothing more.
|
||||
|
||||
// escrowGUID is the body of the two POSTs that name a row.
|
||||
type escrowGUID struct {
|
||||
GUID string `json:"guid"`
|
||||
}
|
||||
|
||||
// escrowVerdict is gogobee's answer: did the money move, and what is the
|
||||
// player's euro balance now.
|
||||
type escrowVerdict struct {
|
||||
GUID string `json:"guid"`
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
BalanceAfter float64 `json:"balance_after"`
|
||||
}
|
||||
|
||||
// handleEscrowPending is gogobee's poll: every crossing waiting to be moved.
|
||||
//
|
||||
// It includes rows gogobee claimed but never reported on — see
|
||||
// storage.PendingEscrow. Re-offering those is the whole reason the guid is an
|
||||
// idempotency key: if gogobee already moved the euros, the retry is a no-op
|
||||
// that reports the same answer, and if it died before moving them, the money
|
||||
// gets moved now instead of being stranded.
|
||||
func (s *Server) handleEscrowPending(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
rows, err := storage.PendingEscrow(escrowPollLimit)
|
||||
if err != nil {
|
||||
slog.Error("games: pending escrow", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []storage.Escrow{} // an empty poll is [], never null
|
||||
}
|
||||
writeJSON(w, rows)
|
||||
}
|
||||
|
||||
// escrowPollLimit caps one poll. gogobee polls every few seconds, so a backlog
|
||||
// drains in a handful of ticks rather than arriving as one enormous body.
|
||||
const escrowPollLimit = 50
|
||||
|
||||
// handleEscrowClaim marks a row as taken. It is not a lock — a row already
|
||||
// claimed can be claimed again, which is how a stale re-offer works — but a row
|
||||
// that has already reached a verdict cannot be, which is what stops a settled
|
||||
// cash-out being paid twice.
|
||||
//
|
||||
// The claimed row goes back in the response, so gogobee moves the money against
|
||||
// the amount and the user *Pete* holds rather than the ones it read a poll ago.
|
||||
func (s *Server) handleEscrowClaim(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var req escrowGUID
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&req); err != nil || req.GUID == "" {
|
||||
http.Error(w, "guid is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
e, err := storage.ClaimEscrow(req.GUID)
|
||||
if errors.Is(err, storage.ErrNoSuchEscrow) {
|
||||
http.Error(w, "no such escrow", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: claim escrow", "guid", req.GUID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, e)
|
||||
}
|
||||
|
||||
// handleEscrowSettled applies gogobee's verdict. This is the only way chips are
|
||||
// ever created, and it runs exactly once per guid no matter how many times the
|
||||
// push is redelivered.
|
||||
//
|
||||
// An unknown guid is a 400 rather than a shrug: gogobee has, by this point,
|
||||
// already moved real euros for a row Pete has no record of. Under the contract
|
||||
// the adventure seam established, a 400 makes gogobee's sender park the row
|
||||
// instead of retrying it forever — which is right, because no amount of retrying
|
||||
// invents the missing row. It leaves the payload sitting in gogobee's queue,
|
||||
// where a human can find it.
|
||||
func (s *Server) handleEscrowSettled(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var v escrowVerdict
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if v.GUID == "" {
|
||||
http.Error(w, "guid is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
e, err := storage.SettleEscrow(v.GUID, v.OK, v.Reason, v.BalanceAfter)
|
||||
if errors.Is(err, storage.ErrNoSuchEscrow) {
|
||||
slog.Error("games: verdict for an escrow row we have never heard of — "+
|
||||
"gogobee has moved euros against it and Pete cannot honour them",
|
||||
"guid", v.GUID, "ok", v.OK)
|
||||
http.Error(w, "no such escrow", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: settle escrow", "guid", v.GUID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("games: escrow settled", "guid", e.GUID, "user", e.MatrixUser,
|
||||
"kind", e.Kind, "amount", e.Amount, "state", e.State, "reason", e.Reason)
|
||||
writeJSON(w, e)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("games: write response", "err", err)
|
||||
}
|
||||
}
|
||||
214
internal/web/games_clock.go
Normal file
214
internal/web/games_clock.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The turn clock: the first goroutine in Pete that has ever mutated game state.
|
||||
//
|
||||
// Every other background loop here reads, refills or prunes. This one plays. When
|
||||
// a human sits at a felt and walks away mid-hand, three other people are waiting
|
||||
// on a decision that is never coming, and something has to make it for them. That
|
||||
// something is this.
|
||||
//
|
||||
// It is built on one discipline and one guard.
|
||||
//
|
||||
// The discipline is rule 1: **collect the due tables and close the rows before
|
||||
// taking any lock.** The scan reads *sql.Rows from the one-connection pool; a lock
|
||||
// taken while those rows are open would hold the connection the rows need, and the
|
||||
// process would wedge. So DueTables returns a plain slice and the connection is
|
||||
// free before the first table is touched.
|
||||
//
|
||||
// The guard is the version. The scan records each table's version; the clock acts
|
||||
// on a table only if it is *still* that version by the time it holds the lock and
|
||||
// has reloaded. Without that check the clock and a real move race and both land:
|
||||
// Bob raises in the same second his clock expires, the action moves to Cara, and
|
||||
// the clock — still believing the seat that ran out of time is to act — folds
|
||||
// Cara, who had twenty-five seconds left. The version check turns that into a
|
||||
// no-op: Bob's move bumped the version, the reload shows the new one, and the
|
||||
// clock steps aside.
|
||||
|
||||
// clockInterval is how often the clock looks for expired turns. Sub-second
|
||||
// precision buys nothing at a card table and would only spin the CPU.
|
||||
const clockInterval = time.Second
|
||||
|
||||
// reaperInterval is how often idle sessions are cashed out. The reaper is a
|
||||
// slow-moving safety net — a player who wandered off half an hour ago is not in a
|
||||
// hurry — so it runs far less often than the clock.
|
||||
const reaperInterval = time.Minute
|
||||
|
||||
// games returns the multiplayer engines by their storage key. It is the registry
|
||||
// the clock and the handlers both dispatch through. Empty until an engine is
|
||||
// wired; a clock over no games is a loop that finds nothing, which is correct.
|
||||
func (s *Server) games() map[string]tableGame {
|
||||
out := make(map[string]tableGame)
|
||||
for _, g := range s.tableGames {
|
||||
out[g.name()] = g
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StartTableClock launches the turn clock and the session reaper if the casino is
|
||||
// on. Safe to call unconditionally.
|
||||
func (s *Server) StartTableClock(ctx context.Context) {
|
||||
if !s.gamesReady() {
|
||||
return
|
||||
}
|
||||
// A deploy just took every table's clock down with it. Shove the live deadlines
|
||||
// out so the first tick does not auto-act the whole room at once.
|
||||
if err := storage.PushDeadlines(bootGrace); err != nil {
|
||||
slog.Error("games: push deadlines on boot", "err", err)
|
||||
}
|
||||
go s.runTableClock(ctx)
|
||||
go s.runSessionReaper(ctx)
|
||||
go s.runTableReaper(ctx)
|
||||
}
|
||||
|
||||
func (s *Server) runTableClock(ctx context.Context) {
|
||||
slog.Info("games: turn clock started", "interval", clockInterval)
|
||||
ticker := time.NewTicker(clockInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.tickClock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tickClock finds the tables whose turn has expired and acts on each. The scan is
|
||||
// done and the connection released before any table is locked — rule 1.
|
||||
func (s *Server) tickClock() {
|
||||
due, err := storage.DueTables(time.Now().Unix())
|
||||
if err != nil {
|
||||
slog.Error("games: due tables", "err", err)
|
||||
return
|
||||
}
|
||||
for _, ref := range due {
|
||||
s.runClockTable(ref)
|
||||
}
|
||||
}
|
||||
|
||||
// runClockTable acts for the walked-away player at one table, but only if the
|
||||
// table is still at the version the scan saw.
|
||||
//
|
||||
// The whole read-modify-write is done under the table's stripe, which is what
|
||||
// keeps the clock from racing a second copy of itself — but the stripe is only an
|
||||
// optimisation. The version check inside CommitTable is the real thing: even
|
||||
// across a redeploy, when two processes hold two different stripes over this row,
|
||||
// the one whose write lands first bumps the version and the other's write finds
|
||||
// zero rows and rolls back.
|
||||
func (s *Server) runClockTable(ref storage.TableRef) {
|
||||
err := s.tableLocks.withTable(ref.ID, func() error {
|
||||
t, seats, err := storage.LoadTable(ref.ID)
|
||||
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||
return nil // closed out from under us; nothing to do
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Somebody moved between the scan and now. Their move set a fresh deadline (or
|
||||
// cleared it), so this expiry is stale — step aside.
|
||||
if t.Version != ref.Version {
|
||||
return nil
|
||||
}
|
||||
// A deadline in the future means the scan is looking at an old view; leave it.
|
||||
if t.Deadline == 0 || t.Deadline > time.Now().Unix() {
|
||||
return nil
|
||||
}
|
||||
|
||||
game := s.games()[t.Game]
|
||||
if game == nil {
|
||||
slog.Error("games: clock over unknown game", "game", t.Game, "table", t.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
st, newSeats, err := game.timeout(t.State, seats)
|
||||
if errors.Is(err, errNotDue) {
|
||||
return nil // the race resolved without us; nothing to act on
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: clock timeout", "table", t.ID, "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
|
||||
if err := storage.CommitTable(storage.TableCommit{Table: t, Seats: newSeats, Audit: st.Audit}); err != nil {
|
||||
if errors.Is(err, storage.ErrStaleTable) {
|
||||
return nil // lost the race after all; the winner handled it
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.publishTable(ref.ID)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("games: clock table", "table", ref.ID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// publishTable pushes the current table view to everyone watching it. It reads
|
||||
// the table fresh (the authoritative state) and fans an opaque frame out through
|
||||
// the hub. Called after every committed write, under no lock the hub cares about
|
||||
// — the hub's sends are non-blocking, so this never stalls a caller.
|
||||
//
|
||||
// A view here is deliberately seat-blind: it carries only what every seat may see
|
||||
// (the version and the public table shape), and each subscriber's own stream
|
||||
// redacts and re-renders for the seat that is watching. That keeps a hole card
|
||||
// from ever entering a frame that fans to the whole table.
|
||||
func (s *Server) publishTable(tableID string) {
|
||||
if s.hub.watchers(tableID) == 0 {
|
||||
return
|
||||
}
|
||||
t, _, err := storage.LoadTable(tableID)
|
||||
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: publish table load", "table", tableID, "err", err)
|
||||
return
|
||||
}
|
||||
// The frame is just a nudge carrying the version: a subscriber that sees a gap
|
||||
// refetches the authoritative, per-seat table. So the payload can be minimal.
|
||||
// The type tells the browser a table changed (come and look) from a chat line
|
||||
// (render it in place) — both ride the one stream.
|
||||
data, _ := json.Marshal(map[string]any{"type": "table", "version": t.Version, "phase": t.Phase})
|
||||
s.hub.publish(tableID, hubFrame{Version: t.Version, Data: data})
|
||||
}
|
||||
|
||||
// runSessionReaper cashes out players who wandered off, on a timer. This is the
|
||||
// loop the plan noted never existed: ReapIdleSessions has always been safe to run
|
||||
// and nothing ever ran it, so chips in abandoned *solo* sessions sat in limbo.
|
||||
//
|
||||
// A seated player is invisible to it — their chips are inside a table blob, not on
|
||||
// their game_chips stack — so it only ever reaps a player who is genuinely idle
|
||||
// with loose chips. Getting up from a table returns them to the stack, and then
|
||||
// this is what eventually sends them home.
|
||||
func (s *Server) runSessionReaper(ctx context.Context) {
|
||||
slog.Info("games: session reaper started", "interval", reaperInterval, "idle", storage.SessionIdleAfter)
|
||||
ticker := time.NewTicker(reaperInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
n, err := storage.ReapIdleSessions(storage.SessionIdleAfter)
|
||||
if err != nil {
|
||||
slog.Error("games: reap idle sessions", "err", err)
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
slog.Info("games: reaped idle sessions", "count", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
142
internal/web/games_clock_test.go
Normal file
142
internal/web/games_clock_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// fakeGame is a tableGame that records whether its clock ever fired. It lets the
|
||||
// runtime tests exercise the lock discipline and the version guard without a real
|
||||
// engine — the thing under test is the clock, not the cards.
|
||||
type fakeGame struct {
|
||||
fired int
|
||||
}
|
||||
|
||||
func (g *fakeGame) name() string { return "fake" }
|
||||
|
||||
func (g *fakeGame) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
|
||||
g.fired++
|
||||
// Act: clear the deadline and bump the hand, as a real settle would.
|
||||
return step{State: []byte(`{"acted":true}`), Phase: "handover", HandNo: 2, Deadline: 0}, seats, nil
|
||||
}
|
||||
|
||||
func (g *fakeGame) stacks(state []byte) ([]int64, error) { return []int64{0, 0}, nil }
|
||||
|
||||
// clockTestServer stands up a Server with just the table machinery wired and a
|
||||
// fresh DB. Enough to drive the clock, nothing else.
|
||||
func clockTestServer(t *testing.T, g tableGame) *Server {
|
||||
t.Helper()
|
||||
// Reset the storage singleton onto a temp DB.
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "clock.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
|
||||
s := &Server{hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{g}}
|
||||
return s
|
||||
}
|
||||
|
||||
func openClockTable(t *testing.T, id string, deadline int64) storage.Table {
|
||||
t.Helper()
|
||||
tbl := storage.Table{
|
||||
ID: id, Game: "fake", Tier: "1-2", State: []byte(`{}`),
|
||||
Seed1: 1, Seed2: 2, Phase: "betting", HandNo: 1, Deadline: deadline,
|
||||
}
|
||||
seats := []storage.Seat{{Seat: 0, MatrixUser: "@reala:parodia.dev", Name: "reala"}, {Seat: 1, Name: "bot"}}
|
||||
if err := storage.OpenTable(tbl, seats); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tbl
|
||||
}
|
||||
|
||||
func TestClock_ActsOnExpiredTable(t *testing.T) {
|
||||
g := &fakeGame{}
|
||||
s := clockTestServer(t, g)
|
||||
openClockTable(t, "t1", time.Now().Unix()-5) // already expired
|
||||
|
||||
s.tickClock()
|
||||
|
||||
if g.fired != 1 {
|
||||
t.Fatalf("clock should have fired once, got %d", g.fired)
|
||||
}
|
||||
after, _, err := storage.LoadTable("t1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Phase != "handover" || after.Deadline != 0 {
|
||||
t.Errorf("table should have advanced: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClock_IgnoresFutureDeadlines(t *testing.T) {
|
||||
g := &fakeGame{}
|
||||
s := clockTestServer(t, g)
|
||||
openClockTable(t, "t1", time.Now().Unix()+60)
|
||||
|
||||
s.tickClock()
|
||||
|
||||
if g.fired != 0 {
|
||||
t.Fatalf("clock should not have fired on a future deadline, got %d", g.fired)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClock_VersionGuardStopsTheDoubleMove is the scenario the whole design turns
|
||||
// on. A move lands in the same tick the clock's scan found the table expired. The
|
||||
// move bumps the version; the clock, acting on its stale scan, must see the new
|
||||
// version and step aside rather than acting a second time.
|
||||
func TestClock_VersionGuardStopsTheDoubleMove(t *testing.T) {
|
||||
g := &fakeGame{}
|
||||
s := clockTestServer(t, g)
|
||||
tbl := openClockTable(t, "t1", time.Now().Unix()-5)
|
||||
|
||||
// The scan saw version 0.
|
||||
due, err := storage.DueTables(time.Now().Unix())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("want 1 due table, got %d", len(due))
|
||||
}
|
||||
|
||||
// A real move lands first, bumping the version and setting a fresh deadline for
|
||||
// the next player.
|
||||
tbl.State = []byte(`{"moved":true}`)
|
||||
tbl.Deadline = time.Now().Unix() + 30
|
||||
if err := storage.CommitTable(storage.TableCommit{Table: tbl}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Now the clock acts on its stale scan (version 0). It must not fire.
|
||||
s.runClockTable(due[0])
|
||||
|
||||
if g.fired != 0 {
|
||||
t.Fatalf("the version guard should have stopped the clock, but it fired %d time(s)", g.fired)
|
||||
}
|
||||
after, _, _ := storage.LoadTable("t1")
|
||||
if string(after.State) != `{"moved":true}` {
|
||||
t.Errorf("the real move should stand, got %s", after.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClock_PublishesToWatchers(t *testing.T) {
|
||||
g := &fakeGame{}
|
||||
s := clockTestServer(t, g)
|
||||
openClockTable(t, "t1", time.Now().Unix()-5)
|
||||
|
||||
ch, done := s.hub.subscribe("t1")
|
||||
defer done()
|
||||
|
||||
s.tickClock()
|
||||
|
||||
select {
|
||||
case f := <-ch:
|
||||
if f.Version == 0 {
|
||||
t.Errorf("frame should carry the bumped version, got %d", f.Version)
|
||||
}
|
||||
default:
|
||||
t.Fatal("a watcher should have received a frame after the clock acted")
|
||||
}
|
||||
}
|
||||
210
internal/web/games_hangman.go
Normal file
210
internal/web/games_hangman.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
|
||||
"pete/internal/games/blackjack"
|
||||
"pete/internal/games/hangman"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// Hangman, played for chips.
|
||||
//
|
||||
// The same shape as the blackjack table: the browser sends intents, the server
|
||||
// holds the state, and the payload carries only what the player is entitled to
|
||||
// see. Here that means the *masked* phrase. The unmasked one is in the engine
|
||||
// state, which is in game_live_hands, which is on this side of the wire — a
|
||||
// phrase sent down and flagged hidden is a phrase read out of devtools, and the
|
||||
// game would be a formality.
|
||||
|
||||
// cellView is one position in the phrase, as the browser draws it.
|
||||
//
|
||||
// Ch is empty while the letter is hidden — not the letter with a flag beside
|
||||
// it. Slot says whether this is a position you'd guess at all: a space or an
|
||||
// exclamation mark is scaffolding, shows from the start, and gets no tile.
|
||||
type cellView struct {
|
||||
Ch string `json:"ch"`
|
||||
Slot bool `json:"slot"`
|
||||
}
|
||||
|
||||
// hangmanView is a game as its player may see it.
|
||||
type hangmanView struct {
|
||||
Tier hangman.Tier `json:"tier"`
|
||||
Cells []cellView `json:"cells"`
|
||||
Tried []string `json:"tried"` // every letter guessed, right or wrong
|
||||
Wrong []string `json:"wrong"` // just the misses — the gallows counts these
|
||||
Lives int `json:"lives"`
|
||||
MaxWrong int `json:"max_wrong"`
|
||||
Multiple float64 `json:"multiple"` // what a win is worth right now
|
||||
Bet int64 `json:"bet"`
|
||||
Stands int64 `json:"stands"` // what the player would actually be paid if they won now
|
||||
|
||||
Phase string `json:"phase"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Phrase string `json:"phrase,omitempty"` // only once it's over
|
||||
Payout int64 `json:"payout,omitempty"`
|
||||
Rake int64 `json:"rake,omitempty"`
|
||||
Net int64 `json:"net"`
|
||||
}
|
||||
|
||||
func viewHangman(g hangman.State) hangmanView {
|
||||
v := hangmanView{
|
||||
Tier: g.Tier,
|
||||
Lives: g.Lives(),
|
||||
MaxWrong: hangman.MaxWrong,
|
||||
Multiple: g.Multiple(),
|
||||
Bet: g.Bet,
|
||||
// What the player would actually collect, rake already taken out. Quoting
|
||||
// the pre-rake figure here would have the felt advertising a payout the
|
||||
// house doesn't hand over.
|
||||
Stands: g.Pays(),
|
||||
Phase: string(g.Phase),
|
||||
Outcome: string(g.Outcome),
|
||||
Payout: g.Payout,
|
||||
Rake: g.Rake,
|
||||
Net: g.Net(),
|
||||
}
|
||||
for i, r := range g.Runes {
|
||||
c := cellView{Slot: hangman.Guessable(r)}
|
||||
if i < len(g.Shown) && g.Shown[i] {
|
||||
c.Ch = string(r)
|
||||
}
|
||||
v.Cells = append(v.Cells, c)
|
||||
}
|
||||
for _, r := range g.Tried {
|
||||
v.Tried = append(v.Tried, string(r))
|
||||
}
|
||||
for _, r := range g.Wrong {
|
||||
v.Wrong = append(v.Wrong, string(r))
|
||||
}
|
||||
// The phrase goes over the wire exactly once: when the game is over and it no
|
||||
// longer decides anything.
|
||||
if g.Phase == hangman.PhaseDone {
|
||||
v.Phrase = g.Phrase
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// handleHangmanStart takes the bet and draws a phrase. Same order as a deal:
|
||||
// the chips are staked first, in the same statement that checks they exist, so
|
||||
// two starts fired at once cannot bet the same chip.
|
||||
func (s *Server) handleHangmanStart(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Bet int64 `json:"bet"`
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
|
||||
return
|
||||
}
|
||||
tier, err := hangman.TierBySlug(req.Tier)
|
||||
if err != nil {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a length"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := storage.Stake(user, req.Bet); err != nil {
|
||||
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
|
||||
return
|
||||
}
|
||||
slog.Error("games: hangman stake", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
seed1, seed2 := newSeeds()
|
||||
rng := rand.New(rand.NewPCG(seed1, seed2))
|
||||
g, evs, err := hangman.New(req.Bet, tier, blackjack.DefaultRules().RakePct, rng)
|
||||
if err != nil {
|
||||
// The game never happened, so the stake never should have left.
|
||||
_ = storage.Award(user, req.Bet)
|
||||
slog.Error("games: hangman start", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.persistHangman(w, user, g, evs, seed1, seed2, true)
|
||||
}
|
||||
|
||||
// handleHangmanGuess plays one guess: a letter, or the whole phrase.
|
||||
func (s *Server) handleHangmanGuess(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var move hangman.Move
|
||||
if err := decodeJSON(r, &move); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
live, err := storage.LoadLiveHand(user)
|
||||
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: hangman load", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if live.Game != gameHangman {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand you're in first"})
|
||||
return
|
||||
}
|
||||
var g hangman.State
|
||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||
slog.Error("games: unreadable hangman game", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
next, evs, err := hangman.ApplyMove(g, move)
|
||||
if err != nil {
|
||||
// A letter already tried is the one illegal move a player makes by
|
||||
// accident rather than by trying it on, so it gets its own answer.
|
||||
msg := "that guess isn't legal here"
|
||||
if errors.Is(err, hangman.ErrAlreadyTried) {
|
||||
msg = "you've already tried that one"
|
||||
}
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
s.persistHangman(w, user, next, evs, live.Seed1, live.Seed2, false)
|
||||
}
|
||||
|
||||
// persistHangman writes the game back and answers the browser.
|
||||
func (s *Server) persistHangman(w http.ResponseWriter, user string, g hangman.State, evs []hangman.Event, seed1, seed2 uint64, fresh bool) {
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
slog.Error("games: marshal hangman", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
done := g.Phase == hangman.PhaseDone
|
||||
v, ok := s.commit(w, user, finished{
|
||||
Game: gameHangman, Blob: blob,
|
||||
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
|
||||
Outcome: string(g.Outcome), Done: done,
|
||||
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// A finished game is gone from storage, so the table has none to show — but
|
||||
// the browser still needs the final board to reveal the phrase onto.
|
||||
if done {
|
||||
hv := viewHangman(g)
|
||||
v.Hangman = &hv
|
||||
}
|
||||
v.HangEvents = evs
|
||||
writeJSON(w, v)
|
||||
}
|
||||
188
internal/web/games_hangman_test.go
Normal file
188
internal/web/games_hangman_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The one thing this table cannot get wrong: the stake leaves the stack, and the
|
||||
// phrase does not leave the server.
|
||||
func TestHangmanStartTakesTheStakeAndKeepsThePhrase(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
v, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "short"}))
|
||||
if code != 200 {
|
||||
t.Fatalf("start = %d, want 200", code)
|
||||
}
|
||||
if v.Chips != 900 {
|
||||
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
|
||||
}
|
||||
if v.Hangman == nil {
|
||||
t.Fatal("start returned no game")
|
||||
}
|
||||
if v.Game != gameHangman {
|
||||
t.Errorf("game = %q, want hangman", v.Game)
|
||||
}
|
||||
if v.Hangman.Phrase != "" {
|
||||
t.Fatalf("the phrase was sent to the browser before it was won: %q", v.Hangman.Phrase)
|
||||
}
|
||||
// Nothing is revealed at the start except the scaffolding, and a space is not
|
||||
// a letter you have to earn.
|
||||
for _, c := range v.Hangman.Cells {
|
||||
if c.Slot && c.Ch != "" {
|
||||
t.Fatalf("a letter was face up before it was guessed: %+v", c)
|
||||
}
|
||||
}
|
||||
if v.Hangman.Lives != 6 {
|
||||
t.Errorf("lives = %d, want 6", v.Hangman.Lives)
|
||||
}
|
||||
}
|
||||
|
||||
// A win pays what the felt said it would, and the rake comes out of the winnings.
|
||||
func TestHangmanWinPaysWhatTheFeltQuoted(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
v, _ := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "short"}))
|
||||
quoted := v.Hangman.Stands
|
||||
|
||||
// The server holds the phrase, so read it out of the live row — which is the
|
||||
// only place it exists — and solve it.
|
||||
phrase := livePhrase(t)
|
||||
v, code := call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess",
|
||||
map[string]string{"solve": phrase}))
|
||||
if code != 200 {
|
||||
t.Fatalf("solve = %d, want 200", code)
|
||||
}
|
||||
if v.Hangman.Outcome != "solved" {
|
||||
t.Fatalf("outcome = %q, want solved", v.Hangman.Outcome)
|
||||
}
|
||||
// No wrong guesses, so the full 2.6×: 260 gross, 160 profit, 8 rake, 252 back.
|
||||
if v.Hangman.Payout != quoted {
|
||||
t.Errorf("felt quoted %d, house paid %d", quoted, v.Hangman.Payout)
|
||||
}
|
||||
if v.Hangman.Payout != 252 || v.Hangman.Rake != 8 {
|
||||
t.Errorf("payout/rake = %d/%d, want 252/8", v.Hangman.Payout, v.Hangman.Rake)
|
||||
}
|
||||
if got := chipsNow(t); got != 900+252 {
|
||||
t.Errorf("chips = %d, want %d", got, 900+252)
|
||||
}
|
||||
// And the phrase is finally allowed out, now that it decides nothing.
|
||||
if v.Hangman.Phrase == "" {
|
||||
t.Error("a finished game never told the player what the phrase was")
|
||||
}
|
||||
// The game is off the felt.
|
||||
if _, err := storage.LoadLiveHand(testPlayer); err == nil {
|
||||
t.Error("a settled game is still sitting in game_live_hands")
|
||||
}
|
||||
}
|
||||
|
||||
// Six wrong guesses take the stake and nothing more.
|
||||
func TestHangmanHangingCostsExactlyTheStake(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "short"}))
|
||||
|
||||
// Six solves that are certainly wrong — a wrong solve costs a life, same as a
|
||||
// wrong letter, and this needs no knowledge of the phrase.
|
||||
var v tableView
|
||||
for i := 0; i < 6; i++ {
|
||||
v, _ = call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess",
|
||||
map[string]string{"solve": "definitely not the phrase at all"}))
|
||||
}
|
||||
if v.Hangman == nil || v.Hangman.Outcome != "hung" {
|
||||
t.Fatalf("outcome = %+v, want hung", v.Hangman)
|
||||
}
|
||||
if v.Hangman.Payout != 0 {
|
||||
t.Errorf("payout = %d, want 0", v.Hangman.Payout)
|
||||
}
|
||||
if got := chipsNow(t); got != 900 {
|
||||
t.Errorf("chips = %d, want 900 — a loss costs the stake and no more", got)
|
||||
}
|
||||
if v.Hangman.Phrase == "" {
|
||||
t.Error("hung without being told the answer")
|
||||
}
|
||||
}
|
||||
|
||||
// One game at a time, across games: you cannot walk from a hangman into a hand of
|
||||
// blackjack with chips still riding on a phrase.
|
||||
func TestHangmanHoldsTheSeatAgainstBlackjack(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "short"}))
|
||||
|
||||
_, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal",
|
||||
map[string]int64{"bet": 100}))
|
||||
if code != 409 {
|
||||
t.Fatalf("dealt blackjack on top of a live hangman: %d, want 409", code)
|
||||
}
|
||||
// And the stake that was refused came back: 1000 - 100 (the hangman) and not a
|
||||
// chip more.
|
||||
if got := chipsNow(t); got != 900 {
|
||||
t.Errorf("chips = %d, want 900 — the refused deal kept the stake", got)
|
||||
}
|
||||
if _, err := storage.LoadLiveHand(testPlayer); err != nil {
|
||||
t.Errorf("the hangman was evicted by the deal it refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cashing out mid-phrase is refused, for the same reason as mid-hand.
|
||||
func TestCannotCashOutMidPhrase(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "short"}))
|
||||
|
||||
_, code := call(t, s, s.handleCashOut, as(t, s, "reala", "POST", "/api/games/cashout",
|
||||
map[string]int64{"amount": 0}))
|
||||
if code != 409 {
|
||||
t.Fatalf("cash-out mid-phrase = %d, want 409", code)
|
||||
}
|
||||
}
|
||||
|
||||
// A tier the browser made up is refused, and costs nothing.
|
||||
func TestHangmanRefusesAnInventedTier(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
_, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "impossible"}))
|
||||
if code != 400 {
|
||||
t.Fatalf("start on a made-up tier = %d, want 400", code)
|
||||
}
|
||||
if got := chipsNow(t); got != 1000 {
|
||||
t.Errorf("chips = %d, want 1000 — a refused game must not take a stake", got)
|
||||
}
|
||||
}
|
||||
|
||||
// livePhrase digs the phrase out of the live row. Only a test may do this: it is
|
||||
// reaching past the wire on purpose, to prove the wire doesn't carry it.
|
||||
func livePhrase(t *testing.T) string {
|
||||
t.Helper()
|
||||
live, err := storage.LoadLiveHand(testPlayer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blob := string(live.State)
|
||||
const key = `"phrase":"`
|
||||
i := strings.Index(blob, key)
|
||||
if i < 0 {
|
||||
t.Fatalf("no phrase in the live row: %s", blob)
|
||||
}
|
||||
rest := blob[i+len(key):]
|
||||
j := strings.Index(rest, `"`)
|
||||
if j < 0 {
|
||||
t.Fatal("unterminated phrase in the live row")
|
||||
}
|
||||
return rest[:j]
|
||||
}
|
||||
731
internal/web/games_holdem.go
Normal file
731
internal/web/games_holdem.go
Normal file
@@ -0,0 +1,731 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/blackjack"
|
||||
"pete/internal/games/holdem"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// Texas hold'em, played for chips against the trained bots.
|
||||
//
|
||||
// This is the only table in the casino that is a *session* rather than a game.
|
||||
// Everywhere else you stake, you play once, and a multiple pays: the hand is the
|
||||
// unit and the money moves at both ends of it. Poker is not that shape. You buy
|
||||
// chips onto the table, you play as many hands as you feel like, and you leave
|
||||
// with whatever is in front of you — so the live row lives across hands, and the
|
||||
// chips move exactly twice: once when you sit down, once when you get up.
|
||||
//
|
||||
// Which means there is no "payout" until you stand up, and `commit` is only ever
|
||||
// told the game is Done when you do (or when you have nothing left to sit with).
|
||||
// In between, every pot won and lost is inside the engine, and storage sees none
|
||||
// of it. That is the honest model, and it is also the safe one: a hand that dies
|
||||
// halfway leaves the chips where they were, on the table, in the live row.
|
||||
//
|
||||
// What the browser is allowed to see: your two cards, the board, everybody's
|
||||
// stacks and bets, and nothing else. Not the deck, and not a bot's hand — until
|
||||
// a showdown turns it over, which is the moment it stops being a secret.
|
||||
|
||||
// holdemSeatView is one seat. Cards are present only when the viewer is entitled
|
||||
// to them: yours always, a bot's never, until the hand is shown down.
|
||||
type holdemSeatView struct {
|
||||
Name string `json:"name"`
|
||||
Bot bool `json:"bot"`
|
||||
You bool `json:"you"`
|
||||
Stack int64 `json:"stack"`
|
||||
Bet int64 `json:"bet"`
|
||||
State string `json:"state"` // active | folded | allin | out
|
||||
Pos string `json:"pos"` // BTN, SB, BB, UTG…
|
||||
Cards []cardView `json:"cards,omitempty"`
|
||||
Won int64 `json:"won,omitempty"`
|
||||
}
|
||||
|
||||
var seatStates = map[holdem.SeatState]string{
|
||||
holdem.Active: "active",
|
||||
holdem.Folded: "folded",
|
||||
holdem.AllIn: "allin",
|
||||
holdem.Out: "out",
|
||||
}
|
||||
|
||||
// holdemView is the table as its player may see it.
|
||||
type holdemView struct {
|
||||
Tier holdem.Tier `json:"tier"`
|
||||
// YourSeat is which chair in Seats is the viewer's. It used to be a convention
|
||||
// (seat zero is you) that the felt hardcoded; at a shared table it is whatever
|
||||
// chair you took, so it rides in the view and the browser reads it rather than
|
||||
// assuming it.
|
||||
YourSeat int `json:"your_seat"`
|
||||
Seats []holdemSeatView `json:"seats"`
|
||||
Button int `json:"button"`
|
||||
HandNo int `json:"hand_no"`
|
||||
|
||||
Board []cardView `json:"board"`
|
||||
Street string `json:"street"`
|
||||
Pot int64 `json:"pot"`
|
||||
Side []int64 `json:"side,omitempty"`
|
||||
|
||||
ToAct int `json:"to_act"`
|
||||
Phase string `json:"phase"`
|
||||
|
||||
// What you may do, decided here rather than in the browser — the felt should
|
||||
// never offer a button the table would refuse.
|
||||
Owed int64 `json:"owed"`
|
||||
CanCheck bool `json:"can_check"`
|
||||
CanRaise bool `json:"can_raise"`
|
||||
MinRaise int64 `json:"min_raise_to"`
|
||||
MaxRaise int64 `json:"max_raise_to"`
|
||||
|
||||
Stack int64 `json:"stack"` // what's in front of you
|
||||
BoughtIn int64 `json:"bought_in"`
|
||||
Rake int64 `json:"rake"` // what the house has taken this session
|
||||
MaxTopUp int64 `json:"max_topup"`
|
||||
Payout int64 `json:"payout,omitempty"`
|
||||
}
|
||||
|
||||
// viewHoldem renders the table as one seat may see it. viewer is which seat is
|
||||
// looking — their cards are the only hole cards it will ever put in the payload
|
||||
// (until a showdown turns the rest over), and the action panel is filled in only
|
||||
// when it is that seat's turn.
|
||||
//
|
||||
// This is the security boundary. Before SSE a missed check here leaked one bot's
|
||||
// cards to one player through a bug in one handler; now the same view fans to
|
||||
// every subscriber's stream, so a seat that renders anyone else's hole card fans
|
||||
// it to the whole table. TestHoldemViewNeverLeaksAnotherSeatsCards renders every
|
||||
// seat's view at every street and greps for cards that are not theirs.
|
||||
func viewHoldem(g holdem.State, viewer int) holdemView {
|
||||
v := holdemView{
|
||||
Tier: g.Tier,
|
||||
YourSeat: viewer,
|
||||
Button: g.Button,
|
||||
HandNo: g.HandNo,
|
||||
Street: g.Street.String(),
|
||||
Pot: g.Total(),
|
||||
ToAct: g.ToAct,
|
||||
Phase: string(g.Phase),
|
||||
Stack: g.Seats[viewer].Stack,
|
||||
BoughtIn: g.BoughtIn, // a table total; the caller overrides it with this seat's own stake
|
||||
Rake: g.Seats[viewer].Paid, // this seat's rake alone, not the table's — see the ledger line on the felt
|
||||
Payout: g.Payout,
|
||||
}
|
||||
for _, p := range g.Side {
|
||||
v.Side = append(v.Side, p.Amount)
|
||||
}
|
||||
// An empty board is an empty board, not null. A Go slice with nothing in it
|
||||
// marshals to null, and a browser that has to write `(board || [])` everywhere
|
||||
// is a browser one forgotten guard away from a crash on every preflop.
|
||||
v.Board = []cardView{}
|
||||
for _, c := range g.Community {
|
||||
v.Board = append(v.Board, viewCard(c))
|
||||
}
|
||||
|
||||
// The wall. Another seat's hand crosses the wire in exactly one situation — the
|
||||
// hand was shown down and they did not fold — because that is the only situation
|
||||
// in which a player at a real table would be looking at it.
|
||||
shown := g.Street == holdem.Showdown
|
||||
for i, p := range g.Seats {
|
||||
seat := holdemSeatView{
|
||||
Name: p.Name,
|
||||
Bot: p.Bot,
|
||||
You: i == viewer,
|
||||
Stack: p.Stack,
|
||||
Bet: p.Bet,
|
||||
State: seatStates[p.State],
|
||||
Pos: g.Position(i),
|
||||
Won: p.Won,
|
||||
}
|
||||
mine := i == viewer
|
||||
dealt := p.State != holdem.Out && p.Hole[0].Rank != 0
|
||||
if dealt && (mine || (shown && p.State != holdem.Folded)) {
|
||||
seat.Cards = []cardView{viewCard(p.Hole[0]), viewCard(p.Hole[1])}
|
||||
}
|
||||
v.Seats = append(v.Seats, seat)
|
||||
}
|
||||
|
||||
if g.Phase == holdem.PhaseBetting && g.ToAct == viewer {
|
||||
v.Owed = g.Owed(viewer)
|
||||
v.CanCheck = v.Owed == 0
|
||||
v.CanRaise = g.CanRaise(viewer)
|
||||
v.MinRaise = g.MinRaiseTo(viewer)
|
||||
v.MaxRaise = g.MaxRaiseTo(viewer)
|
||||
}
|
||||
if top := g.Tier.MaxBuy - g.Seats[viewer].Stack; top > 0 {
|
||||
v.MaxTopUp = top
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// holdemEventView is one beat of the script the felt plays back. The engine only
|
||||
// ever attaches a bot's cards to a showdown; this drops them again anywhere else,
|
||||
// which is the second of the two walls.
|
||||
type holdemEventView struct {
|
||||
Kind string `json:"kind"`
|
||||
Seat int `json:"seat"`
|
||||
Cards []cardView `json:"cards,omitempty"`
|
||||
Amount int64 `json:"amount,omitempty"`
|
||||
Total int64 `json:"total,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// viewHoldemEvents redacts the engine's script for one viewer. The engine emits
|
||||
// every seat's hole cards now (it cannot know who a shared stream is for), so
|
||||
// this is where the cards that are not the viewer's are stripped — turning a
|
||||
// "deal seat 3 these two cards" beat into "deal seat 3 two face-down cards".
|
||||
//
|
||||
// A card may ride an event only if it is a board card (Seat < 0), the viewer's
|
||||
// own, or a hand being shown down. Everything else is nulled here, and a missed
|
||||
// case is the leak that fans one seat's hole card to every subscriber.
|
||||
func viewHoldemEvents(evs []holdem.Event, viewer int) []holdemEventView {
|
||||
out := make([]holdemEventView, 0, len(evs))
|
||||
for _, e := range evs {
|
||||
v := holdemEventView{Kind: e.Kind, Seat: e.Seat, Amount: e.Amount, Total: e.Total, Text: e.Text}
|
||||
for _, c := range e.Cards {
|
||||
v.Cards = append(v.Cards, viewCard(c))
|
||||
}
|
||||
if len(v.Cards) > 0 && e.Seat >= 0 && e.Seat != viewer && e.Kind != "show" {
|
||||
v.Cards = nil
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
// ---- sitting down: a table of your own, or somebody else's -----------------
|
||||
|
||||
// displayName is what goes on the felt. It is the player's session name if they
|
||||
// have one, the local part of their Matrix id otherwise — never empty, which
|
||||
// would sit a nameless chair at the table.
|
||||
func (s *Server) displayName(r *http.Request, user string) string {
|
||||
if u := s.auth.userFromRequest(r); u != nil {
|
||||
if u.Name != "" {
|
||||
return u.Name
|
||||
}
|
||||
if u.Username != "" {
|
||||
return u.Username
|
||||
}
|
||||
}
|
||||
name := strings.TrimPrefix(user, "@")
|
||||
if i := strings.IndexByte(name, ':'); i > 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// seatRows mirrors the engine's seats into the storage rows that shadow them,
|
||||
// index for index — seat i in the blob is seat i in game_seats — which is the
|
||||
// alignment the view redacts by and the audit attributes by. A human's staked is
|
||||
// the buy-in that actually crossed the border; a bot's is zero, because the only
|
||||
// real money at the table is in the human seats and staked is where the border
|
||||
// accounting lives.
|
||||
func seatRows(g holdem.State, human string, buyIn int64) []storage.Seat {
|
||||
rows := make([]storage.Seat, len(g.Seats))
|
||||
for i := range g.Seats {
|
||||
p := g.Seats[i]
|
||||
row := storage.Seat{Seat: i, Name: p.Name}
|
||||
if !p.Bot {
|
||||
row.MatrixUser = human
|
||||
row.Staked = buyIn
|
||||
}
|
||||
rows[i] = row
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// handleHoldemSit seats a player: at a fresh table of their own (solo is just a
|
||||
// table nobody else has joined yet), or at an open chair on somebody else's.
|
||||
func (s *Server) handleHoldemSit(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Tier string `json:"tier"`
|
||||
Bots int `json:"bots"`
|
||||
BuyIn int64 `json:"buyin"`
|
||||
Table string `json:"table"` // set to join an existing table rather than open one
|
||||
Seat *int `json:"seat"` // which chair to take when joining; optional
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Table != "" {
|
||||
s.joinHoldem(w, r, user, req.Table, req.Seat, req.BuyIn)
|
||||
return
|
||||
}
|
||||
s.openHoldem(w, r, user, req.Tier, req.Bots, req.BuyIn)
|
||||
}
|
||||
|
||||
// openHoldem opens a fresh table with the player in seat zero and bots in the
|
||||
// rest. It is the old solo flow, now a real shared table that simply has no other
|
||||
// humans on it yet.
|
||||
func (s *Server) openHoldem(w http.ResponseWriter, r *http.Request, user, tierSlug string, bots int, buyIn int64) {
|
||||
tier, err := holdem.TierBySlug(tierSlug)
|
||||
if err != nil {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
|
||||
return
|
||||
}
|
||||
if buyIn < tier.MinBuy || buyIn > tier.MaxBuy {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that isn't a legal buy-in for this table"})
|
||||
return
|
||||
}
|
||||
if bots < 1 || bots > holdem.MaxBots {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick some opponents"})
|
||||
return
|
||||
}
|
||||
|
||||
name := s.displayName(r, user)
|
||||
seed1, seed2 := newSeeds()
|
||||
g, _, err := holdem.New(tier, holdem.TableSeats(tier, name, bots, buyIn), blackjack.DefaultRules().RakePct, seed1, seed2)
|
||||
if err != nil {
|
||||
slog.Error("games: holdem open", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
slog.Error("games: marshal new holdem", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
id, err := storage.NewTableID()
|
||||
if err != nil {
|
||||
slog.Error("games: mint table id", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
t := storage.Table{
|
||||
ID: id, Game: gameHoldem, Tier: tier.Slug, State: blob,
|
||||
Seed1: seed1, Seed2: seed2, Phase: string(g.Phase), HandNo: int64(g.HandNo),
|
||||
}
|
||||
err = storage.OpenSoloTable(t, seatRows(g, user, buyIn), buyIn)
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"})
|
||||
return
|
||||
case errors.Is(err, storage.ErrHandInProgress):
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
|
||||
return
|
||||
case err != nil:
|
||||
slog.Error("games: open solo table", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.writeHoldemTable(w, user, nil)
|
||||
}
|
||||
|
||||
// pickOpenSeat chooses a chair to join: the one the caller asked for if it is a
|
||||
// bot's, otherwise the first bot seat. Returns -1 if there is nowhere to sit.
|
||||
func pickOpenSeat(g holdem.State, want *int) int {
|
||||
if want != nil {
|
||||
i := *want
|
||||
if i >= 0 && i < len(g.Seats) && g.Seats[i].Bot {
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
for i := range g.Seats {
|
||||
if g.Seats[i].Bot {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// joinHoldem sits a player down at an open chair on an existing table. It runs
|
||||
// under the table lock, and every step of the sit-down is one transaction in
|
||||
// SitDown — stake, claim, take the chair out of a bot's hands, save the state —
|
||||
// so two people racing for the last seat cannot both win it.
|
||||
func (s *Server) joinHoldem(w http.ResponseWriter, r *http.Request, user, tableID string, wantSeat *int, buyIn int64) {
|
||||
name := s.displayName(r, user)
|
||||
var respErr error
|
||||
err := s.tableLocks.withTable(tableID, func() error {
|
||||
t, _, err := storage.LoadTable(tableID)
|
||||
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||
respErr = storage.ErrNoSuchTable
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.Game != gameHoldem {
|
||||
respErr = holdem.ErrUnknownMove
|
||||
return nil
|
||||
}
|
||||
var g holdem.State
|
||||
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
if g.Phase == holdem.PhaseBetting {
|
||||
respErr = holdem.ErrHandLive // you join between hands, not into one
|
||||
return nil
|
||||
}
|
||||
seat := pickOpenSeat(g, wantSeat)
|
||||
if seat < 0 {
|
||||
respErr = holdem.ErrTableFull
|
||||
return nil
|
||||
}
|
||||
if err := g.Occupy(seat, name, buyIn); err != nil {
|
||||
respErr = err
|
||||
return nil
|
||||
}
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.State, t.Phase, t.HandNo = blob, string(g.Phase), int64(g.HandNo)
|
||||
err = storage.SitDown(storage.Sit{
|
||||
Table: t,
|
||||
Seat: storage.Seat{Seat: seat, MatrixUser: user, Name: name, Staked: buyIn},
|
||||
BuyIn: buyIn,
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
|
||||
respErr = storage.ErrInsufficientChips
|
||||
return nil
|
||||
case errors.Is(err, storage.ErrHandInProgress):
|
||||
respErr = storage.ErrHandInProgress
|
||||
return nil
|
||||
case errors.Is(err, storage.ErrSeatTaken), errors.Is(err, storage.ErrStaleTable):
|
||||
respErr = storage.ErrSeatTaken
|
||||
return nil
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
s.publishTable(tableID)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("games: join holdem", "user", user, "table", tableID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if respErr != nil {
|
||||
writeJSONStatus(w, joinStatus(respErr), map[string]string{"error": joinMessage(respErr)})
|
||||
return
|
||||
}
|
||||
s.writeHoldemTable(w, user, nil)
|
||||
}
|
||||
|
||||
func joinStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNoSuchTable), errors.Is(err, holdem.ErrTableFull),
|
||||
errors.Is(err, storage.ErrSeatTaken), errors.Is(err, holdem.ErrHandLive):
|
||||
return http.StatusConflict
|
||||
default:
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
func joinMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNoSuchTable):
|
||||
return "that table has closed"
|
||||
case errors.Is(err, holdem.ErrTableFull), errors.Is(err, storage.ErrSeatTaken):
|
||||
return "that seat is taken"
|
||||
case errors.Is(err, holdem.ErrHandLive):
|
||||
return "a hand is in play — sit down when it's over"
|
||||
case errors.Is(err, holdem.ErrBadBuyIn):
|
||||
return "that isn't a legal buy-in for this table"
|
||||
case errors.Is(err, storage.ErrInsufficientChips):
|
||||
return "not enough chips to sit down"
|
||||
case errors.Is(err, storage.ErrHandInProgress):
|
||||
return "finish the game you're in first"
|
||||
default:
|
||||
return "you can't sit there"
|
||||
}
|
||||
}
|
||||
|
||||
// ---- playing a hand --------------------------------------------------------
|
||||
|
||||
// handleHoldemMove plays one move at the player's table: a betting action, or the
|
||||
// two session moves that are not leaving (deal the next hand, top up between
|
||||
// them). Leaving is its own endpoint, because it is a storage operation rather
|
||||
// than an engine one.
|
||||
func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var move holdem.Move
|
||||
if err := decodeJSON(r, &move); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if move.Kind == holdem.Leave {
|
||||
s.leaveHoldem(w, user)
|
||||
return
|
||||
}
|
||||
|
||||
tableID, seat, err := storage.PlayerSeat(user)
|
||||
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: holdem move seat", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var respErr error
|
||||
var respEvents []holdem.Event
|
||||
err = s.tableLocks.withTable(tableID, func() error {
|
||||
t, seats, err := storage.LoadTable(tableID)
|
||||
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||
respErr = storage.ErrNoSuchTable
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var g holdem.State
|
||||
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A top-up is real chips crossing the border, so it comes off the stack
|
||||
// before the engine is asked, and goes straight back if the engine says no —
|
||||
// the same order, and the same reason, as doubling down at blackjack.
|
||||
topped := int64(0)
|
||||
if move.Kind == holdem.TopUp {
|
||||
if err := storage.Stake(user, move.Amount); err != nil {
|
||||
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
||||
respErr = holdem.ErrTooBig
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
topped = move.Amount
|
||||
}
|
||||
|
||||
next, evs, aerr := holdem.ApplyMove(g, seat, move)
|
||||
if aerr != nil {
|
||||
if topped > 0 {
|
||||
_ = storage.Award(user, topped) // the top-up didn't happen
|
||||
}
|
||||
respErr = aerr
|
||||
return nil
|
||||
}
|
||||
|
||||
// A solo session that just ended (the one human busted) is not a table any
|
||||
// more: cash the seat out — for nothing, but the claim still has to be
|
||||
// released — and close the felt. Only a one-human bust reaches PhaseDone; a
|
||||
// shared table sits the busted seat Out and plays on.
|
||||
if next.Phase == holdem.PhaseDone {
|
||||
if err := s.settleLeave(t, next, seat, user); err != nil {
|
||||
if topped > 0 {
|
||||
_ = storage.Award(user, topped)
|
||||
}
|
||||
if errors.Is(err, storage.ErrStaleTable) {
|
||||
respErr = storage.ErrStaleTable
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
respEvents = evs
|
||||
s.publishTable(tableID)
|
||||
return nil
|
||||
}
|
||||
|
||||
st, err := holdemStep(g, next, evs, seats)
|
||||
if err != nil {
|
||||
if topped > 0 {
|
||||
_ = storage.Award(user, topped)
|
||||
}
|
||||
return err
|
||||
}
|
||||
acting := seats[seat]
|
||||
acting.Away = false
|
||||
acting.LastSeen = time.Now().Unix()
|
||||
// A top-up is more real money crossing the border, so the seat's staked row —
|
||||
// the record of what they brought and can still take home — has to grow with
|
||||
// it. Without this the storage invariant (stacks + pot == staked - rake) drifts
|
||||
// by every top-up, and the felt under-reports what they bought in for.
|
||||
acting.Staked += topped
|
||||
|
||||
t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
|
||||
if err := storage.CommitTable(storage.TableCommit{
|
||||
Table: t, Seats: []storage.Seat{acting}, Audit: st.Audit,
|
||||
}); err != nil {
|
||||
if errors.Is(err, storage.ErrStaleTable) {
|
||||
if topped > 0 {
|
||||
_ = storage.Award(user, topped)
|
||||
}
|
||||
respErr = storage.ErrStaleTable
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
respEvents = evs
|
||||
s.publishTable(tableID)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("games: holdem move", "user", user, "table", tableID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if respErr != nil {
|
||||
writeJSONStatus(w, moveStatus(respErr), map[string]string{"error": moveMessage(respErr)})
|
||||
return
|
||||
}
|
||||
s.writeHoldemTable(w, user, respEvents)
|
||||
}
|
||||
|
||||
func moveStatus(err error) int {
|
||||
// A 409 is a concurrency verdict: the table is not where the caller thought, so
|
||||
// reload and look again. Everything else — an illegal move for this state — is
|
||||
// the caller's mistake, a 400. Leaving mid-hand or acting out of turn is a 400:
|
||||
// the request was simply not allowed, not raced.
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable):
|
||||
return http.StatusConflict
|
||||
default:
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
func moveMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrStaleTable):
|
||||
return "the table moved on — take another look"
|
||||
case errors.Is(err, storage.ErrNoSuchTable):
|
||||
return "that table has closed"
|
||||
case errors.Is(err, holdem.ErrHandLive):
|
||||
return "finish the hand first"
|
||||
case errors.Is(err, holdem.ErrNotYourTurn):
|
||||
return "it isn't your turn"
|
||||
case errors.Is(err, holdem.ErrCantCheck):
|
||||
return "there's a bet to you"
|
||||
case errors.Is(err, holdem.ErrTooSmall):
|
||||
return "that's under the minimum raise"
|
||||
case errors.Is(err, holdem.ErrTooBig):
|
||||
return "you don't have that many chips"
|
||||
case errors.Is(err, holdem.ErrBadBuyIn):
|
||||
return "that would put you over the table maximum"
|
||||
default:
|
||||
return "that move isn't legal here"
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getting up ------------------------------------------------------------
|
||||
|
||||
// handleHoldemLeave is the get-up endpoint. Leaving is its own route because it
|
||||
// is a storage operation, not an engine move — the chips cross the border and the
|
||||
// felt may close — even though the felt also lets you send it as a "leave" move.
|
||||
func (s *Server) handleHoldemLeave(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.leaveHoldem(w, user)
|
||||
}
|
||||
|
||||
// leaveHoldem gets a player up from their table, turning what is in front of them
|
||||
// back into chips. It refuses mid-hand — you cannot walk out on chips you have in
|
||||
// the pot — and closes the felt behind the last human to leave.
|
||||
func (s *Server) leaveHoldem(w http.ResponseWriter, user string) {
|
||||
tableID, seat, err := storage.PlayerSeat(user)
|
||||
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: holdem leave seat", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var respErr error
|
||||
err = s.tableLocks.withTable(tableID, func() error {
|
||||
t, _, err := storage.LoadTable(tableID)
|
||||
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||
respErr = storage.ErrNoSuchTable
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var g holdem.State
|
||||
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
if g.Phase == holdem.PhaseBetting {
|
||||
respErr = holdem.ErrHandLive
|
||||
return nil
|
||||
}
|
||||
if err := s.settleLeave(t, g, seat, user); err != nil {
|
||||
if errors.Is(err, storage.ErrStaleTable) {
|
||||
respErr = storage.ErrStaleTable
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.publishTable(tableID)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("games: holdem leave", "user", user, "table", tableID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if respErr != nil {
|
||||
writeJSONStatus(w, moveStatus(respErr), map[string]string{"error": moveMessage(respErr)})
|
||||
return
|
||||
}
|
||||
s.writeHoldemTable(w, user, nil)
|
||||
}
|
||||
|
||||
// settleLeave vacates a seat, credits the stack home, and closes the table if
|
||||
// nobody human is left — all inside the caller's lock. The engine's Vacate turns
|
||||
// the chair back into the house's (its chips become house money, rebought like
|
||||
// any bot's), and storage.LeaveTable does the border crossing in one transaction
|
||||
// with the state write, so a crash can never pay a player and then leave their
|
||||
// seat sitting there to be cashed out again.
|
||||
func (s *Server) settleLeave(t storage.Table, g holdem.State, seat int, user string) error {
|
||||
home, err := g.Vacate(seat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.State, t.Phase, t.HandNo, t.Deadline = blob, string(g.Phase), int64(g.HandNo), 0
|
||||
if err := storage.LeaveTable(storage.Leave{
|
||||
Table: t, Seat: seat, MatrixUser: user, Bot: g.Seats[seat].Name, Amount: home,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := storage.CloseTable(t.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- the response ----------------------------------------------------------
|
||||
|
||||
// writeHoldemTable answers with the whole page state — the money and the table as
|
||||
// the player's own seat may see it — plus, when a move produced one, the redacted
|
||||
// event script for that seat to animate. A player who has just got up has no seat
|
||||
// and no table; the money view carries the leftover verdict and the felt clears.
|
||||
func (s *Server) writeHoldemTable(w http.ResponseWriter, user string, evs []holdem.Event) {
|
||||
v, err := s.table(user)
|
||||
if err != nil {
|
||||
slog.Error("games: holdem table", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(evs) > 0 {
|
||||
if _, seat, serr := storage.PlayerSeat(user); serr == nil {
|
||||
v.HoldemEvents = viewHoldemEvents(evs, seat)
|
||||
}
|
||||
}
|
||||
writeJSON(w, v)
|
||||
}
|
||||
230
internal/web/games_holdem_multiplayer_test.go
Normal file
230
internal/web/games_holdem_multiplayer_test.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
const bobPlayer = "@bob:parodia.dev"
|
||||
|
||||
// fundUser puts chips in front of a named player the way the border really does.
|
||||
func fundUser(t *testing.T, user string, chips int64) {
|
||||
t.Helper()
|
||||
e, err := storage.RequestBuyIn(user, chips)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := storage.ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := storage.SettleEscrow(e.GUID, true, "", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func chipsOf(t *testing.T, user string) int64 {
|
||||
t.Helper()
|
||||
st, err := storage.Chips(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st.Chips
|
||||
}
|
||||
|
||||
// A second person can sit down at a table somebody else opened, taking a chair a
|
||||
// bot was keeping warm — which is the whole point of the thing being multiplayer.
|
||||
func TestHoldemJoinTakesAnOpenSeat(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000) // reala
|
||||
fundUser(t, bobPlayer, 5000)
|
||||
|
||||
if _, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 2, "buyin": 500})); code != 200 {
|
||||
t.Fatalf("reala sit = %d, want 200", code)
|
||||
}
|
||||
tableID, err := storage.TableOf(testPlayer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
v, code := call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"table": tableID, "buyin": 500}))
|
||||
if code != 200 {
|
||||
t.Fatalf("bob join = %d, want 200", code)
|
||||
}
|
||||
if v.Chips != 4500 {
|
||||
t.Errorf("bob's chips after a 500 buy-in = %d, want 4500", v.Chips)
|
||||
}
|
||||
if v.Holdem == nil {
|
||||
t.Fatal("join returned no table")
|
||||
}
|
||||
|
||||
_, bobSeat, err := storage.PlayerSeat(bobPlayer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bobSeat == 0 {
|
||||
t.Errorf("bob took reala's seat %d", bobSeat)
|
||||
}
|
||||
if v.Holdem.YourSeat != bobSeat {
|
||||
t.Errorf("the view says bob is at seat %d, storage says %d", v.Holdem.YourSeat, bobSeat)
|
||||
}
|
||||
|
||||
_, seats, err := storage.LoadTable(tableID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
humans := 0
|
||||
for _, seat := range seats {
|
||||
if seat.MatrixUser != "" {
|
||||
humans++
|
||||
}
|
||||
}
|
||||
if humans != 2 {
|
||||
t.Errorf("two people at the table, storage says %d humans", humans)
|
||||
}
|
||||
}
|
||||
|
||||
// The felt tells each player "you bought in for X" — their own stake, not the
|
||||
// table's. The engine's BoughtIn is the sum across every human (the audit wants
|
||||
// that), so a two-human table would quote both of them the pair's total if the
|
||||
// view read it straight. It reads each seat's own staked row from storage instead.
|
||||
func TestBoughtInIsPerPlayerNotTheTableTotal(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000) // reala
|
||||
fundUser(t, bobPlayer, 5000)
|
||||
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
|
||||
tableID, _ := storage.TableOf(testPlayer)
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"table": tableID, "buyin": 500}))
|
||||
|
||||
for _, who := range []string{testPlayer, bobPlayer} {
|
||||
v, err := s.table(who)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Holdem == nil {
|
||||
t.Fatalf("%s has no table", who)
|
||||
}
|
||||
if v.Holdem.BoughtIn != 500 {
|
||||
t.Errorf("%s is shown bought-in %d, want their own 500 — not the table's 1000",
|
||||
who, v.Holdem.BoughtIn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Leaving a shared table gives you your stack and hands your chair back to the
|
||||
// house; the table stays open for the people still on it. Only the last human out
|
||||
// closes the felt.
|
||||
func TestHoldemLeavingSharedTableKeepsItOpen(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
fundUser(t, bobPlayer, 5000)
|
||||
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
|
||||
tableID, _ := storage.TableOf(testPlayer)
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"table": tableID, "buyin": 500}))
|
||||
|
||||
// Bob gets up. His 500 comes home and his chair goes back to a bot.
|
||||
if _, code := call(t, s, s.handleHoldemLeave, as(t, s, "bob", "POST", "/api/games/holdem/leave", nil)); code != 200 {
|
||||
t.Fatalf("bob leave = %d, want 200", code)
|
||||
}
|
||||
if got := chipsOf(t, bobPlayer); got != 5000 {
|
||||
t.Errorf("bob left with %d, want his 5000 back", got)
|
||||
}
|
||||
if _, _, err := storage.PlayerSeat(bobPlayer); err != storage.ErrNoLiveHand {
|
||||
t.Errorf("bob still holds a seat after leaving: %v", err)
|
||||
}
|
||||
if _, _, err := storage.LoadTable(tableID); err != nil {
|
||||
t.Errorf("the table closed under reala when bob left: %v", err)
|
||||
}
|
||||
|
||||
// Reala is the last one out, so the felt closes behind them.
|
||||
if _, code := call(t, s, s.handleHoldemLeave, as(t, s, "reala", "POST", "/api/games/holdem/leave", nil)); code != 200 {
|
||||
t.Fatalf("reala leave = %d, want 200", code)
|
||||
}
|
||||
if _, _, err := storage.LoadTable(tableID); err != storage.ErrNoSuchTable {
|
||||
t.Errorf("an empty table survived the last human: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A table everyone has walked away from is cashed out and closed by the reaper —
|
||||
// the chips inside a walked-away poker session are not left in limbo.
|
||||
func TestHoldemReaperCashesOutAbandonedTable(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
|
||||
tableID, _ := storage.TableOf(testPlayer)
|
||||
|
||||
// The seat has been away, and last acted for itself longer ago than the idle
|
||||
// cutoff — the state the reaper is meant to find.
|
||||
tbl, seats, err := storage.LoadTable(tableID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seats[0].Away = true
|
||||
seats[0].LastSeen = time.Now().Unix() - int64(storage.SessionIdleAfter.Seconds()) - 60
|
||||
if err := storage.CommitTable(storage.TableCommit{Table: tbl, Seats: []storage.Seat{seats[0]}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s.reapAbandonedTables()
|
||||
|
||||
if got := chipsOf(t, testPlayer); got != 5000 {
|
||||
t.Errorf("the reaper sent home %d, want the whole 5000 back (buy-in and all)", got)
|
||||
}
|
||||
if _, _, err := storage.LoadTable(tableID); err != storage.ErrNoSuchTable {
|
||||
t.Errorf("the reaper left the table standing: %v", err)
|
||||
}
|
||||
if _, err := storage.TableOf(testPlayer); err != storage.ErrNoLiveHand {
|
||||
t.Errorf("the reaper left the occupancy claim behind: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh table is not abandoned, and the reaper leaves it alone.
|
||||
func TestHoldemReaperSparesALiveTable(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
|
||||
tableID, _ := storage.TableOf(testPlayer)
|
||||
|
||||
s.reapAbandonedTables()
|
||||
|
||||
if _, _, err := storage.LoadTable(tableID); err != nil {
|
||||
t.Errorf("the reaper closed a table someone is sitting at: %v", err)
|
||||
}
|
||||
if got := chipsOf(t, testPlayer); got != 4500 {
|
||||
t.Errorf("chips = %d — the reaper should not have moved a live table's money", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The lobby lists a table with a seat going spare, and drops it once it is full.
|
||||
func TestHoldemLobbyListsJoinableTables(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
|
||||
// A one-bot table: two seats, one human, one open.
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 1, "buyin": 500}))
|
||||
|
||||
tables, err := storage.LobbyTables(gameHoldem, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tables) != 1 {
|
||||
t.Fatalf("lobby has %d tables, want 1", len(tables))
|
||||
}
|
||||
if tables[0].Humans != 1 || tables[0].Seats != 2 {
|
||||
t.Errorf("lobby table = %d/%d humans/seats, want 1/2", tables[0].Humans, tables[0].Seats)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user