105 Commits

Author SHA1 Message Date
prosolis
4189c03a82 mischief: tighten the detail page's live poll and drop dead state
The who-page poll now keeps the location line honest both ways (a mark
that came back to town stops showing its old expedition) and stops
polling once the adventurer leaves the board. Also collapses a redundant
branch in ResolveMischiefOrder and removes the always-false whoPage.Stale.
2026-07-14 23:12:01 -07:00
prosolis
16711e13e6 adventure: a click-through page for every adventurer on the board
Each roster name becomes /adventure/who/{token}. Anyone sees the public sheet —
stats and equipped gear, decoded from the detail_json gogobee now hangs on each
board entry — with a live JSON re-poll so an open tab tracks HP and room as they
move. The signed-in owner sees the same page enriched with their private
inventory, vault, house, and pets, unlocked by an ownership join in the new
player_self_detail table (localpart owns token) — Pete never reverses the
anonymous token to decide it. buyerLocalpart is extracted so the storefront and
the ownership check lowercase the session name the same way.
2026-07-14 22:22:52 -07:00
prosolis
2ac6ec6b91 mischief: a storefront where money buys a stranger some trouble
The web half of Mischief Makers M3. A signed-in buyer picks a mark off the
anonymous roster board and pays for a monster to find them; gogobee does the
real work and hands back a verdict Pete files against the order.

- mischief_orders: intent in, verdict out, idempotent on a guid that is the
  end-to-end key gogobee passes to DebitIdem and stamps on the contract
- user_euro + mischief_tiers: advisory balance and the live price list, pushed
  on the roster tick so the storefront never hardcodes a number that can drift
- OIDC-gated buy API (target + tier + signed), bearer-authed poll/claim wire
- roster board grows a 'send trouble' button, a tier picker, and a status panel

Pete never touches money and never runs a game rule. It records what a buyer
wants and what gogobee said happened.
2026-07-14 20:55:15 -07:00
prosolis
983748ea98 games: brighter marquee bulbs, a chase that loops, and no more subtitle
The Casino Night light strip was a dim blurred stripe that jumped every
cycle (it slid further than the bulb pattern repeats). Now it's a row of
round bulbs with a white-hot core and a real glow, and the chase travels
exactly one bulb-period so it loops seamlessly. Dropped the 'Chips are
euros' line under the room name; the welcome card and house rules already
say it.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 20:26:48 -07:00
prosolis
e5af5326d5 games: stop tracking the holdem-train binary
It is a build output of cmd/holdem-train, not source. Ignore it.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 19:32:08 -07:00
prosolis
dbde827f75 games: the chat line that shows up once, not twice
Your own message came back twice — once from the POST that sent it, once
echoed over your own SSE stream — so the felt printed it on the rail twice.
Drop any chat id already seen; reset the seen-set when the log is cleared
(unseated, and on a full chat reload).

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 19:32:08 -07:00
prosolis
18049f6f59 games: the UNO felt other people can sit at, and the pot on the rail
Phase D frontend: uno.html and uno.js rewired from the solo bet-builder to the
session shape hold'em already ships. You sit with a buy-in (or join a table from
the lobby), ante into a pot each hand, deal when ready, and get up with what's in
front of you. Everything is keyed on your_seat now, not seat zero: the felt puts
your hand at the bottom whatever chair you took, and one EventSource per seat
refetches your own redacted view when the table changes. Chat runs along the rail.

The card rendering and the event-script animation are unchanged — a move still
plays back a whole lap of the table — but the money is simpler than solo: a pot
won moves your on-table stack, not your purse, so the chip bar only stirs at
sit-down and get-up. Page renders; the two-browser pass is still to come.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 18:44:10 -07:00
prosolis
927ed84163 games: UNO becomes a table you sit at, and the pot that pays whoever goes out first
Phase D backend: UNO is now a session like hold'em, not a single stake. You sit
with a buy-in stack, ante into a pot each hand, and leave with what's in front of
you. The engine lost its `You` constant and its measured multiples: ApplyMove
takes the acting seat, New takes a seat list, a Tier carries an ante instead of a
Base, and a hand settles by moving the pot to the winner (less rake, and never
when a bot takes it) rather than paying a multiple. A mercy kill puts a seat out
of the hand, not out of the game — the last one standing takes the pot.

The redaction moved to the web layer, where hold'em's already lives: the engine
now stamps every seat's hand onto its events, and viewUno/viewUnoEvents strip
everything that isn't the viewer's own. TestUnoViewNeverLeaksAnotherSeatsCards is
the wall. unoTable implements tableGame; /uno/{sit,move,leave,tables,stream,chat,
say} mirror hold'em, with stream/chat/say now shared game-agnostic handlers.

The frontend is not done: uno.js still calls the retired solo endpoint, so the
page renders but is not yet playable. All engine and web tests are green.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 18:37:51 -07:00
prosolis
f8b07d8e6c games: the buy-in and the rake each player sees are their own, not the table's
The two-browser pass found it: at a table two humans share, the felt quoted
each of them the pair's total. "Bought in for 200" to a player who put in 100,
and a session-rake line that climbed on a pot the other one won.

Both were table totals the view read straight off the engine — correct while a
table had one human, wrong the moment it had two. Fixed along the border it
already draws: bought_in is border accounting, so it comes from the viewer's own
game_seats.staked; session rake is a within-table event, so it rides a new
per-seat Seat.Paid beside the audit's table-total s.Paid.

And top-up never grew game_seats.staked, so the storage invariant drifted by
every top-up and the felt under-reported the buy-in — it does now.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 17:17:25 -07:00
prosolis
4ad96dcb5e games: the felt that knows which seat is yours, and the rail you can talk on
Phase C frontend: the hold'em felt runs on the shared-table runtime.

- holdem.js reads view.your_seat instead of assuming seat zero — every "you"
  test (layout, your cards, the burst on a pot you win, the verdict) is keyed on
  it now, so a joiner at seat 2 sees their own hand at the bottom.
- Leaving is its own endpoint, and a bust closes a solo table; play() animates a
  session-ending hand (the last showdown) before the felt clears.
- A live table: one EventSource per seated player. The server pushes a nudge on
  every table change and a chat line as it is said; a nudge refetches the player's
  own redacted view (a hole card must never ride a frame that fans to the table),
  and a frame that lands mid-animation is held until the script finishes.
- Chat on the felt (a _chat panel, messages only) and a lobby that lists tables
  with a seat going spare. Two-cookie dev rig (reala + bob), with the turn clock
  and reaper live under it.

Browser-confirmed for solo: sit renders your seat and the rail, a hand deals and
conserves to the chip (bought in 100, 100 in front), chat sends. The two-browser
multiplayer pass (join, live sync between windows, shared-table conservation) is
still owed before this deploys.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 16:49:27 -07:00
prosolis
5139385350 games: the poker table others can walk up to, and the one that empties when they leave
Phase C's handler cutover: hold'em now runs on the shared-table runtime instead
of the solo game_live_hands blob. Solo is just a table nobody else has joined.

- holdem implements tableGame (name/timeout/stacks). timeout auto-checks-or-folds
  a walked-away seat and marks it away; the audit is per-hand, with each pot's
  rake on the winner's row alone so HouseTake cannot 4x itself.
- New endpoints: sit opens a table (or joins an open bot seat), leave gets you up
  (LeaveTable + CloseTable behind the last human), plus tables (lobby), stream
  (SSE), chat and say. The move path loads the player's table, applies at their
  seat, commits under the version guard, and fans an SSE nudge.
- Engine grows Vacate/Occupy (a human leaving/joining between hands) and
  TableSeats (a named human + bots). The view carries your_seat, since a shared
  table has no seat-zero-is-you convention.
- Storage grows OpenSoloTable (stake+claim+create+seat in one tx), PlayerSeat,
  and the abandoned-table reaper (AbandonedTables/ReapTable) — the seated-player
  counterpart to the session reaper, since a walked-away stack is inside a blob
  the session reaper cannot see. upsertSeat preserves last_seen so an auto-fold
  never refreshes an away player's clock.

Not deployed, and the felt is not rewired yet: the frontend still assumes
seat zero is you, so this is browser-unverified. Solo sit/deal/play/leave and
two-human join/leave/reaper are covered by tests; the whole suite is green.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 16:37:49 -07:00
prosolis
5b381b03ff games: the poker engine learns there can be more than one of you
Phase C, the engine half: hold'em becomes multiway, and the redaction that was a
bug-in-one-handler becomes the security boundary the plan warned it would.

- const You is gone. A table is a list of seats and which are human is a per-seat
  property, not the fixed index zero. New(tier, []SeatConfig, ...) seats the ring;
  SoloSeats builds the old one-human-plus-bots shape the solo handler still opens.
- ApplyMove(state, seat, move) — seat identity enters the engine in exactly one
  place; every helper below already worked on indices. The advance loop stops at
  any human (not just seat 0), so one request plays the bots and hands control
  back at whichever person is next to act.
- deal() now emits every seat's hole cards. The engine cannot redact a stream it
  doesn't know the audience of, so it stops trying: the view layer builds each
  viewer's redacted copy. viewHoldem/viewHoldemEvents take a viewerSeat.
- Rake attributed to Paid whenever a *human* wins, not just seat 0 — real house
  income is rake off any player's pot, and bot pots are house-vs-house.
- Bust is per-seat: at a solo table it still ends the session (PhaseDone), at a
  shared one a busted human just goes Out and the table plays on.

Tests, three ways, all green:
- the solo suite unchanged as a regression guard (a test-local You=0 alias);
- TestMultiwayChipsAreConserved — 100 games, two humans at seats 0 and 2, chips
  counted after every move, proving the reshape actually plays;
- TestHoldemViewNeverLeaksAnotherSeatsCards — renders every seat's view and event
  stream at every street and greps for anyone else's cards. Mutation-tested: undo
  the redaction and it fails on the preflop deal.

No handlers rewired yet — the solo path still calls New(SoloSeats(...)) and renders
for seat 0, so nothing a player sees has changed. The table cutover is next.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 16:05:19 -07:00
prosolis
004fca3f25 games: the clock that plays for whoever walked away, and the guard that stops it playing twice
Phase B runtime: the turn clock, the session reaper the plan noticed nobody had
ever wired, and the game-agnostic seam the engines will plug into.

- tableGame interface + a games() registry keyed on storage name, so the clock,
  the reaper and (soon) the handlers never know whether they drive poker or UNO.
- The turn clock is the first goroutine in Pete to mutate game state. It obeys
  rule 1 (DueTables returns a plain slice — the rows are closed before any lock,
  or the scan would hold the one connection a locked write needs) and the version
  guard (act only if the table is still the version the scan saw). Tested against
  the exact double-move the plan warned of: a real move lands in the same tick the
  scan fired, bumps the version, and the clock steps aside instead of folding the
  next player who still had 25 seconds.
- PushDeadlines on boot shoves every live clock out by a grace period, so the
  first tick after a deploy doesn't auto-fold the whole room at once.
- ReapIdleSessions finally has a caller. A seated player is invisible to it —
  their chips are inside a table blob — so it only ever reaps loose idle chips.
- publishTable fans a minimal version-carrying nudge through the hub; the frame
  is seat-blind, so a hole card never rides a broadcast that reaches the table.

Clock wired into main.go behind gamesReady(). Still no engine implements
tableGame, so the registry is empty and nothing a player can see has changed.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 15:49:02 -07:00
prosolis
4b3e5fe4c5 games: the felt other people can sit at, and the version that settles the race
Phase B foundation for the multiplayer casino: the shared-table storage layer,
the SSE fan-out, and the lock that only ever pretends to be the authority.

- game_tables/game_seats/game_chat, plus a nullable table_id on game_live_hands
  so occupancy stays one row per player — the same primary key that stops a
  second solo hand stops a second seat. No second uniqueness domain, no split
  brain, no cash-out-to-zero while sitting on a pot.
- The money model the plan sketched turned out simpler than it drew: chips cross
  the border only at sit-down and get-up, so a hand settles by moving the pot
  *within* the state blob and credits nobody. That deletes the payout ledger
  the design called for — there is no money write to make idempotent, only a
  state write conditional on the version. A replayed settle affects zero rows.
- CommitTable/SitDown/LeaveTable each one transaction with the state write in it;
  the version column is the concurrency authority and the striped in-memory lock
  is only an optimisation over it, because a mutex does not survive a redeploy.
- The SSE hub is a dumb byte fan-out: non-blocking sends (a stalled phone must
  not hold the table lock and freeze the clock for the room) and never a DB
  touch after the first read (holding the one connection open bricks the app).
- DueTables/PushDeadlines for the turn clock to come; Chat keeps the hand_no it
  was said during, because at a money table collusion looks like chat.

Storage and hub tested, including the version race and the never-block publish.
No handlers wired yet, so nothing a player can see has changed.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 15:43:39 -07:00
prosolis
1f1a6cb6e8 games: the payout that survives the crash, and the note that lied twice
The settle was four autocommit statements — save, award, record, clear —
sequenced so a crash between any two of them cost the player as little as
possible. That reasoning holds for a game owned by one player, and the old
comment made it well. It does not survive a pot, which is what the tables are
about to become: pay the winner, die before the state write, and the hand still
reads as live, so it settles again and pays again. Chips minted from nothing,
and gogobee turns those into euros.

The obvious fix is a trap. Award is a bare Get().Exec, so wrapping the settle in
a transaction makes it wait for the connection the transaction is holding. Not
an error — a hung process, and since the news app shares the pool it goes too.

So storage.CommitHand does the lot in one Begin/Commit, with tx-taking award and
recordHand beside the public ones. addChips has done it this way since the escrow
ledger was written; this is only that pattern, applied where the money is.

Two things fell out. A deal landing on a taken seat used to be refused and *then*
refunded in a separate statement, so a crash in between took a stake for a game
that existed nowhere — no felt, no audit row, nothing to find. And the audit row
is now inside the settle, which means failing to write it rolls the payout back
rather than paying quietly and logging: the payout and the audit row are the same
fact, and a payout nobody can account for is worse than one that didn't happen.

TestTheSettleDoesNotDeadlockAgainstItsOwnConnection is a canary, and it has been
made to sing — put the bug back and it doesn't fail with a message, it hangs, and
the timeout is the message. Which is exactly what production would do. A canary
that has never sung is just a bird.

Nothing a player can see has changed: eight blackjack hands conserving to the
chip across win, lose and push (a natural is the sharp one — Fresh and Done in a
single CommitHand), a double-deal 409 that refunds and leaves the live game
alone, hangman, and a hold'em session that bought in for 200 and got up with 197.

Also: the plan's deploy note was stale for the second time, with the lesson from
the first time written directly underneath it. Everything is live and always was.
A hand-written record of what is deployed will rot. Ask the box.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 15:28:54 -07:00
prosolis
a5b7e41929 games: the card the dealer never turned over, and the bet that came back doubled
Bust every hand and the dealer doesn't draw, which is right, but it was also
not turning over: reveal is only emitted by dealerPlay, and busting out skips
the dealer entirely. The browser kept the hole card face down while the settled
state printed the dealer's whole total under it. Emit the reveal on that path.

And standing your bet back up after a reload read the hand's bet straight off
the settled state, which a double has already doubled. Reload, double 200, and
the next hand starts with 400 on the spot.

Plus: the share card was hand-writing a Content-Length that ServeContent
overwrites anyway, and serving a zero-byte 200 for a room with no card.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 14:22:26 -07:00
prosolis
57c445ff29 games: the plan catches up with the door and the split
The handover was still saying "only blackjack is live", which was wrong two
sessions ago and is wronger now. It records what is actually on the box (03524ae)
and what is waiting on it: the UNO redraw fix, the front door and share card, and
blackjack's split. The user has seen that list and is holding the deploy, so the
next session should ask rather than assume.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 13:55:56 -07:00
prosolis
6f34a89622 games: the hand that becomes two, and the bet that has to follow it
Blackjack has a split. It was the last rule missing from a game that has been
live for a week, and it is the only move in blackjack that takes chips out of
your stack *after* the cards are out — which is most of what there is to get
wrong about it.

So the state stops pretending. State.Player is gone; there is a slice of Hands,
each with its own cards, its own bet, its own outcome and its own payout, and an
Active index the player works left to right. Settle runs per hand and rakes per
hand: netting them against each other first would mean a player who won one and
lost one paid no rake at all, which is not a rake, it's a discount for
splitting. The web layer takes the second bet before the move and hands it
straight back if the engine refuses — the same shape double already used, except
double was staking st.Bet, the whole table's stake, which was the same number as
the hand's until today and is now emphatically not. DoubleCost/SplitCost are the
active hand's, and the felt would have found this by charging you 300 to double
the third hand of a split.

The rules that cost money if you guess them: split aces get one card each and no
say (a pair of aces is otherwise the best hand in the game, forever), 21 on a
split hand is twenty-one and not a natural (it does not pay 3:2 — the test that
pins this is the most expensive one in the file), same rank rather than same
value (a king and a queen are not a pair), four hands maximum, double after
split allowed, and if every hand busts the dealer does not turn over.

A live hand outlives a deploy, so State.UnmarshalJSON still reads the old blobs:
"player" with no "hands" becomes one hand holding the whole stake. Without it, a
player mid-hand at restart is a player whose cards vanished — which is not a
decode error, and would not have looked like one.

On the felt a hand is now a box with its own spot, and a split is a card lifting
out of one hand into a new one with a second stack of chips flying after it from
your pile. Verified in a browser against a real pair: chips 4738 -> 4638 on the
split, two hands played out, one push and one loss, "Down on the deal. -100",
4738 back. Three hands stack without collision at 390px. Settled hands come back
to full brightness — dimming means "not your turn", and when the deal is over
they are the thing you are reading.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 13:54:55 -07:00
prosolis
7ca1f7a030 games: the door you can see from outside, and the picture on it
We never had Open Graph on the casino, and adding meta tags would not have
fixed it. Every route was behind requirePlayer, so a link pasted into a chat
window got a 302 to sign-in and unfurled as whatever the auth screen said:
"parodia.dev", no image, no description. Tags on a page a stranger cannot
fetch are tags nobody reads. So the casino now has a front door — a real page,
served to anybody, that says what the place is and offers a way in. You still
can't play from it, and every table still bounces you to sign-in.

The share card is drawn in Go rather than checked in as a picture, because the
casino has two names on a clock and the card keeps the joke: paste the link in
daylight and you get Casinopolis on green felt, paste it after six and the neon
is on and it says Casino Night Zone. Same roomAt() rule as everywhere else,
except the clock that decides is the server's — an unfurl bot has no evening of
its own. Both cards are drawn once, at first ask, and kept.

Two things worth keeping from building it. color.RGBA is alpha-premultiplied,
and the lamp over the table wrote raw channels next to a low alpha, which is
not a dim glow but an invalid colour: image/draw ran it past 255 and wrapped
the hue, and the first card came out with a blue dome over a green stripe. If
a colour here ever comes out impossible, look for a missing premultiply. And
og:image has to be an absolute URL that actually resolves, which is two
different addresses depending on how you arrived: /og.png on the games host
(hostRouter puts the /games back on) and /games/og.png anywhere else. The dev
rig advertised the first while serving only the second. The test now reads the
URL off the page and goes and fetches it, on both hosts, because an og:image
that 404s is worth exactly as much as no og:image.

Fredoka is vendored (OFL) — the page can reach for a font over the network and
a server drawing a PNG cannot.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 13:30:52 -07:00
prosolis
39ed293f4f games: the word you owe the table, and the hand you were already holding
Three things, and the first one was a bug.

Your own hand didn't move until the lap ended. bump() keeps the bots'
fans honest and has always refused seat zero, and nothing else touched
yours — so a +4 landing on you at the top of a lap put four backs into
your hand and then nothing, and the cards themselves turned up seconds
later when the script finished and paint() finally ran. You spent the
whole lap looking at a hand you no longer held. The engine now stamps
your hand onto every event that changes it (Event.Hand, seat zero only,
which is the one hand the browser is already entitled to see) and the
table redraws as the cards land. Measured in the running app: 2 -> 3
cards at 414ms into a 1791ms lap.

You couldn't call UNO, and not because the button was missing: going
down to one card *was* the call. discard() fired the uno event by
itself, which made it a thing that happened to you rather than a thing
you did, and a rule nobody can fail is not a rule. So now you say it or
you don't (Move.Uno), and if you don't, every bot still in the game gets
one look at you before any of them plays — because a bot that has moved
on is a bot that has stopped watching your hand. 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*. No event, no
badge, no tell on the felt except the count beside its fan reading
"1 card". Catch it 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.

Which cards owe the call is the engine's answer, not a count of your
hand: No Mercy's "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 walks you into a catch it never warned you about.

And the room was silent. Every sound in here is *made* — an oscillator,
a burst of filtered noise, an envelope — the same bargain the weather
engine takes with its clouds. A card is a slap of noise through a
bandpass, a chip is two detuned sines with a knock on the front, a win
is four notes going up. No asset files, no round trips, and a sound can
be pitched and detuned per call instead of being the same wav three
hundred times. Hooked into the FX layer rather than into the games, so
every table that throws a chip or turns a card got it at once.

The multiples moved, and the test that exists to catch that caught it.
The naive strategy now calls UNO, because calling is a button and not a
strategy — what these tiers price is bad card play, not a player who
ignores the felt shouting at them — and on that footing the normal
tables come back to where they were (40.1 / 28.5 / 23.1). No Mercy Full
House did not: it was paying a *negative* house edge, which is the house
paying you to sit down. Re-priced 3.8 -> 3.5.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 13:15:11 -07:00
prosolis
a4666866a8 games: the casino is open, and the note that said it wasn't
All six games are deployed. The plan has been saying "only blackjack is live" for
two sessions and it was not true — hangman, solitaire, trivia and UNO had already
gone out, and hold'em was the only thing actually missing. One loop over the routes
said so in seconds: /games/holdem was a 404 on the live box while the other five
were a 302 to sign-in. Ask the server what it serves. The note was written by
whoever last deployed; the 404 was written by the thing that is running.

Also written down: the six-handed position bug (03524ae), the half of it that was
never at risk (the bots read InPosition, not Position — the money never moved), and
the rig trap that cost most of the session, which is that a live hand outlives the
script that dealt it and will happily lie to you about the blinds.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 11:49:13 -07:00
prosolis
03524aefbc games: the seat you sit in, and the seat you are left in
Six-handed, the felt printed CO on three seats at once. Position walked the
table with nextIn, which steps over folded seats, while the seat count it walked
against still included them — so every muck slid the anchors round and the
labels landed somewhere new. Folding the small blind relabelled it the cutoff.

The two walks are a pair and they are easy to confuse. nextIn asks who is still
in the betting; a fold takes you out of it. Position needs the other question —
who was dealt in — because where you sit is decided when the button moves and
does not change because somebody threw their hand away. So nextDealt, which
skips only the seats that are not in the hand at all, and a note at both of them
saying which is which.

The bots never read this. They use InPosition, which really does want the last
seat still live, and which is deliberately not this function. So the policy is
untouched and the money never moved — the only thing this ever broke was the
badge on the plate, which is precisely why nothing caught it.

TestPositionsDoNotMoveWhenSeatsFold deals six-handed, asserts the table prints
each of BTN/SB/BB/UTG/MP/CO exactly once, then folds the seats out from under it
one at a time and asserts nobody's label moves.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 11:42:18 -07:00
prosolis
8db8845feb games: no mercy on the felt, and the bill that went to the wrong window
The engine has been able to play No Mercy since aca523e. Now a browser can.

The switch is a switch, not a fourth table: the tier is still the table size,
because that is what you are paid for, and the deck is the other dial. Six faces
the normal box does not print, sized by the card's own vars and never by the box
they sit in. The stack says what the bill is on the felt, in the turn line and on
the button, and under it the deck is dead — you cannot draw your way out of a
bill somebody has run up and pointed at you.

The wild draws glow. That started as decoration and turned out to be doing work:
No Mercy prints a coloured +4 right beside the wild one, and in a hand of twenty
the glow is what tells them apart.

A buried seat is not an empty one, which is the whole trap here — a seat killed
at twenty-five holds no cards, and neither does a seat that just went out and
won. The view asks the engine which it is instead of counting to zero, so the
winner is never the corpse.

Two bugs, both found in a browser and neither findable anywhere else:

The felt's stack bill was writing into the chip bar. It was [data-pending], and
so is the bar's "your chips are still coming" readout — and the bar lives inside
the table's own root and comes first in the document. A stack quietly overwrote
the escrow message and never appeared on the felt at all. A table's attributes
are not a private namespace.

And hold'em, re-driven on the 20M-hand policy (six hands, got up 61 ahead of a
100 buy-in, money conserved to the chip — Phase 4 closed), let you click a button
that did nothing: Deal, Leave and Top up stayed alive through the whole deal
animation, where send() drops the click on purpose. The lock is on the buttons
now, not only in the variable.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 11:10:07 -07:00
prosolis
aca523e511 games: no mercy, and the multiples nobody re-measured
No Mercy UNO as a rules dial on the existing tier, not a fourth table: 168 cards,
draw-until-playable, draw-stacking, and the twenty-five card mercy kill. Six
tiers now; a normal game never runs a line of the new code.

The engine is the whole of it so far — the felt hasn't been touched, so there is
no way to play this in a browser yet.

Two things worth knowing.

The normal tiers were mispriced, and had been for a while. They were set against
a naive win rate of 43/32/27%; it now measures 40.3/29.2/23.3%. The bots got
better at some point after the multiples were written down and nobody re-ran the
measurement — which the plan explicitly warns about, because the bots and the
tiers are a pair. Table and Full House had been charging an 18–19% house edge
instead of the 8% they were meant to. All six tiers are repriced off a fresh
measurement, and TestTheMultiplesAreStillPriced now fails the build if they
drift again. It is the test the normal tiers never had, which is how they drifted.

And No Mercy is *easier* than UNO, at every table size, so it pays less. The
mercy rule does not care whose hand hits twenty-five: it kills bots too, and
every bot it buries is one fewer seat that can beat you to the last card. A deck
built to be merciless turns out to be merciless mostly to the table.

The rake test used to assert a payout of 214, which was the 2.2x duel written
down as a number. It failed on a rake that was entirely correct. It derives the
arithmetic from the tier now: the rule is that the house takes its cut of the
profit and never touches the stake, and that holds at any multiple.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 10:07:55 -07:00
prosolis
4bc38859d4 games: the bots come back from school
The 20M-hand policy the trainer was running on millenia, collected. 4,159 nodes,
which is barely more than the 300k-hand placeholder had — the info-set
abstraction is coarse, so what twenty million hands bought is better-converged
strategies at the same decision points, not a bigger tree. The heads-up hit rate
is 94% and chips still conserve across a hundred sessions of real hands.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 10:07:55 -07:00
prosolis
638e28263a games: leave the bots at school, and a note saying where they are
The policy on main is a 300k-hand placeholder — real, and the bots play a proper
game off it, but thin. A 20M-hand run is still going on millenia. Both the plan
and the memory now say exactly how to collect it, and how to start it again if
it is lost. Nothing in the code is waiting on it: the policy is a data file, and
a better one only makes the bots harder to beat.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 09:28:47 -07:00
prosolis
b96879d25c games: the short stack that could win money it never matched
A review pass, and it found the one that would have cost somebody real chips.

Side pots were only ever cut in runout() — the path taken when the betting
stops because nobody is left able to bet. But a hand reaches a showdown with an
all-in player in it and the betting having finished perfectly normally: a short
stack shoves, two players who still have chips behind call, and then keep
betting past them street after street to the river. Nothing was cut. One pot,
everybody eligible, and the short stack takes the lot — every chip the deep
players put in after they were already all-in, money that could never have been
lost to them. All-in for 100 against two players who each put in 500, and the
best hand collects 1,100 instead of the 300 it was playing for.

Chip conservation never saw it. The chips balance perfectly; they just land in
the wrong seat. And every browser session went through runout(), because a
player shoving is what ends the betting. It took reading the code.

Also from the review: play() dereferenced a table it had just been handed as
null, the top-up button offered chips the wallet could not cover, and the
trainer's ETA was sixty thousand hands optimistic on the first line it printed.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 09:16:52 -07:00
prosolis
903c5accdb games: the rake you pay, and the rake the table lifts
They are different numbers and the felt was quoting the wrong one. Every raked
pot has chips lifted off it, whoever wins — that has to stay true or the table
stops balancing. But the bots' chips are not real, so a pot a bot wins costs
you nothing, and the counter under your stack was climbing anyway while you sat
there folding.

Rake is now every chip off the table (so the chips conserve) and Paid is the
part that came out of a pot you won, which is the only part that is money and
the only part worth telling you about. A chop costs you half of it. The audit
log takes Paid too: the house's income is what it made off the player, not what
it lifted off a bot.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 09:11:14 -07:00
prosolis
e6c1bd3b54 games: the poker table opens, and the bots go back to school
Phase 4. Hold'em, and it's the only table in the casino that is a session
rather than a game: you buy in, play as many hands as you like, and leave with
what's in front of you. So the live row spans hands and chips cross the border
exactly twice. Everything in between is inside the engine.

The bots move inside ApplyMove, as UNO's do, which is what keeps poker off a
socket: shove all-in and the flop, turn, river, showdown and payout all come
back in one response, as a script the felt plays back.

The CFR policy the plan called "the single highest-value asset in either repo"
was never read. Not once, in the whole life of the game: the trainer wrote its
info-set keys under IP/OOP and the runtime looked them up under BTN/SB/BB, so
every lookup missed and fell silently through to a pot-odds heuristic. Nothing
looked broken, because a policy miss is not an error. And it was the wrong
policy anyway — ten big blinds deep, trained on a tree where a call always ends
the street, which is not poker. So the trainer is rewritten to play the real
engine through the real reducer, at every stack depth the table deals, and the
trainer and the table now build the key with the same function so they cannot
drift apart again. A test fails if the bots stop finding themselves in it.

Three money bugs, and the tests earned their keep. Chip conservation across a
hundred sessions caught an uncalled bet that minted chips. A var-init ordering
trap meant every card was identical, every showdown tied and every bot believed
it held exactly 50% equity. And the browser caught the rake being silently
zero — the tier said 5 meaning percent, the casino handed it 0.05 meaning a
fraction, and integer division took the house's cut down to nothing.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 09:08:59 -07:00
prosolis
6e20883e5d games: the table that couldn't end, and the lock that let go too early
A code review of the uno table found the stuck guard had never once fired.
It counted how many bots had passed in a row and wanted more of them than
there are seats — but the bot loop hands the turn back the moment it comes
round to you, so the count could never get there, and your own empty-handed
pass was never in it. A dead table just passed the turn round forever. That
is not an ugly ending, it's a game you cannot finish, and a game you cannot
finish is chips you cannot cash out. So it asks the real question now: is
there anything to draw, and is anyone holding a card that goes.

And the table let go of itself too early. busy came off when the request
landed, not when the script it came back with had finished playing — so for
the seconds a bot lap takes, you could click a card at a board the server
had already moved past. It comes off at the end now, like the other tables.

Also: left: 0 was being dropped on its way out the door, which is the one
number that matters (the seat that just went out), the deck counter didn't
come back after a reshuffle, and hoisting fly() into flyNode() had quietly
flattened the chip arc on every other table in the room.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 07:50:52 -07:00
prosolis
d7e63d86a6 games: the uno table opens its doors
Driven in a browser for the first time, which is where three bugs were.

Every visit to /games/uno was a 500: the page was never added to the list
server.go parses into the games template set, so render() answered "unknown
page". The casino tests all call their handlers directly and never go through
render(), so nothing saw it. TestEveryCasinoPageRenders now walks the mux and
asks for every page the casino routes to.

The play script hid the first card that lit up rather than the one you clicked,
so playing any other playable card made an innocent card vanish. And on a phone
the discard sized its box but not its card, which takes its size from --uno-h,
so a full-size card hung out of a small hole and covered the colour in play.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 07:38:03 -07:00
prosolis
79c857023f games: a table of bots you have to beat to the last card
UNO, played for chips. You stake once, sit down against one to three bots,
and going out first pays the table: 2.2x heads up, 3.6x against a full house.
Anybody else going out first takes the stake. The table size is the tier,
because it is the only dial UNO has.

The bots move inside ApplyMove. A game with opponents is normally where you
reach for a socket, and the plan says solo UNO must not — so one request plays
your move and every bot turn behind it, and hands back the whole lap as a
script the felt plays in order.

The RNG is in the state rather than an argument to it: the bots choose and a
spent deck reshuffles, so the engine needs randomness mid-game, and there is no
generator alive across requests to pass in. The seed rides in the state and each
step derives its own. The game still replays exactly as it fell.

The zero value of Color is Wild, and that is the whole point of it: a wild
played with the colour field missing from the JSON must be refused, not
quietly played as a red one. It was red for an hour.

The browser never sees a bot's card — not the deck, not a hand, not the face of
a card a bot drew, which is most of the deck. Seats cross the wire as a name and
a count.

The multiples are measured, not guessed: playing the first legal card you hold
wins 43/32/27% of the time against these bots, so the tiers price that to lose
about 8% a game and leave good play worth roughly the house's edge.

PeteFX.flyNode is the throw with the chip taken out of it, so a card can be
thrown across the felt the same way. fly() is now that with a chip in it.

Not yet driven in a browser, which in this room means not yet finished.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 07:07:17 -07:00
prosolis
3e9b93af55 games: the clock beats the walk button, and the rack isn't betting
The trivia ladder handled a walk before it looked at the clock, so the
timeout only ever bit if the browser volunteered it. Sit on a question,
look it up, answer if you find it and walk if you don't, and you never
lose a ladder. The clock is now the first thing that happens to a move.

The house's chip rack was wired up as bet buttons on blackjack and
hangman: it's four spans with data-chip on them and nothing said the
handler only wanted the real ones. Clicking the house's money raised
your bet.

Hangman had two definitions of "a letter you'd guess" — unicode in the
engine, ASCII in the renderer — and a phrase with an accent in it would
have had no tile to fill and no key to fill it with. One definition now.

Plus: trivia's countdown no longer freezes at zero when the server turns
down a timeout report it was early for, questions whose wrong answer
decodes into the right one are dropped at the door, and hangman bets on
PeteFX's spot like every other table instead of its own copy of it.
2026-07-14 06:28:38 -07:00
prosolis
2d653bf439 games: the ladder gets played, and the rack learns where to stand
Trivia had every Go test passing and had never been in a browser, which
this plan's own rule says means nothing. So: play it.

The game itself holds up. The clock drains honestly and does not restart
on a reload, the multiple compounds, walking pays exactly what the felt
quoted, the reveal marks the right answer, and the auto-submit at zero
lands as a timeout rather than an illegal move. The next question's
answer never crosses the wire.

Two bugs only the browser could show:

- The spot printed double the stake after every settled game. standing()
  set spot.amount and *then* poured the chips on, and pour grows the pile
  from what it is told is already there. The money was always right; the
  number under the chips was not, which is the one rule the felt is built
  on.

- The house rack sat on top of the multiplier at 390px. Its 5.75rem inset
  is not a margin, it is the width of blackjack's shoe — so on a phone the
  rack sits in the middle of the felt. It now shrinks on small screens and
  pulls into the corner where the corner is empty; data-at says which rack
  is which, because pulling blackjack's to the edge slides it under the
  deck.

The dev rig seeds its own question bank now (one real OpenTDB batch per
difficulty), because a fresh dev database 503s every start otherwise.
2026-07-14 02:33:28 -07:00
prosolis
c62d736223 games: a ladder you climb against the clock 2026-07-14 02:11:09 -07:00
prosolis
feb353f789 games: the solitaire table gets played, and blackjack still pays 2026-07-14 01:57:03 -07:00
prosolis
5ca056bf20 games: you buy the deck, and win it back a card at a time
Solitaire, Vegas rules — the only shape solitaire has ever had as a
gambling game. You don't win or lose the deal: the stake buys the deck
outright, and every card you get home to a foundation pays a fifty-second
of the tier's multiple back. Cash the board whenever you like and keep
what you've banked, so a board that has gone dead is a decision rather
than a wall. No undo: the stake is spent the moment the deck is bought,
and an undo would be a way to walk a losing board backwards until it wins.

Three deals, and the two dials are the whole difficulty of Klondike.
Patient draws one with unlimited passes and pays 1.4x, so it takes 38
cards home to get square. Vegas draws three, three times round, 2.2x,
square at 24. Cutthroat draws three and gives you one pass, 3.4x, square
at 16 — most of those boards never clear, and you're ahead long before
they would.

internal/games/klondike is the same pure reducer as the other two, and
Pays() is one function for the same reason hangman's is. Two fuzzers hold
the deck together: no sequence of moves can lose or duplicate a card, and
the board stays well-formed. They earned their keep immediately — the
first thing they caught was a recycle that reversed the waste. It flips as
a block, so the card drawn first comes out first, and reversing it would
have dealt a different game on every pass and quietly broken the seed in
the audit log.

The browser never sees the stock or a face-down card, which here is most
of the deck rather than blackjack's one hole card: a column sends how many
cards are under it, never which.

The table re-renders and animates the difference. Blackjack plays back a
script because a hand only ever grows at one end; solitaire moves runs
from anywhere to anywhere and an auto-finish moves eleven cards at once,
so a script of "append this card there" would be a second engine over here
and it would be the one that's wrong. Instead the board on screen is
always exactly the board the server says exists, and each card is played
from where it just was to where it now is. The events supply only what a
diff can't: where a newly-revealed card came from, and what the board is
worth.

The rules are mirrored in JS on purpose, and only to light up the columns
a held card can go to. Being shown where a card goes is the game teaching
you; being told no after you commit is the game scolding you. The server
still decides, and a disagreement snaps the board back to what it says.

Two things came out into the open rather than being copied, which is the
rule this room runs on: casino-cards.js (the deck — faces, pips, the flip)
and PeteFX.spot() (the pile of chips and the number under it, which now
owns the rule that the number is a readout of the pile). Blackjack uses
both.

Not yet driven in a browser.
2026-07-14 01:40:14 -07:00
prosolis
fe2195e85f games: a gallows you can bet on
Hangman, and it plays for chips — which the plan had down as a free game, on
the grounds that trivia has no euro coupling in gogobee. But a free game in a
casino reads as a demo, so it stakes like everything else.

The idea that makes it a casino game rather than hangman with a wager stapled
on: the gallows is the payout meter. A wrong guess draws a limb *and* takes a
tenth off what a win is worth, because those are the same event and showing
them as one is the entire reason to bet on this. Short phrases pay 2.6x (fewer
letters, less to go on), long ones 1.6x — the floor is 1x, so a win never hands
back less than the stake, and the rake still comes out of winnings only.

State.Pays() is the number the felt quotes and the number settle() lands on.
They were briefly two sums, and the table spent an afternoon advertising a
pre-rake payout it didn't honour.

Two things the storage layer had already decided for us, and one it hadn't:
game_live_hands is keyed on the player, so "one game at a time" holds across
games for free (a live hangman 409s a blackjack deal, stake intact). But
table() unmarshalled every live row as a blackjack hand, which does not fail on
a hangman row — it quietly yields an empty hand. It dispatches on the game now.

commit() is the settle path both games share, and casinoRoutes() the one route
list, since the dev rig wires its own mux and a second copy is a copy that stops
including the newest game.

Driven in a browser, win and loss: 200 at 2.34x paid 455 and the bar landed on
it; six wrong took the stake and no more; a reload mid-phrase brought back the
board, the limbs, the multiple, the spent keys and the chips on the spot. The
browser found the two bugs a Go test can't — a lives counter under the house
rack, and a word wrapping early because the rack's clearance was on the whole
column instead of the one row beside it.
2026-07-14 01:19:05 -07:00
prosolis
d29a311eff games: the doors open on games.parodia.dev 2026-07-14 00:49:16 -07:00
prosolis
6961f90634 games: the money moves
The table dealt cards but settled money by editing a number. So the felt got
the two things it was missing: a bet spot in front of you, and the house's rack
beside the shoe. Every chip is now always travelling between one of those and
the other.

You build a bet by throwing chips onto the spot — the chip you clicked is the
chip that flies. The stake sits there through the hand. The house pays out of
its rack into the spot, and the pile is then swept back to your stack. A loss
goes to the rack and does not come back.

Two rules hold it together. The number under the pile is a readout of the pile,
never the other way round: the bet starts at nothing rather than at a default
nobody put down, and a settled hand leaves your stake back up on the spot,
because otherwise the panel prints "your bet: 300" over an empty circle. And
the chip bar does not move until the chips that justify it have landed — a
counter that pays you before the dealer turns over is a counter that has told
you the ending.

casino-fx.js is the engine underneath: chips fly on an arc, out of a fixed
overlay so no container clips one crossing from a button to the felt. It knows
nothing about blackjack.

Also: cards land with weight and a degree or two of tilt, so a hand looks dealt
rather than typeset; the dealer takes a beat before drawing out; and a natural
gets confetti, which is the only thing in the room that does.

Driven in a real browser, which is the only way to review an animation — and
which is what caught the verdict pill rendering white on white in a dark room,
a chip rack sitting on top of the dealer, and Hit being offered over a table
that was still being paid out. devcasino_test.go is that harness, kept.
2026-07-14 00:33:49 -07:00
prosolis
b00da21a47 games: draw the card, don't type it
The last attempt built a card face out of text: a "♠" in a span for every
pip. At the size a card actually is, a suit character renders as a speck —
the shape is whatever font answered, it doesn't scale, and it can't be put
on the half-row a real pip layout needs. The result read worse than the
plain rank it replaced.

So each face is one SVG on a 100×140 field, suits as vector shapes, pips at
the coordinates a printed deck puts them. Courts get a framed panel with the
suit above the letter and again below it upside down — mirroring a letter,
which is what the first pass did, just stacks two of them into a blob; a real
court mirrors a figure.

Also restores .pete-card-back, which went out with the text rules it was
sitting among: without it a face-down card had no back at all, so the
dealer's hole card was invisible on the felt. Caught by driving a hand.
2026-07-13 23:49:17 -07:00
prosolis
8ec13eab5b games: the casino moves out, and gets a clock of its own
The tables were living in the news app's shell: Pete's face in the header
and the footer, the channel nav, search, the reader, the weather canvas,
the PWA. A casino is not a news page with a felt on it.

So it gets its own layout. What carries over is the design language — the
four palette vars, Fredoka/Nunito, the fat rounded cards, the dropped
shadow. What doesn't is every control it has no use for. gamesPage stops
embedding the news pageData, which is what keeps the furniture from
drifting back one convenient field at a time.

It keeps a clock, but tells a different joke with it: Casinopolis by day,
Casino Night Zone from six, palette and felt and the sign over the door all
changing together. The rule lives in roomAt() for the first paint and again
in the browser, so a player abroad gets their own evening.

And the cards are cards now — corner indices in both corners, the bottom
one upside down as printed, pips on the three-by-seven grid every real deck
has used for four hundred years, courts as a letter with the suit over each
shoulder. Driven in a real browser, both rooms, dealt through to a payout.
2026-07-13 23:40:33 -07:00
prosolis
c69fbb63db games: a blackjack table you can actually sit down at
The engine, the escrow and the wire were all in place; nothing had a browser on
the end of it. This is that end: a lobby, a table, and the five endpoints between
them.

The browser holds no game. It sends intents and gets back a view — the cards it
is entitled to see, and the script of how they arrived, one event per card off
the shoe. The dealer's hole card is not in the payload at all until the reveal,
because a field the client is told to ignore is a field somebody reads in
devtools. The shoe lives in game_live_hands, which also means a redeploy
mid-hand no longer costs a player their stake: the hand is still there when they
come back.

The money is ordered so nothing can be spent twice. The stake leaves the stack in
the same statement that checks it exists, before a card is dealt. Every new hand
is seated with a plain INSERT, so a double-clicked Deal is decided by the primary
key rather than by a read that raced — it loses, gets its chips back, and the
hand in progress is untouched. A double takes its raise up front and hands it
straight back if the engine refuses the move.

Cards are dealt rather than swapped in — they fly out of the shoe and turn over,
which was a requirement and not a flourish. The faces and the chips are still
plain; that's next.
2026-07-13 23:20:42 -07:00
prosolis
cb84e1d549 games: a news session that travels to the games box
preferred_username was being read from the ID token and thrown away after
serving as a display-name fallback. It is the whole identity story: MAS imports
it as the Matrix localpart, so it is also who the player is in the euro economy.
Keep it in the session, and derive @user:server from it.

The session cookie was host-only, so a sign-in on news never reached games.
Widen it with an opt-in web.auth.cookie_domain — but only the session cookie:
the OAuth round-trip cookie pairs with a redirect back to the host that started
the login and stays where it was set. And because the redirect must return to
that host, the redirect_uri is now derived per-request for hosts inside the
cookie domain, with the configured URL as the fallback for anything else — a
Host header we don't own is never echoed into a redirect.
2026-07-13 23:04:09 -07:00
prosolis
44613c4760 games: the wire the euros cross
Three bearer-authed endpoints and gogobee can work the border: poll what's
waiting, claim a row, report what happened to the money. The storage layer
underneath was already done; this is the transport, and deliberately nothing
more.

All three are idempotent, because the thing on the other end of them is a
retrying queue and the thing they move is money. A verdict delivered three
times creates chips once. A rejected buy-in moves nothing and clears the
pending amount so it stops eating the table cap. A cash-out gogobee couldn't
pay gives the chips back rather than vanishing them from both sides.

A verdict for a row Pete has never heard of is a 400, not a shrug: gogobee has
by then moved real euros against it, and no amount of retrying invents the
missing row. Under the contract the adventure seam set, a 400 parks it in
gogobee's queue where a human can find it.
2026-07-13 23:00:19 -07:00
prosolis
a442cfccaa games: the plan learns what we decided and what we built 2026-07-13 22:49:26 -07:00
prosolis
f9a98f72a6 games: the euro/chip border, and the ledger that keeps it honest
A euro is either in gogobee's balances or in Pete's chip escrow, never both. It
crosses only via a game_escrow row whose guid is the same idempotency key gogobee
hands to DebitIdem/CreditIdem, so a claim whose ack is lost on the wire can be
retried without the player paying twice.

The border exists because gogobee has no inbound API and isn't getting one, so it
polls. A bet that round-tripped through a poll loop would take seconds to be
dealt. Instead the loop runs twice per session — buy in, cash out — and every hand
between them plays against chips held here, with no economy call in the hot path.

Two rules do most of the work. Chips appear only when gogobee confirms it took the
euros, so a buy-in can't mint money out of a pending request. Chips are destroyed
the moment a cash-out opens, so a player can't bet chips whose euros are already
in flight — and if the credit fails, they come back rather than evaporating.

Also: the €10k table cap counts in-flight buy-ins, so it can't be cleared by
firing several at once; a reaper cashes out anyone idle for 30 minutes, because
chips in an abandoned session are euros in limbo; and every hand is logged with
its seed, so a disputed hand gets answered with a re-deal instead of an apology.
2026-07-13 22:48:55 -07:00
prosolis
8310b30439 games: a deck you can seed and a blackjack you can replay
The first two pieces of games.parodia.dev, both pure: no HTTP, no timers, no
euros, nothing that knows a player's name.

cards/ is the shared deck gogobee never had — blackjack carried its own, UNO
carried another, hold'em leaned on a third-party one. The RNG is threaded rather
than the package global, so a hand is reproducible from its seed. That's what
makes the engine testable, and what lets a disputed hand be dealt again exactly
as it fell.

blackjack/ is ApplyMove(state, move) -> (state, events, error), where an error
means the move was illegal and nothing else. gogobee's engine *was* the message
sender, so its errors meant "the send failed"; there was no seam to test against.
State is a plain value, so a hand survives a redeploy.

House terms match the Matrix table — six decks, 3:2, dealer hits soft 17 — plus a
5% rake. The rake comes off winnings only: a push returns the stake untouched and
a loss is never charged for the privilege.
2026-07-13 22:44:45 -07:00
prosolis
6ccd18452c css: rebuild — the stale-roster dimmer had no rule behind it
99574db added the live roster card to the adventure page but shipped the CSS
bundle it was built against, which is generated and checked in. Three utility
classes the new markup uses were never compiled: mb-3, divide-y, and opacity-60.

The last one isn't cosmetic. channel.html toggles opacity-60 from JS to dim the
card when the roster data has gone stale — that dimming IS the staleness signal,
and with no rule behind it a stale roster rendered identically to a live one.
Same failure as the ticker: the thing that's supposed to look wrong looks fine.

Pure `npm run build:css` output, byte-reproducible from the committed templates.
2026-07-13 20:05:16 -07:00
prosolis
e85ebe56f7 news: Pete learns to report a contract killing
gogobee's Mischief Makers ships four new event types — a hit going out, the
target walking away from it, the target not walking away, and the monster
turning up to an empty dungeon. Pete 400s an unknown event_type, gogobee retries
and then parks the bulletin forever, so this deploys first.

The anonymity is the story, not an implementation detail. A contract is
anonymous unless the buyer paid extra to sign it, so the lede reports the money
and pointedly not the name Pete doesn't have. A survival unseals the buyer
whether or not they signed — that exposure is the only brake on casual griefing,
and it's the one beat worth leading with. Being maimed buys you nothing: an
anonymous buyer stays anonymous when the contract lands.
2026-07-13 20:03:28 -07:00
prosolis
99574db3e9 news: the adventure page gets something that's actually happening
Every dispatch Pete publishes is an accomplishment — a death, a clear, a
milestone — and an accomplishment is a newspaper clipping the moment it lands.
No refresh interval fixes that. So the page never felt alive, and it never was
going to.

The board is the other kind of thing: state that is currently true. gogobee
pushes the whole roster, we replace ours with it, and it renders above the
clippings. An open tab re-polls so it keeps telling the truth.

Replace, never merge: anyone gogobee omits (opted out, no character) drops off
the public page. That omission IS the opt-out — a standing row showing class,
level and zone names the player anyway, so "an adventurer" would have been a fig
leaf.

The snapshot time lives in its own row, because an empty board is ambiguous:
nobody playing, or gogobee stopped talking to us. The page has to tell those
apart — one is a quiet realm, the other is a board that lies confidently, which
is worse than one that admits it lost the wire.

Also teaches Pete "departure", so a bored adventurer letting itself out is news.
2026-07-13 18:05:38 -07:00
prosolis
8cb5b38599 Adventure: stop signing my own posts
The source tag credits an outlet Pete is relaying (`ars technica`). On
his own reporting it rendered as a trailing `pete` under a message he
already sent - him signing his own name. Drop Source on adventure posts
and omit the tag line entirely when there's nothing to credit; RSS posts
keep theirs.
2026-07-12 22:01:12 -07:00
prosolis
a614077cff Adventure: Pete learns the word "retreat"
gogobee has started filing a `retreat` bulletin — an expedition that ended with
the player walking out alive. Pete had no case for it, and handleAdventureIngest
answers an unrecognized event_type with 400.

That failure is silent and total. gogobee's sender treats any non-2xx as a
failure, retries eight times over roughly two hours, and then parks the row for
good. A gogobee emitting `retreat` at a Pete who doesn't know the word would not
log an error anyone reads — it would just quietly drop every retreat the realm
ever files.

So Pete has to learn it BEFORE gogobee starts saying it. Deploy order matters
here, and it is Pete first.

The copy leads with everyone coming home, because that is the true and the kind
part: a retreat is a bad day, not a funeral. Bulletin tier — it rides the daily
digest and does not interrupt the room. Announcing every failed run live would
be a firehose, and an unkind one.

https://claude.ai/code/session_01B2MwktU4RgfWkar8HM3zZn
2026-07-12 10:33:53 -07:00
prosolis
82d1c6ebeb Adventure: gate live beats on [adventure], not posting.enabled
posting.enabled governs the RSS pipeline's Matrix chatter. Adventure facts
are push-based -- they arrive from gogobee at ingest, are never polled,
classified or paced, and are excluded from the round-robin rotation -- so
folding them under the same flag made 'news on the web only, adventure live
in the games room' inexpressible. Gate advPost on [adventure].enabled + a
configured channel instead; a nil poster still shuts off both the live beats
and the digest loop.
2026-07-11 08:25:29 -07:00
prosolis
10bcc78c51 Merge adventure-news: gogobee adventure news section 2026-07-11 08:07:58 -07:00
prosolis
8e0d6aff3e Adventure news: fix posting gates, digest counts, and section gating
- Don't post adventure beats when posting.enabled=false (PostNow ignores the flag)
- Keep the adventure channel out of the round-robin rotation so it can't steal bulletins from the digest
- no_push now retires a fact against the digest, not just the live post
- Default a missing occurred_at to now instead of the Unix epoch
- Keep protocol-relative image URLs on the guarded thumbnailer
- Make digest_hour=0 (midnight UTC) settable
- Quote the true window total in the digest, not the truncated slice
- Hide the Adventure section entirely when it's disabled
2026-07-11 08:07:40 -07:00
prosolis
9bf56cbb4e Adventure news: add zone_clear event type
gogobee now splits the realm's first-ever clear (zone_first, priority)
from a later repeat (zone_clear, bulletin) so a repeat is never filed as
a first-ever. Handle both:

- renderAdventure: zone_first keeps the "cleared for the very first
  time" headline; zone_clear gets the personal "{subject} clears {zone}"
  headline, sharing the lede. A tier fallback still handles a legacy
  zone_first that predates the split.
- advEventMeta: zone_clear -> "Zone cleared" (distinct from zone_first's
  "First clear"), so a repeat's permalink page and emblem aren't stamped
  "First clear".

The GUID contract is otherwise unchanged: prefix == event_type, so the
permalink label derives correctly. Test: TestRenderZoneTaxonomy.

Deploy this before the matching gogobee change — a zone_clear fact hits
an un-updated Pete as an unknown event_type (400).
2026-07-11 07:44:15 -07:00
prosolis
4c671fb410 Adventure section: ingest, permalink, digest, emblems, noindex
Pete's side of the Adventure news feed. Receives structured game-event
facts from gogobee, templates them in Pete's warm-reporter voice, and
publishes to a new /adventure section + live Matrix posts.

- adventure.go: bearer ingest + fact-guard + 13 event templates;
  /adventure/{guid} permalink (story.html); per-event SVG emblems at
  /adventure/art/{type}.svg (card image + og:image); NoPush suppresses
  the live Matrix post (cold-start backfill).
- adventure_digest.go: daily BULLETIN roundup at DigestHour (UTC);
  unposted-in-48h = bulletins; marks them digested; per-day ?digest= URL
  avoids canonical dedup.
- config AdventureConfig (enabled/ingest_token/channel/digest_hour);
  web.New takes the seam + a priority poster; started in main.
- adventure theme colors; thumbURL passes through local emblem paths;
  adventure pages are noindex (player-named; gap #5).

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-11 00:53:59 -07:00
prosolis
0a723418ff Redo moon and clouds as procedural shaders in the GL engine
The baked atlas sprites (blurred-circle clouds, gradient moon with flat
maria blobs) read as cheap. Clouds are now built per-instance in the
particle fragment shader from fbm density with a flattened base and lit
tops; the moon renders in the sky shader with sphere shading, emboss
relief, maria and a halo. Stars skip the moon disc since the sky pass
draws first.
2026-07-08 00:19:55 -07:00
prosolis
9b20040b49 Rebuild weather effects on WebGL2 with new variants and a live demo page
The background weather is now GPU-rendered: one instanced-quad draw call
over a baked sprite atlas plus a fullscreen sky shader (fog, Saharan
haze, aurora, sun rays, storm gloom and lightning flash). The old
Canvas2D renderer stays as weather-2d.js and kicks in automatically
when WebGL2 is missing; weather.js is now a thin controller that owns
the toggle, prefs and the PeteWeather API.

Effect upgrades: shared wind with gust pulses leans the whole scene
together, rain gets depth, splash pops and velocity-aligned streaks,
storms grow procedural branched lightning bolts, snow mixes soft motes
with spinning six-arm crystals, clouds drift in two parallax layers,
clear nights get a moon with maria, twinkling stars and the occasional
shooting star, blossoms and leaves tumble with a faked 3D flip.

New variants: haze (Saharan calima), wind (autumn gusts with streak
lines), hail (bouncing stones with drizzle) and aurora. The /weather
demo page switches variant, intensity and phase in place without a
reload and shows the active renderer plus an FPS meter.
2026-07-07 23:53:45 -07:00
prosolis
e91b423b1a Focus reader scroll region on open so keys scroll the story
The reader overlay opened without moving focus, so Arrow-Up/Down and
PageUp/Down scrolled the page behind it until the user clicked into the
text. Make the scroll container focusable (tabindex=-1) and focus it on
open, and drop its focus outline.
2026-07-07 23:14:05 -07:00
prosolis
dbcb459908 Add server-side Piper read-aloud with voice picker
Reader read-aloud now streams neural WAV audio from a new POST /api/tts
endpoint that shells out to Piper, instead of the browser's Web Speech
voice. Each paragraph is synthesized on demand with the next one
prefetched during playback, keeping the existing highlight/scroll sync.

Voices are configured under [web.tts] (piper binary + voices_dir + a
labelled voice list) and exposed to the client as window.PETE_TTS; the
reader gets a Voice selector in the Aa menu, persisted per-device. Still
a signed-in-only perk and gated on auth.
2026-07-07 23:00:27 -07:00
prosolis
fceeb12ad5 Fix reader prefs popover ignoring the hidden attribute
The .pete-reader-typemenu class sets display:flex, which outranks the
UA [hidden]{display:none} rule, so toggling typeMenu.hidden never hid
the popover. Add an explicit [hidden] guard.
2026-07-07 22:48:06 -07:00
prosolis
74aa578a2d Precompute content_chars to drop per-render body scans
The N-min-read chip derived reading time via LENGTH(content) over the
full article-body TEXT column on every listing render. LENGTH can't use
an index, so SQLite read each row's whole body per request on the hottest
path. Cache the character count in a content_chars column filled at insert
time (backfilled for existing rows), and point StoryContentLengths at it.
2026-07-07 22:41:41 -07:00
prosolis
8f9fcc45f3 Fix reader read-aloud races and add rows.Err checks
- reader.js: guard read-aloud against the loading placeholder (bodyReady)
  and invalidate stale speechSynthesis callbacks with a generation token
- storage: check rows.Err() after iterating story-view/content-length reads
- metrics: reuse placeholders() instead of duplicating the IN-clause builder
2026-07-07 22:35:09 -07:00
prosolis
616055a704 Add per-story views, trending, and reader reading-experience upgrades
Surface read counts and sharpen the reader:
- story_views table + RecordStoryView on /api/article (background,
  filter-guarded); "Popular this week" home rail via TrendingStories;
  read-count badge and reading-time chip decorated onto every listing
- reader: signed-in-only read-aloud (TTS), native share/copy, and an
  Aa typography popover (size/serif/sepia) persisted per device
- real alt text on card/reader/related/search images; time-of-day Pete
  greeting on the home hero
- harden exec() to skip (not panic) on a nil DB so background writes
  can't crash on a closed handle

Tests: story_views_test.go, trending_test.go. Suite green, CSS rebuilt.
2026-07-07 22:17:23 -07:00
prosolis
2ea5f7a6f7 Strip inline scripts from reader body extraction
Datawrapper embed resizers (and other inline scripts) were leaking into
reader mode as literal text. Two extraction paths were affected:

- parser.go extractContentText/extractLede stripped only script tags via
  htmlTagRe, leaving the JS body behind. This is the path that usually
  wins for feeds shipping full content:encoded (e.g. Politico).
- article.go goquery paths call .Text(), which concatenates script source.

Both now drop whole <script>/<style>/<noscript> elements before pulling
text. Paywall detection (JSON-LD) still runs before the goquery strip.
2026-07-07 21:33:35 -07:00
prosolis
ff3a0e87be Tidy mobile header into a two-tier layout
The account/logout button was a loose justify-between flex child alongside
all the utility icons, so on mobile it wrapped onto a row by itself. Split
the header into a brand bar (logo + account) and a controls bar (utilities +
search + nav) so the account is pinned top-right and never orphaned.
2026-07-07 18:35:38 -07:00
prosolis
77581ac152 Make source-health page public with a trimmed reader view
/status was admin-only (404 for everyone else). Serve it to all: a
reader view with per-feed live/idle/delayed status and last-update time,
while admins additionally get poll cadence, item/story counts, paywall
rates, posting times, and raw fetch errors. Error strings are stripped
server-side for non-admins so feed-specific workarounds and upstream URLs
never reach the public payload. Nav status link now shows for everyone.
2026-07-07 17:56:22 -07:00
prosolis
35850eaf73 Fix push-digest sentinel filter, watermark, and cleanups from review
- push digest queries now exclude _duplicate channel like every other
  visibility query (bookmarks list/count and NewClassifiedSince)
- advance push watermark to newest scanned story, not pass-start now, so
  stories arriving during a long send pass aren't re-counted next pass
- replace hand-rolled escapeHTMLText with stdlib html.EscapeString
- drop em-dashes from user-facing copy; bump PWA CACHE_VERSION so clients
  pick up the changed shell assets
2026-07-07 01:25:23 -07:00
prosolis
8863b75916 Fix push SSRF, cross-user unsub, and personalization edge cases
Code review of the personalization/feeds/PWA/push work surfaced ten
confirmed issues, now fixed:

- Web Push delivery bypassed the SSRF guard (unguarded default client);
  now routes through safehttp.NewClient with a hard timeout, and the
  subscribe handler validates the endpoint URL.
- Push unsubscribe deleted by endpoint with no owner check; added
  RemovePushSubscriptionForUser scoped to the signed-in user.
- Byte-slice body/content truncation could split a UTF-8 rune and break
  the RSS content:encoded XML; added a rune-safe truncateUTF8 helper.
- Digest sender could permanently starve a user who hid a high-volume
  source; step the watermark past a full hidden-source scan window.
- Service worker cached personalized HTML navigations into a shared
  cache (identity leak across PWA users); navigations are now
  network-only, CACHE_VERSION bumped to v2 to purge stale pages.
- Public /api/article leaked discarded/unclassified bodies; filter to
  classified, non-sentinel stories.
- runLocal never started the push sender; digests now fire in -local.
- Push client had no timeout, so one hung endpoint stalled all sends.
- Reader migration resurrected cross-device-cleared reads; gate it
  behind a one-time flag so the server stays authoritative.
- Bookmarks count didn't match the classified list filter.
2026-07-07 01:08:42 -07:00
prosolis
71f7050f41 Add personalization, outbound feeds, and PWA/push to the web UI
A multi-session build turning Pete's read-only web UI into something people
return to. Five phases, signed-in features keyed off the OIDC subject; anonymous
visitors keep the reverse-chron feed and localStorage-only state.

Phase 1 — per-user read + bookmark state: user_story_state table +
storage/userstate.go; auth-gated /api/read, /api/bookmark, /api/state and a
/bookmarks page; reader.js syncs state server-side for signed-in users. Also
hides the Matrix-posting UI when posting.enabled=false (web-only mode).

Phase 2 — outbound feeds: storage.ListForFeed + web/feed.go hand-build RSS 2.0
(content:encoded) and JSON Feed 1.1 (no new dep); /feed.xml, /feed.json and
per-channel variants; <link rel=alternate> discovery tags.

Phase 3 — "For you" + related: storage/rank.go scores recent unread candidates
by channel/source affinity + recency decay; RelatedStories via FTS5. ForYou rail
+ /for-you page; public /api/related feeds the reader's "You might also like".

Phase 4 — source-health dashboard: source_health table + storage/sourcehealth.go
(RecordPollResult, ListSourceHealth, SourceContentStats), written by the poller;
admin-gated /status page behind web.admin_subs.

Phase 5 — PWA + offline reader + Web Push: root-scoped manifest.webmanifest and
sw.js (app-shell precache, /api/article runtime cache for offline reading,
offline fallback, push/notificationclick handlers); PNG icons from pete.avif;
pwa.js registers the SW and drives a notifications toggle. Web Push adds
webpush-go, a [web.push] config block (pete -genvapid mints VAPID keys), a
push_subscriptions table, auth-gated subscribe/unsubscribe endpoints, and a
digest sender that pings each subscriber "N new stories" past their watermark,
honoring disabled-sources and pruning gone endpoints.

Tests added beside each new storage/web file; go test ./... and go vet clean.
2026-07-07 00:07:19 -07:00
prosolis
55aa167151 Add feed/reader mode with full-article capture at ingest
Reader mode presents the stories on a page one at a time in a focused
overlay, marking each read as it's shown. Left/right arrows (or the header
book button / `f`) page through them; read stories dim on the grid. Read
state is device-local in localStorage.

Backing this required actually capturing article bodies, which Pete wasn't
doing — it kept only the RSS <description> lede and discarded content:encoded:

- stories.content column (idempotent migration; old rows fall back to lede)
- parser keeps content:encoded as paragraph-preserving text
- article fetch already done for paywall detection now also returns its body,
  so ingest stores the richer of feed-content vs scraped body with no extra
  request (prefers the archive snapshot body for paywalled stories)
- GET /api/article?id= serves the stored text; card queries now select id and
  expose it as data-id for the reader

Tests cover content extraction, the storage round-trip, and the article
endpoint + card rendering end to end.
2026-07-06 22:46:14 -07:00
prosolis
410f8dda0a Add posting.enabled master switch to disable auto-posting to Matrix
Stories are still ingested, classified, and served to the web UI; only
automatic Matrix posting is gated. Command replies (!post, !petestats)
still work. Pointer-bool so an absent key defaults to posting-on.
2026-06-24 16:39:59 -07:00
prosolis
e65ffb1373 Add server-side web usage metrics with !petestats command
Track per-page/per-channel view counts and a privacy-preserving daily
unique-visitor estimate (salted IP+UA hash, salt rotated daily and never
persisted). No third-party analytics, no JS beacon. Surfaced via the
admin-gated !petestats Matrix command (named to avoid an existing !stats
bot in the rooms).
2026-06-22 01:54:34 -07:00
prosolis
aaf6e551a0 Add live air-quality (US EPA AQI) chip and forecast-card row
Reuses the saved weather location's lat/lon to fetch us_aqi from
Open-Meteo's air-quality API (no key), folded into the existing 2h
forecast cache (bumped v1->v2). AQI is best-effort: a failed fetch
never sinks the forecast. Shows as a header chip and a colored row
on the forecast card; both self-hide when there is no reading.
2026-06-22 01:14:16 -07:00
prosolis
4bdf9a7615 Add per-source user_agent override for WAF-gated feeds
The Portugal News moved its RSS behind AWS WAF, which 405s Pete's honest
bot UA (confirmed: bot UA 0/6, browser UA 6/6 from the server IP). Add an
optional per-source user_agent that falls back to the default bot UA, and
set The Portugal News to a browser UA. Not load-related — 30-min polls and
the IP isn't banned.
2026-06-21 16:40:05 -07:00
prosolis
d5d0656dba Harden security: SSRF guard on all outbound fetches, auth fixes
- Route image-validation, Matrix image-download, and wayback/archive.today
  fetches through safehttp so feed-controlled URLs can't reach loopback/
  RFC1918/cloud-metadata IPs (incl. via redirects).
- Cap feed body size with safehttp.LimitedBody (16 MiB) to prevent OOM.
- Fail closed if matrix.pickle_key is unset/<16 chars; drop the hardcoded
  "pete_pickle_key" default that silently weakened E2EE-at-rest.
- Gate !post behind a matrix.admins allowlist; empty = disabled (channel
  rooms are public, so empty must not mean anyone).
- Reject /\host open-redirect bypass in post-login safeNext.
- Allowlist http(s) schemes for the Matrix link href; escape JSON embedded
  in inline <script> (prefs/sources blobs).
2026-06-21 16:21:03 -07:00
prosolis
cbbedd9894 Add optional Authentik (OIDC) sign-in with server-side preference sync
Signed-in users get their preferences (hidden feeds, weather location,
weather toggle) stored server-side keyed by their OIDC subject and synced
across devices. Anonymous visitors keep using browser localStorage, so the
site stays public. First sign-in migrates existing localStorage prefs up.

- config: [web.auth] section (issuer, client_id/secret, redirect, session_secret)
- storage: user_preferences table + Get/PutUserPrefs
- web/auth: OIDC code flow, HMAC-signed session cookie, CSRF state + nonce
- web/prefs_api: GET/PUT /api/preferences (auth-gated, 64KB cap)
- frontend: prefs.js sync layer seeds localStorage from server, pushes on write
- header: sign-in / account control

OIDC discovery is non-fatal at boot: if Authentik is down, Pete serves
anonymously rather than refusing to start.
2026-06-21 15:44:53 -07:00
prosolis
1a43616248 Add local weather forecast with live-weather backgrounds
Visitors can save a postal code (international, via Zippopotam) to get the
local forecast from Open-Meteo — a header chip + a 5-day home-page card —
and the canvas background switches from the seasonal effect to live
conditions. Entirely client-side: no keys, no server logic. Geocode cached
permanently, forecast cached 2h. Celsius by default, Fahrenheit opt-in.

New canvas effects: clear (sun by day, shaded moon + stars at night),
clouds (blurred drifting sprites), snow, fog, and storm (rain + lightning).
Seasonal effects remain the no-location fallback.
2026-06-21 00:24:39 -07:00
prosolis
95f6e71933 Add LEGO channel and stock-recommendation feeds to Finance
LEGO channel (🧱, red theme) posts to Matrix with Brickset + The Brick Fan
feeds. Finance gains MarketBeat (analyst ratings/upgrades) and Seeking Alpha
(tier 2; expect paywall stamps).
2026-06-20 23:51:12 -07:00
prosolis
7b76f9ed23 Fix Matrix E2EE: stop double-login and make cross-signing bootstrap reachable
Two bugs prevented Pete's device from ever cross-signing itself:

1. Double login / split-brain: New() did a manual mx.Login() AND set
   ch.LoginAs, so cryptohelper.Init() logged in a second time on a fresh
   crypto store, minting a separate device. device.json recorded one device
   while the olm account belonged to another, and every cold start leaked an
   orphan device. Pete's outgoing events were attributed to a device whose
   keys no client could verify. Removed ch.LoginAs (auth is already handled by
   the manual login + isTokenValid re-login path).

2. Unreachable bootstrap: the reset gate used
   IsDeviceTrusted(mach.OwnIdentity()), but OwnIdentity() hard-codes
   Trust=TrustStateVerified, so it always returns true and the bootstrap/reset
   branch was never entered. Replaced with GetOwnVerificationStatus(), which
   actually checks whether the current device key is signed by our
   self-signing key.
2026-06-05 11:35:29 -07:00
prosolis
0b94ce7fe5 Add Finance channel (web-only) with markets/macro feeds
New web-only category mirroring the EU channel: shows in the web UI but
does not post to Matrix. Adds the channel entry, emerald (money green)
theme utilities, and two macro/finance feeds (Naked Capitalism, Wolf
Street) to the example config.
2026-06-01 17:05:16 -07:00
prosolis
8aceb259ca Add kids news channel; document per-source language filter
- New kids channel (/kids) with theme color, surfaced in the web UI
  and routed to by BBC Newsround, DOGO News, Science News Explores,
  and NASA for Students in the example config.
- README and config.example.toml now mention the optional
  per-source `language` filter added in the previous commit.
2026-05-26 23:06:17 -07:00
prosolis
fc4cab3ad6 Seek 5s into videos for thumbnail extraction
The 0.5s seek often landed on the initial keyframe or a fade-in,
producing blocky thumbnails. Aim ~5s in instead, with an ffprobe
midpoint fallback for clips shorter than that and a 2s → 0
retry chain if the seek still overshoots.
2026-05-26 23:03:31 -07:00
prosolis
3e29acaa23 Fix feed settings panel failing to open
btoa() throws InvalidCharacterError on non-Latin1 input, so feed
names with em-dashes ("The Guardian — World") killed render()
before the dialog's hidden class was removed and nothing visibly
happened on click. Use the loop index for the row id instead.
2026-05-26 23:03:23 -07:00
prosolis
78fc3ef811 Add per-source language filter
When a source sets language = "en", drop items whose per-item
language tag is present and doesn't prefix-match. Items without
a language tag pass through unchanged. Politico Europe is the
motivating case — same headlines appear in en, fr, and de.
2026-05-26 23:03:12 -07:00
prosolis
7d469cf8c5 Fix glued-together RSS ledes; parse per-item language
Two ingestion changes:

- extractLede replaced HTML tags with empty string, so adjacent
  block tags like </p><p> fused words across paragraphs. Replace
  tags with a space and collapse whitespace.
- Pull each item's <language> tag (or dc:language) into FeedItem
  so the poller can filter on it. Politico Europe publishes the
  same story in en / fr / de side-by-side and we want to keep
  only one language per source.
2026-05-26 23:03:00 -07:00
prosolis
59658e7ebb Remove stale pete_ava.png (replaced by pete.avif) 2026-05-26 17:18:24 -07:00
prosolis
b617d403b7 Exclude !post from the daily cap
Manual !post overrides were counted toward daily_cap_total, so a few
forced posts could starve the round-robin rotation for the rest of the
day. Tag forced rows in post_log and skip them in CountAllPostsInWindow
so the cap only meters the auto-rotation.
2026-05-26 17:15:53 -07:00
prosolis
a15025089d Add per-feed visibility settings panel
Visitors can hide individual feeds via a gear-icon panel in the header.
Preferences live in localStorage; the server ships the full source list
(name + channel) as window.PETE_SOURCES so the panel lists every feed,
grouped by channel, regardless of what's on the current page.
2026-05-26 17:15:46 -07:00
prosolis
92d700f2bb Refresh README: TOML config, new channels, ffmpeg, weather toggle 2026-05-25 20:23:00 -07:00
prosolis
f52f26ff8d Order stories by feed publish time, not ingest time 2026-05-25 18:49:47 -07:00
prosolis
58493006e1 Add star button to toggle weather animation 2026-05-25 18:06:23 -07:00
prosolis
04a3c41bed Fall back to ffmpeg when Go's JPEG decoder rejects a source
Go's stdlib image/jpeg refuses some valid-but-rare features such as
4:1:1 luma/chroma subsampling (ANN's CDN serves these). When the
in-process decode fails, route the bytes through ffmpeg the same way
we already do for video sources.
2026-05-25 16:27:32 -07:00
prosolis
b3002c8e12 Fix mobile horizontal scroll from overflowing channel nav
Channel pill row outgrew the viewport once more channels were added,
pushing the whole page sideways. Let the header wrap, scroll the nav
inside its own pill, and clip body overflow as a safety net.
2026-05-25 15:39:11 -07:00
prosolis
70e1dfc2b8 Extract frame for video thumbnails via ffmpeg
mp4/webm/mov/m4v/mkv URLs now route to a frame-extraction path with a
larger 64 MiB download cap, and ffmpeg pulls a single resized frame
that the existing avifenc step turns into the cached thumb. ffmpeg is
optional: if missing, we fall through like any other build failure
and the handler redirects to the source URL.
2026-05-25 14:43:18 -07:00
prosolis
83b1216541 Drop LWN subscriber-only articles instead of stamping paywalled
LWN's subscriber-only articles have no public version reachable by
archive snapshots or bypass UAs, so stamping them as paywalled produces
clicks that always dead-end. Detect the marker text and skip ingestion
entirely.
2026-05-25 14:31:08 -07:00
prosolis
dd99f2c7ab Fix paywall false positive on Anime News Network
ANN wraps article bodies in <div class="KonaBody"> with no semantic
<article>/<main> tags, so body-length extraction fell below the 500-char
threshold and the poller flagged stories as gated. Broaden the container
fallback to also try itemprop="articleBody", common content-container
classes, and ANN's KonaBody.
2026-05-25 13:12:50 -07:00
prosolis
9e5ba5aafc Fix SSRF, XSS, dedup, force-post, and DB hot-path issues
- Add internal/safehttp: hardened HTTP client (DNS-resolved dial guard
  blocking loopback/RFC1918/CGNAT/link-local, redirect re-validation,
  body-size cap) and rewire article/feed/thumb clients through it
- Cap goquery body at 5 MiB so a hostile origin can't OOM the process
- search.js: reject non-http(s) hrefs to block stored XSS via javascript:
- dedup: tracking-param key "CMP" was unreachable (lookup lowercases);
  fixed to "cmp" so CMP= is actually stripped from canonical URLs
- ForcePost: postItem now returns bool; on dedup-skip ForcePost returns
  false so !post falls back to DB lookup instead of silently consuming
- Bound reaction callbacks behind an 8-slot semaphore; drop overflow
- Add stories indexes on (channel, classified, seen_at DESC),
  (classified, seen_at DESC), and partial image_url to kill full scans
  in IsKnownImageURL and ORDER BY seen_at hot paths
- Surface FTS5 probe Scan error instead of swallowing it
2026-05-25 12:23:37 -07:00
prosolis
e428f37c9e Add music sources to example config 2026-05-25 12:08:57 -07:00
prosolis
b8dcaa2aa1 Fix paywall false positives + add og:image fallbacks
- Body extractor falls back to <article>/<main> container text when
  <p> extraction is sparse, catching <br>-separated bodies (Phoronix).
- Detect Cloudflare bot-block / JS-challenge pages on 403/503 and
  treat them as transport failures rather than paywalls (Brooklyn Vegan).
- og:image extractor falls back to img.wp-post-image and the first
  content <img> in <article>/<main>, with lazy-load placeholder
  handling via data-src / data-lazy-src (Hardcore Gaming 101).
- New -backfill-paywall flag re-checks paywalled=1 rows with the
  current logic, clearing false positives and filling missing thumbs.
2026-05-25 12:06:29 -07:00
prosolis
71dc6d77ab Add anime and foss channel themes
Two new web-only channels alongside EU: anime (🌸 sakura pink #ec5e8a)
and foss (🐧 amber #d97706). Both get the full bg/text/decoration/
border/glow theme classes.
2026-05-25 11:48:33 -07:00
prosolis
ec1f130ed5 Add full-text search, EU channel theme, and web-only sources
Search: new FTS5-backed SearchStories query, /search JSON endpoint,
client-side overlay (search.js) wired into the layout header. EU
channel gets its own theme color (#003399) across bg/text/border/glow
classes. Sources can now route to non-Matrix channels without
validation error (web-only mode); a warning still flags typos.
2026-05-25 11:23:52 -07:00
prosolis
509a0fc7a7 Harden paywall detection + render paywalled stamp on cards
Bypass-UA retry (Googlebot + Google referer) for soft paywalls, JSON-LD
gating scoped to Article-typed nodes, HTTP 402 treated as explicit
paywall, Wayback freshness filter (30d cap), archive.today as secondary
archive fallback, and transport failures no longer trigger snapshot
swaps. When gating is detected and no archive workaround succeeds, the
story is stored with paywalled=1 and the web card renders a diagonal
red rubber-stamp overlay so readers know the link is gated.
2026-05-25 11:23:22 -07:00
193 changed files with 47878 additions and 538 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ pete
config.yaml
config.toml
node_modules/
holdem-train

View File

@@ -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
View 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)
}

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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)
@@ -140,7 +318,10 @@ func (c *Config) validate() error {
return fmt.Errorf("sources[%d] (%s): direct_route is required for enabled sources", i, s.Name)
}
if _, ok := c.Matrix.Channels[s.DirectRoute]; !ok {
return fmt.Errorf("sources[%d] (%s): direct_route %q is not a configured matrix.channels key", i, s.Name, s.DirectRoute)
// Not a Matrix channel — treated as web-only (stories visible in the
// UI but never posted). Warn so typos still surface.
slog.Warn("source routes to non-Matrix channel (web-only)",
"source", s.Name, "direct_route", s.DirectRoute)
}
}
@@ -154,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
@@ -181,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

View File

@@ -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]
@@ -203,22 +212,42 @@ tier = 1
poll_interval_minutes = 20
enabled = true
`},
{"direct_route not in channels", `
{"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"
[[sources]]
name = "S"
feed_url = "https://e.com/rss"
tier = 1
poll_interval_minutes = 20
direct_route = "gaming"
[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"
`},
}
@@ -242,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]

View File

@@ -21,7 +21,7 @@ var trackingParams = map[string]struct{}{
"share": {},
"shared": {},
"cmpid": {},
"CMP": {},
"cmp": {},
"icid": {},
"ito": {},
"yclid": {},

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

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

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

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

View 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")
}
}

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

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

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

View 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?

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

Binary file not shown.

View 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 012, 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)) }

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

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

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

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

View 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")
}
}

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

View 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

File diff suppressed because it is too large Load Diff

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

View File

@@ -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 {
@@ -48,54 +58,205 @@ func resolveURL(base, ref string) string {
// as paywalled / gated, and the caller should fall back to an archive snapshot.
const PaywallBodyThreshold = 500
// googlebotUA is what many metered publishers grant first-click access to.
// Re-tried automatically when the default-UA fetch looks gated.
const googlebotUA = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
// ArticleMeta is what we can learn from fetching an article page directly.
type ArticleMeta struct {
ImageURL string // og:image or twitter:image, absolute URL
BodyChars int // length of extracted visible body text
Fetched bool // true if we got an HTTP 200 with HTML
Paywalled bool // true if the page explicitly declares gated access
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
// 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)
}
var articleClient = &http.Client{Timeout: 12 * time.Second}
// Gated reports whether the response carries a strong gating signal: an
// explicit paywall meta/JSON-LD declaration, an HTTP 402 Payment Required,
// or a 403 Forbidden after the bypass retry. A short body alone is not
// considered gating — that's a heuristic used by callers separately.
func (m ArticleMeta) Gated() bool {
if m.Paywalled {
return true
}
return m.Status == http.StatusPaymentRequired || m.Status == http.StatusForbidden
}
// FetchArticleMeta fetches an article URL and returns its og:image plus the
// length of the visible body text (concatenation of <p> tags under <article>
// or <main>, falling back to all <p> tags). Returns Fetched=false on any
// network/HTTP failure so callers can branch on accessibility.
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
// retries once with a Googlebot UA + Google referer — the combination most
// metered publishers grant first-click access to. Returns the best of the
// two attempts.
func FetchArticleMeta(articleURL string) ArticleMeta {
if articleURL == "" {
return ArticleMeta{}
}
first := fetchArticleMetaOnce(articleURL, userAgent, "")
if !shouldRetryAsBot(first) {
return first
}
second := fetchArticleMetaOnce(articleURL, googlebotUA, "https://www.google.com/")
return pickBetter(first, second)
}
// shouldRetryAsBot returns true when the first attempt looks gated or too
// thin to be the real article body.
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
}
if m.Fetched && m.BodyChars < PaywallBodyThreshold {
return true
}
return false
}
// pickBetter chooses the more useful of two fetch attempts: prefer the one
// that isn't gated, then the one with more body, then the one that fetched
// at all.
func pickBetter(a, b ArticleMeta) ArticleMeta {
aGated, bGated := a.Gated(), b.Gated()
if aGated != bGated {
if bGated {
return a
}
return b
}
if a.Fetched != b.Fetched {
if b.Fetched {
return b
}
return a
}
if b.BodyChars > a.BodyChars {
return b
}
return a
}
func fetchArticleMetaOnce(articleURL, ua, referer string) ArticleMeta {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", articleURL, nil)
if err != nil {
return ArticleMeta{}
return ArticleMeta{FetchError: true}
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("User-Agent", ua)
req.Header.Set("Accept", "text/html,application/xhtml+xml")
if referer != "" {
req.Header.Set("Referer", referer)
}
resp, err := articleClient.Do(req)
if err != nil {
return ArticleMeta{}
return ArticleMeta{FetchError: true}
}
defer resp.Body.Close()
meta := ArticleMeta{Status: resp.StatusCode}
// 402 is unambiguous gating; 403 often is too (e.g. NYT-style hard wall).
// 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 {
return ArticleMeta{}
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 ArticleMeta{}
return meta
}
return ArticleMeta{
ImageURL: extractOGImage(doc, articleURL),
BodyChars: extractBodyChars(doc),
Fetched: true,
Paywalled: detectPaywall(doc),
meta.Fetched = true
meta.ImageURL = extractOGImage(doc, articleURL)
// 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
@@ -121,9 +282,11 @@ func detectPaywall(doc *goquery.Document) bool {
return gated
}
// jsonLDDeclaresGated returns true if the JSON-LD payload contains
// "isAccessibleForFree": false anywhere in its (possibly nested or arrayed)
// structure. Publishers vary wildly in shape, so we walk generically.
// jsonLDDeclaresGated returns true if the JSON-LD payload contains an
// Article-typed object (Article, NewsArticle, Report, BlogPosting, etc.)
// with "isAccessibleForFree" set falsy. We restrict to Article types so
// that embedded related-content or breadcrumb markup doesn't flip a free
// article to gated.
func jsonLDDeclaresGated(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -136,21 +299,27 @@ func jsonLDDeclaresGated(raw string) bool {
return walkJSONLDForGated(v)
}
// articleSchemaTypes are the schema.org @type values we treat as "the main
// article" for paywall purposes.
var articleSchemaTypes = map[string]bool{
"article": true,
"newsarticle": true,
"report": true,
"reportagenewsarticle": true,
"blogposting": true,
"scholarlyarticle": true,
"techarticle": true,
"opinionnewsarticle": true,
"analysisnewsarticle": true,
"backgroundnewsarticle": true,
"reviewnewsarticle": true,
}
func walkJSONLDForGated(v any) bool {
switch x := v.(type) {
case map[string]any:
if raw, ok := x["isAccessibleForFree"]; ok {
switch r := raw.(type) {
case bool:
if !r {
return true
}
case string:
s := strings.ToLower(strings.TrimSpace(r))
if s == "false" || s == "no" {
return true
}
}
if isArticleType(x["@type"]) && declaresGated(x["isAccessibleForFree"]) {
return true
}
for _, vv := range x {
if walkJSONLDForGated(vv) {
@@ -167,6 +336,31 @@ func walkJSONLDForGated(v any) bool {
return false
}
func isArticleType(v any) bool {
switch t := v.(type) {
case string:
return articleSchemaTypes[strings.ToLower(strings.TrimSpace(t))]
case []any:
for _, e := range t {
if isArticleType(e) {
return true
}
}
}
return false
}
func declaresGated(v any) bool {
switch r := v.(type) {
case bool:
return !r
case string:
s := strings.ToLower(strings.TrimSpace(r))
return s == "false" || s == "no"
}
return false
}
// FetchOGImage is a thin wrapper around FetchArticleMeta kept for callers
// that only care about the image. Returns "" when not found.
func FetchOGImage(articleURL string) string {
@@ -203,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())
@@ -225,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 {
@@ -249,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
}

View File

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

View File

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

View File

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

View File

@@ -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&#39;s a new era&#8212;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 &amp; gas fell by &gt;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> &amp; 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)
}
}

View File

@@ -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
@@ -126,32 +146,65 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
continue
}
// Genuinely new story — fetch the article page once. We use it for:
// Genuinely new story — fetch the article page once (with an internal
// bypass-UA retry on gating signals). We use it for:
// 1. og:image fallback when the feed didn't give us one.
// 2. Paywall detection: visible body text below the threshold means
// the article is gated, so swap in a Wayback snapshot for both
// Pete (image) and the reader (posted link).
// 2. Paywall detection: explicit gating signals or a body too short
// to be a real article means we swap in an archive snapshot for
// both Pete (image) and the reader (posted link).
//
// Pure transport failures (FetchError) are NOT treated as gating —
// swapping in a stale snapshot for a transient blip is worse than
// posting the live link.
meta := FetchArticleMeta(originalURL)
paywalled := !meta.Fetched || meta.Paywalled || meta.BodyChars < PaywallBodyThreshold
if paywalled {
if snap := ResolveWayback(originalURL); snap != "" {
// 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.
// When a snapshot swap succeeds, the reader gets a readable page, so
// we don't stamp it.
paywalled := false
if gated {
workedAround := false
if snap := resolveArchive(originalURL); snap != "" {
snapMeta := FetchArticleMeta(snap)
if snapMeta.Fetched {
items[i].ArticleURL = snap
if items[i].ImageURL == "" && snapMeta.ImageURL != "" {
items[i].ImageURL = snapMeta.ImageURL
}
slog.Info("paywall detected, using wayback snapshot",
if snapMeta.BodyText != "" {
bodyText = snapMeta.BodyText
}
workedAround = true
slog.Info("paywall detected, using archive snapshot",
"guid", items[i].GUID, "original", originalURL,
"snapshot", snap, "body_chars", meta.BodyChars)
"snapshot", snap, "body_chars", meta.BodyChars,
"status", meta.Status)
} else {
slog.Debug("paywall detected but snapshot fetch failed",
"guid", items[i].GUID, "original", originalURL, "snapshot", snap)
}
} else {
slog.Debug("paywall detected but no wayback snapshot available",
"guid", items[i].GUID, "url", originalURL, "body_chars", meta.BodyChars)
slog.Debug("paywall detected but no archive snapshot available",
"guid", items[i].GUID, "url", originalURL,
"body_chars", meta.BodyChars, "status", meta.Status)
}
paywalled = !workedAround
} else if items[i].ImageURL == "" && meta.ImageURL != "" {
items[i].ImageURL = meta.ImageURL
slog.Debug("og:image fallback used",
@@ -162,17 +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,
SeenAt: time.Now().Unix(),
Paywalled: paywalled,
SeenAt: seenAt,
PublishedAt: publishedAt,
}); err != nil {
slog.Error("failed to insert story", "guid", items[i].GUID, "err", err)
continue
@@ -188,3 +259,13 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
return nil
}
// resolveArchive tries Wayback first (well-behaved availability API, fresh
// snapshots filtered) and falls back to archive.today, which often has
// captures Wayback doesn't — especially for hard-walled publishers.
func resolveArchive(articleURL string) string {
if snap := ResolveWayback(articleURL); snap != "" {
return snap
}
return ResolveArchiveToday(articleURL)
}

View File

@@ -5,10 +5,18 @@ import (
"encoding/json"
"net/http"
"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
// returned by the availability API can otherwise be years old.
const maxSnapshotAge = 30 * 24 * time.Hour
type waybackResp struct {
ArchivedSnapshots struct {
@@ -16,18 +24,23 @@ type waybackResp struct {
Available bool `json:"available"`
URL string `json:"url"`
Status string `json:"status"`
Timestamp string `json:"timestamp"` // YYYYMMDDhhmmss
} `json:"closest"`
} `json:"archived_snapshots"`
}
// ResolveWayback asks the Internet Archive's Wayback availability API for the
// closest snapshot of the given URL. Returns the snapshot URL (https) or ""
// if no snapshot exists or the request fails.
// ResolveWayback asks the Internet Archive's Wayback availability API for
// the closest snapshot of the given URL. Returns the snapshot URL (https)
// or "" if no snapshot exists, the request fails, or the closest snapshot
// is older than maxSnapshotAge.
func ResolveWayback(articleURL string) string {
if articleURL == "" {
return ""
}
api := "https://archive.org/wayback/available?url=" + url.QueryEscape(articleURL)
// Anchor the lookup to "now" so we get the freshest snapshot rather
// than whichever happens to be Wayback's default closest match.
api := "https://archive.org/wayback/available?url=" + url.QueryEscape(articleURL) +
"&timestamp=" + time.Now().UTC().Format("20060102150405")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -54,9 +67,78 @@ func ResolveWayback(articleURL string) string {
if !snap.Available || snap.Status != "200" || snap.URL == "" {
return ""
}
if !snapshotFreshEnough(snap.Timestamp) {
return ""
}
// Wayback sometimes returns http:// even when https is available.
if len(snap.URL) > 7 && snap.URL[:7] == "http://" {
if strings.HasPrefix(snap.URL, "http://") {
return "https://" + snap.URL[7:]
}
return snap.URL
}
func snapshotFreshEnough(ts string) bool {
if ts == "" {
return false
}
t, err := time.Parse("20060102150405", ts)
if err != nil {
return false
}
return time.Since(t) <= maxSnapshotAge
}
// 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
// `/newest/<url>` endpoint redirects (HTTP 302) to the most recent capture
// when one exists, or returns a non-redirect response otherwise. Returns
// "" on any failure or when no snapshot exists.
func ResolveArchiveToday(articleURL string) string {
if articleURL == "" {
return ""
}
api := "https://archive.ph/newest/" + articleURL
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", api, nil)
if err != nil {
return ""
}
req.Header.Set("User-Agent", userAgent)
resp, err := archiveTodayClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode < 300 || resp.StatusCode >= 400 {
return ""
}
loc := strings.TrimSpace(resp.Header.Get("Location"))
if loc == "" {
return ""
}
// archive.ph occasionally returns a relative Location; absolutize it.
if strings.HasPrefix(loc, "/") {
loc = "https://archive.ph" + loc
}
// Bounce back the input as Location means "no snapshot, here's the form"
// — distinguish a real capture URL (contains /YYYY/ or a short hash path).
if strings.Contains(loc, "://archive.ph/") && !strings.Contains(loc, "://archive.ph/newest/") {
return loc
}
return ""
}

View File

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

View File

@@ -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
View 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 &quot;Dune&quot;?"), 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))
}

View File

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

View File

@@ -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)
}()
}

View File

@@ -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(

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

View File

@@ -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)

View File

@@ -80,13 +80,34 @@ func runMigrations(d *sql.DB) error {
// we swallow that specifically.
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)
@@ -111,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
View 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
}

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

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

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

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

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

View 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
View 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()
}

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

View File

@@ -5,7 +5,9 @@ import (
"encoding/json"
"errors"
"log/slog"
"strings"
"time"
"unicode/utf8"
)
func nowUnix() int64 {
@@ -28,14 +30,42 @@ func InsertStory(s *Story) error {
if s.Classified {
classified = 1
}
paywalled := 0
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, 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, 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 {
@@ -102,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
@@ -113,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.
@@ -130,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
}
@@ -180,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 {
@@ -203,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 {
@@ -215,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.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
@@ -234,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.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)
@@ -246,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.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
@@ -261,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.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)
@@ -269,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.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
@@ -286,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.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
@@ -295,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
@@ -343,6 +542,67 @@ func SetRoundRobinState(lastChannel string, tickAt int64) {
lastChannel, tickAt)
}
// SearchStories runs an FTS5 query against headline + lede and returns the
// best-ranked classified stories (real channels only), newest-ish first via
// bm25. The user query is tokenized and each token becomes a prefix match;
// FTS5 special characters are stripped so user input cannot break syntax.
func SearchStories(query string, limit int) ([]Story, error) {
fts := buildFTSQuery(query)
if fts == "" {
return nil, nil
}
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,
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.classified = 1
AND s.channel IS NOT NULL
AND s.channel NOT IN ('_discarded', '_duplicate')
ORDER BY bm25(stories_fts) ASC, s.seen_at DESC
LIMIT ?`, fts, limit)
if err != nil {
return nil, err
}
defer rows.Close()
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 {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func buildFTSQuery(raw string) string {
var tokens []string
var cur strings.Builder
flush := func() {
if cur.Len() > 0 {
tokens = append(tokens, cur.String())
cur.Reset()
}
}
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()
for i, t := range tokens {
tokens[i] = "\"" + t + "\"*"
}
return strings.Join(tokens, " ")
}
// MarshalPlatforms converts a string slice to a JSON array string for storage.
func MarshalPlatforms(platforms []string) string {
if len(platforms) == 0 {

275
internal/storage/rank.go Normal file
View 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 ")
}

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

View File

@@ -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,
@@ -14,16 +16,127 @@ CREATE TABLE IF NOT EXISTS stories (
platforms TEXT,
channel TEXT,
classified INTEGER NOT NULL DEFAULT 0,
seen_at INTEGER NOT NULL
paywalled INTEGER NOT NULL DEFAULT 0,
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 (
@@ -42,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 = `

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

View 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()
}

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

View File

@@ -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)

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

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

View File

@@ -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
@@ -14,6 +15,8 @@ type Story struct {
Platforms string // JSON array e.g. '["nintendo-switch","multi"]'
Channel string
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)
}

View 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) + "?"
}

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

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

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

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

Binary file not shown.

View File

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

342
internal/web/auth.go Normal file
View 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
View 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])
}
}

View 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
View 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
View 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, "&lt;html&gt;") {
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
View 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
View 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)
}
}
}
}

View 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")
}
}

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

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

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

View 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