29 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
88 changed files with 18543 additions and 1519 deletions

1
.gitignore vendored
View File

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

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

1
go.mod
View File

@@ -18,6 +18,7 @@ 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

7
go.sum
View File

@@ -10,6 +10,8 @@ github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1
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=
@@ -31,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=
@@ -48,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=
@@ -58,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=
@@ -157,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

@@ -15,6 +15,7 @@
package blackjack
import (
"encoding/json"
"errors"
"math"
"math/rand/v2"
@@ -29,15 +30,21 @@ var (
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
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
)
@@ -72,37 +79,77 @@ type Rules struct {
// 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.
// — see settle for exactly what it touches.
func DefaultRules() Rules {
return Rules{Decks: 6, BlackjackPays: 1.5, DealerHitsSoft17: true, RakePct: 0.05}
}
// State is one hand of heads-up blackjack: one player, one dealer. Splitting
// isn't in v1, so there's exactly one player hand.
// 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
Player []cards.Card `json:"player"`
Dealer []cards.Card `json:"dealer"`
Bet int64 `json:"bet"` // chips at risk; doubles on a double-down
Doubled bool `json:"doubled"`
// 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"`
Outcome Outcome `json:"outcome"` // the deal as a whole; per-hand outcomes live on the hands
// Payout is what returns to the player's chip stack when the hand is done:
// stake plus winnings, net of rake. Zero on a loss. Rake is the house's cut,
// recorded so the ledger can account for every chip that left the table.
// 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" | "reveal" | "settle"
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"`
}
@@ -113,6 +160,7 @@ 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
@@ -161,36 +209,52 @@ func New(bet int64, r Rules, rng *rand.Rand) (State, []Event, error) {
deck := cards.NewDeck(r.Decks)
deck.Shuffle(rng)
s := State{Rules: r, Deck: deck, Bet: bet, Phase: PhasePlayer}
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.draw(&s.Player, "player_card", &evs); err != nil {
if err := s.hit(0, &evs); err != nil {
return State{}, nil, err
}
if err := s.draw(&s.Dealer, "dealer_card", &evs); err != nil {
if err := s.drawDealer(&evs); err != nil {
return State{}, nil, err
}
}
// A natural on either side ends it before the player ever acts.
if IsBlackjack(s.Player) || IsBlackjack(s.Dealer) {
if s.Hands[0].Natural() || IsBlackjack(s.Dealer) {
s.settle(&evs)
}
return s, evs, nil
}
// draw takes one card off the shoe onto the given hand and records the event.
// 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) draw(hand *[]cards.Card, kind string, evs *[]Event) error {
// 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
}
*hand = append(*hand, c)
s.Hands[i].Cards = append(s.Hands[i].Cards, c)
card := c
*evs = append(*evs, Event{Kind: kind, Card: &card})
*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
}
@@ -210,41 +274,127 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
if s.Phase != PhasePlayer {
return s, nil, ErrNotYourTurn
}
if m == Double && len(s.Player) != 2 {
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 != Hit && m != Stand && m != Double {
return s, nil, ErrUnknownMove
if m == Split && !s.CanSplit() {
return s, nil, ErrCantSplit
}
i := s.Active
evs := []Event{}
if m == Double {
s.Bet *= 2
s.Doubled = true
}
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
if m == Hit || m == Double {
if err := s.draw(&s.Player, "player_card", &evs); err != nil {
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 v, _ := HandValue(s.Player); v > 21 {
s.settle(&evs) // bust; the dealer never has to play
return s, evs, nil
if err := s.hit(i+1, &evs); err != nil {
return s, nil, err
}
if m == Hit {
return s, evs, nil // still the player's turn
// 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
}
// Stand, or a double that survived its card: the dealer draws out.
s.Phase = PhaseDealer
s.dealerPlay(&evs)
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) {
@@ -255,74 +405,104 @@ func (s *State) dealerPlay(evs *[]Event) {
if v >= 17 && !hitSoft17 {
break
}
if err := s.draw(&s.Dealer, "dealer_card", evs); err != nil {
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 the outcome and the payout, and is the only place chips are
// computed.
// 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) {
playerVal, _ := HandValue(s.Player)
dealerVal, _ := HandValue(s.Dealer)
playerBJ := IsBlackjack(s.Player)
dealerBJ := IsBlackjack(s.Dealer)
// profit is what the player wins on top of their stake. Negative means the
// stake is gone.
var profit int64
s.Payout, s.Rake = 0, 0
for i := range s.Hands {
h := &s.Hands[i]
playerVal, _ := h.Value()
switch {
case playerVal > 21:
s.Outcome = OutcomeBust
profit = -s.Bet
case playerBJ && dealerBJ:
s.Outcome = OutcomePush
case playerBJ:
s.Outcome = OutcomeBlackjack
profit = int64(math.Floor(float64(s.Bet) * s.Rules.BlackjackPays))
case dealerBJ:
s.Outcome = OutcomeLose
profit = -s.Bet
case dealerVal > 21:
s.Outcome = OutcomeDealerBust
profit = s.Bet
case playerVal > dealerVal:
s.Outcome = OutcomeWin
profit = s.Bet
case playerVal == dealerVal:
s.Outcome = OutcomePush
default:
s.Outcome = OutcomeLose
profit = -s.Bet
}
// profit is what this hand wins on top of its stake. Negative means the
// stake is gone.
var profit int64
if profit > 0 {
s.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct))
if s.Rake < 0 {
s.Rake = 0
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
}
profit -= s.Rake
}
if profit < 0 {
s.Payout = 0 // stake is lost; nothing comes back
} else {
s.Payout = s.Bet + profit
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)})
}
// Net is what the hand did to the player's chip stack: payout minus the stake
// they put up. Negative on a loss, zero on a push.
// 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
@@ -330,17 +510,85 @@ func (s State) Net() int64 {
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 {
return s.Phase == PhasePlayer && len(s.Player) == 2
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.Player = append([]cards.Card(nil), s.Player...)
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

@@ -60,7 +60,7 @@ func TestIsBlackjack_OnlyOnTwoCards(t *testing.T) {
// 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, Player: player, Dealer: dealer}
s := State{Rules: r, Bet: bet, Hands: []Hand{{Cards: player, Bet: bet}}, Dealer: dealer}
evs := []Event{}
s.settle(&evs)
if s.Phase != PhaseDone {
@@ -164,8 +164,8 @@ func TestNew_DealsFourCardsAndAskThePlayer(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(s.Player) != 2 || len(s.Dealer) != 2 {
t.Fatalf("dealt %d/%d cards, want 2/2", len(s.Player), len(s.Dealer))
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)
@@ -174,7 +174,7 @@ func TestNew_DealsFourCardsAndAskThePlayer(t *testing.T) {
t.Fatal("no deal event")
}
// Unless somebody was dealt a natural, it's the player's move.
if !IsBlackjack(s.Player) && !IsBlackjack(s.Dealer) && s.Phase != PhasePlayer {
if !IsBlackjack(s.Hands[0].Cards) && !IsBlackjack(s.Dealer) && s.Phase != PhasePlayer {
t.Fatalf("phase = %q, want %q", s.Phase, PhasePlayer)
}
}
@@ -195,7 +195,7 @@ func TestNew_NaturalSettlesImmediately(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if IsBlackjack(s.Player) {
if IsBlackjack(s.Hands[0].Cards) {
if s.Phase != PhaseDone {
t.Fatalf("seed %d: player has a natural but phase = %q", seed, s.Phase)
}
@@ -225,7 +225,7 @@ func TestApplyMove_HitUntilBustSettles(t *testing.T) {
if s.Phase != PhaseDone {
t.Fatal("hitting a dozen times never ended the hand")
}
if v, _ := HandValue(s.Player); v <= 21 {
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 {
@@ -285,11 +285,11 @@ func TestApplyMove_DoubleTakesOneCardThenStands(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !s.Doubled || s.Bet != 200 {
t.Fatalf("bet = %d doubled = %v, want 200/true", s.Bet, s.Doubled)
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.Player) != 3 {
t.Fatalf("player has %d cards after a double, want exactly 3", len(s.Player))
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")
@@ -378,9 +378,9 @@ func TestNew_IsReproducibleFromItsSeed(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if cards.Hand(a.Player) != cards.Hand(b.Player) || cards.Hand(a.Dealer) != cards.Hand(b.Dealer) {
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.Player), cards.Hand(a.Dealer), cards.Hand(b.Player), cards.Hand(b.Dealer))
cards.Hand(a.Hands[0].Cards), cards.Hand(a.Dealer), cards.Hand(b.Hands[0].Cards), cards.Hand(b.Dealer))
}
}
@@ -395,21 +395,21 @@ func TestApplyMove_DerivedStatesDoNotShareCards(t *testing.T) {
if s.Phase == PhaseDone {
t.Skip("dealt a natural")
}
before := cards.Hand(s.Player)
before := cards.Hand(s.Hands[0].Cards)
a, _, err := ApplyMove(s, Hit)
if err != nil {
t.Fatal(err)
}
aHand := cards.Hand(a.Player)
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.Player); got != aHand {
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.Player); got != before {
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,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

@@ -93,6 +93,64 @@ func botRank(hand []Card, topColor Color, playable []int, minOpponent int) []int
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.
@@ -115,17 +173,9 @@ func botColor(hand []Card, rng *rand.Rand) Color {
return best
}
// botNames deals the bots their names. Flavour, and load-bearing flavour: "Kiwi
// played a +4" is a table, "Bot 2 played a +4" is a test fixture.
func botNames(n int, rng *rand.Rand) []string {
pool := append([]string(nil), botPool...)
rng.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
if n > len(pool) {
n = len(pool)
}
return pool[:n]
}
// 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")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,19 +8,38 @@ import (
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 }
// deal starts a game, failing the test if it can't.
func deal(t *testing.T, tier Tier, bet int64, seed uint64) State {
// 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, evs, err := New(bet, tier, rake, seed, 0xC0FFEE)
s, _, err := New(tier, SoloSeats(tier, tier.Bots, 1000), rake, seed, 0xC0FFEE)
if err != nil {
t.Fatalf("New: %v", err)
}
if len(evs) != 1 || evs[0].Kind != EvDeal {
t.Fatalf("New should deal exactly one event, got %+v", evs)
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
}
@@ -38,9 +57,7 @@ func census(s State) map[Card]int {
m[c]++
}
for _, c := range s.Discard {
// A wild is stamped with the colour it was played as while it sits on the
// pile, so it counts as the wild it really is.
if c.Value == WildCard || c.Value == WildDrawFour {
if c.Value.Wild() {
c.Color = Wild
}
m[c]++
@@ -73,7 +90,7 @@ func TestNewDeckIsADeck(t *testing.T) {
}
func TestNewDeals(t *testing.T) {
s := deal(t, full(), 100, 7)
s := deal(t, full(), 7)
if len(s.Hands) != 4 {
t.Fatalf("full house is four seats, got %d", len(s.Hands))
}
@@ -82,21 +99,31 @@ func TestNewDeals(t *testing.T) {
t.Errorf("seat %d holds %d cards, want %d", i, len(h), HandSize)
}
}
if len(s.Bots) != 3 {
t.Fatalf("want three bot names, got %v", s.Bots)
bots := 0
for _, seat := range s.Seats {
if seat.Bot {
bots++
if seat.Name == "" {
t.Error("a bot seat has no name")
}
}
}
if s.Turn != You {
t.Errorf("you play first, turn is %d", s.Turn)
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 New.
// 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(), 50, seed)
s := deal(t, table(), seed)
if s.Top().Value.Action() {
t.Fatalf("seed %d opened on %v", seed, s.Top())
}
@@ -108,12 +135,9 @@ func TestOpeningCardIsANumber(t *testing.T) {
// ---- the rules ------------------------------------------------------------
// rig builds a state by hand, so a rule can be tested without hunting a seed
// that happens to deal it.
//
// The deck is the rest of the deck: every card not in a hand and not the one in
// play. So a rigged game still holds 108 cards, and the census invariant means
// something in these tests too.
// 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() {
@@ -134,30 +158,38 @@ func rig(hands [][]Card, top Card, color Color) State {
var deck []Card
for _, c := range NewDeck() {
key := c
if left[key] > 0 {
left[key]--
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(), Hands: hands, Discard: []Card{top}, Color: color,
Deck: deck, Dir: 1, Turn: You, Phase: PhasePlay,
Bet: 100, RakePct: rake, Seed1: 1, Seed2: 2,
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, Move{Kind: MovePlay, Index: 0}); err != ErrCantPlay {
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) {
// Same face, different colour: legal.
s := rig([][]Card{{{Blue, Nine}, {Red, Two}}, {{Green, Five}}}, Card{Red, Nine}, Red)
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
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)
}
@@ -171,22 +203,20 @@ func TestPlayMatchesFaceOrColor(t *testing.T) {
func TestWildNeedsAColor(t *testing.T) {
s := rig([][]Card{{{Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor {
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, Move{Kind: MovePlay, Index: 0, Color: Wild}); err != ErrNeedColor {
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, Move{Kind: MovePlay, Index: 0, Color: Green})
next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Green})
if err != nil {
t.Fatalf("play wild: %v", err)
}
// The bot moved after us, so the colour in play is whatever it left behind —
// what we can check is that the wild itself went down as green.
top := next.Discard
if len(top) < 2 {
t.Fatalf("expected the wild and the bot's card on the pile: %v", top)
@@ -197,10 +227,9 @@ func TestWildNamesTheColor(t *testing.T) {
}
func TestDrawTwoHitsTheNextSeat(t *testing.T) {
// Two seats, so the +2 lands on the bot and the turn comes straight back.
s := rig([][]Card{{{Red, DrawTwo}, {Red, One}}, {{Blue, Five}, {Blue, Six}}}, Card{Red, Nine}, Red)
s.Tier = duel()
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play +2: %v", err)
}
@@ -221,7 +250,7 @@ func TestDrawTwoHitsTheNextSeat(t *testing.T) {
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, Move{Kind: MovePlay, Index: 0})
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play reverse: %v", err)
}
@@ -240,15 +269,13 @@ func TestReverseIsASkipHeadsUp(t *testing.T) {
}
func TestReverseTurnsTheTableAround(t *testing.T) {
// Every bot holds a red card, so each of them can play the moment the turn
// reaches it — which is what makes the *order* they play in observable.
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, Move{Kind: MovePlay, Index: 0})
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play reverse: %v", err)
}
@@ -261,7 +288,6 @@ func TestReverseTurnsTheTableAround(t *testing.T) {
if next.Turn != You {
t.Errorf("the bots should have played round to you, turn is %d", next.Turn)
}
// The table now runs anticlockwise: seat 3 plays, then 2, then 1.
var order []int
for _, e := range evs {
if e.Kind == EvPlay && e.Seat != You {
@@ -274,15 +300,13 @@ func TestReverseTurnsTheTableAround(t *testing.T) {
}
func TestSkipSkips(t *testing.T) {
// Both bots hold a playable red, so the only reason either of them doesn't
// play is that it wasn't asked to.
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, Move{Kind: MovePlay, Index: 0})
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play skip: %v", err)
}
@@ -307,7 +331,7 @@ func TestSkipSkips(t *testing.T) {
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, Move{Kind: MoveDraw})
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
@@ -320,18 +344,16 @@ func TestDrawnPlayableWaitsForYou(t *testing.T) {
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(); len(got) != 1 || got[0] != 1 {
if got := next.Playable(You); len(got) != 1 || got[0] != 1 {
t.Errorf("the drawn card, and only it, is playable: %v", got)
}
// You may not play the *other* card instead — drawing would otherwise be a
// free look with no cost.
if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow {
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, Move{Kind: MoveDraw}); err != ErrMustPlayNow {
if _, _, err := ApplyMove(next, You, Move{Kind: MoveDraw}); err != ErrMustPlayNow {
t.Fatalf("you can't draw twice, got %v", err)
}
after, _, err := ApplyMove(next, Move{Kind: MovePass})
after, _, err := ApplyMove(next, You, Move{Kind: MovePass})
if err != nil {
t.Fatalf("pass: %v", err)
}
@@ -346,7 +368,7 @@ func TestDrawnPlayableWaitsForYou(t *testing.T) {
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, Move{Kind: MoveDraw})
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
@@ -360,69 +382,78 @@ func TestUnplayableDrawPassesTheTurn(t *testing.T) {
func TestPassOnlyAfterADraw(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
if _, _, err := ApplyMove(s, Move{Kind: MovePass}); err != ErrCantPass {
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 so there is nothing to reshuffle out of, and not a seat holds a card that
// goes on the pile. Every seat can only pass, forever.
// 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
}
// The game has to end here. It used to not: the stuck guard counted how many
// bots had passed in a row and asked for more of them than there are seats, so
// it never fired once, and a dead table just handed the turn round and round.
// That is a game the player cannot finish — and a game they cannot finish is
// chips they cannot cash out, because the cage won't let them leave a live hand.
// 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}}})
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}}) // level: one card each
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.Phase != PhaseDone {
t.Fatalf("nobody can move and there is nothing to draw: the game is over, not %q", next.Phase)
if next.playing() {
t.Fatalf("nobody can move and there is nothing to draw: the hand is over, not %q", next.Phase)
}
if next.Outcome != OutcomeStuck {
t.Errorf("a tie on the shortest hand is nobody going out: %q", next.Outcome)
if next.Phase != PhaseHandOver {
t.Fatalf("a dead hand at a shared table returns to handover, not %q", next.Phase)
}
if next.Payout != 0 {
t.Errorf("a stuck game pays nothing, not %d", next.Payout)
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 it's over: %+v", evs)
t.Errorf("the table has to be told the hand is over: %+v", evs)
}
}
// And the shortest hand takes it, which is the one way a stuck table still pays.
// 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, Move{Kind: MoveDraw})
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.Outcome != OutcomeWon {
t.Fatalf("one card against two is a win: %q", next.Outcome)
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)
}
if next.Payout != s.Pays() {
t.Errorf("payout %d, quoted %d — the felt has to honour what it advertised", next.Payout, s.Pays())
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)
// An empty deck, and a discard with something under the top card to become one.
// The buried wild went down as green; it has to come back as a wild.
s.Deck = nil
s.Discard = []Card{{Green, WildCard}, {Red, Two}, {Red, Nine}}
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw on an empty deck: %v", err)
}
@@ -446,74 +477,64 @@ func TestReshuffleRebuildsTheDeck(t *testing.T) {
}
}
// ---- the money ------------------------------------------------------------
// ---- the pot --------------------------------------------------------------
// The rule every game in this casino has had to be taught: the number the felt
// quotes and the number the settle lands on are one function, not two.
func TestQuoteIsThePayout(t *testing.T) {
for _, tier := range Tiers {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
s.Tier = tier
s.Hands = make([][]Card, tier.Bots+1)
s.Hands[You] = []Card{{Red, Three}}
for i := 1; i <= tier.Bots; i++ {
s.Hands[i] = []Card{{Green, Five}, {Green, Six}}
}
quoted := s.Pays()
// 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, Move{Kind: MovePlay, Index: 0}) // your last card
if err != nil {
t.Fatalf("%s: go out: %v", tier.Slug, err)
}
if next.Outcome != OutcomeWon {
t.Fatalf("%s: playing your last card wins, got %q", tier.Slug, next.Outcome)
}
if next.Payout != quoted {
t.Errorf("%s: the felt quoted %d, the house paid %d", tier.Slug, quoted, next.Payout)
}
if next.Net() != quoted-s.Bet {
t.Errorf("%s: net is %d, want %d", tier.Slug, next.Net(), quoted-s.Bet)
}
}
}
// The rake comes out of the winnings, never the stake.
func TestRakeIsOnWinningsOnly(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
s.Tier = duel() // 2.2x on 100: 220 back, 120 of it profit, 6 of that to the house
s.Bet = 100
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) // your last card
if err != nil {
t.Fatalf("go out: %v", err)
}
if next.Payout != 214 {
t.Errorf("payout %d, want 214 (100 stake + 120 winnings - 6 rake)", next.Payout)
if next.Outcome != OutcomeWon || next.Winner != You {
t.Fatalf("playing your last card wins: outcome %q winner %d", next.Outcome, next.Winner)
}
if next.Rake != 6 {
t.Errorf("rake %d, want 6", next.Rake)
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.Net() != 114 {
t.Errorf("net %d, want 114", next.Net())
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)
}
}
func TestLosingPaysNothingAndIsNotCharged(t *testing.T) {
// The bot holds one card that plays on the pile, so it goes out the moment the
// turn reaches it.
// 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()
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
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 != OutcomeLost {
t.Fatalf("the bot went out, so you lost: %q", next.Outcome)
if next.Outcome != OutcomeWon || next.Winner != 1 {
t.Fatalf("the bot went out: outcome %q winner %d", next.Outcome, next.Winner)
}
if next.Payout != 0 || next.Rake != 0 {
t.Errorf("a loss pays nothing and is charged nothing: payout %d rake %d", next.Payout, next.Rake)
if next.Rake != 0 {
t.Errorf("a bot winning rakes nothing, got %d", next.Rake)
}
if next.Net() != -s.Bet {
t.Errorf("a loss costs the stake and no more: %d", next.Net())
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 {
@@ -521,51 +542,81 @@ func TestLosingPaysNothingAndIsNotCharged(t *testing.T) {
}
}
func TestNoMoveAfterItIsOver(t *testing.T) {
// 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()
done, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
over, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("go out: %v", err)
}
if _, _, err := ApplyMove(done, Move{Kind: MoveDraw}); err != ErrGameOver {
t.Fatalf("a finished game takes no more moves, got %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)
}
}
func TestBadBet(t *testing.T) {
if _, _, err := New(0, duel(), rake, 1, 2); err != ErrBadBet {
t.Fatalf("want ErrBadBet, 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 a game to its end with a simple strategy: play the first legal
// card, otherwise draw, otherwise pass. It asserts the invariants at every step.
// 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.Phase != PhaseDone; turn++ {
for turn := 0; s.playing(); turn++ {
if turn > maxTurns {
t.Fatalf("the game never ended in %d turns", 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(); len(p) > 0 {
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, m)
next, evs, err := ApplyMove(s, You, m)
if err != nil {
t.Fatalf("turn %d: %v (move %+v, phase %v)", turn, err, m, s.Phase)
}
@@ -580,13 +631,6 @@ func playOut(t *testing.T, s State, maxTurns int) State {
t.Fatalf("turn %d: %d of %v, want %d — a card was duplicated", turn, n, c, want)
}
}
// No event ever names a bot's card. That is the hole card of this game, and
// it is most of the deck.
for _, e := range evs {
if (e.Kind == EvDraw || e.Kind == EvForced) && e.Seat != You && e.Card != nil {
t.Fatalf("turn %d: a bot's drawn card crossed the wire: %+v", turn, e)
}
}
s = next
}
return s
@@ -604,72 +648,54 @@ func deckCount(c Card) int {
}
}
// A hundred games, played out, with the invariants checked at every step. This
// is the test that would have caught a deck that leaks cards through the
// reshuffle, a turn the bots don't hand back, or a game that can't end.
// A hundred hands, played out, with the invariants checked at every step.
func TestGamesPlayOut(t *testing.T) {
wins, losses, stuck := 0, 0, 0
yous, others, ties := 0, 0, 0
for seed := uint64(0); seed < 100; seed++ {
tier := Tiers[seed%3]
end := playOut(t, deal(t, tier, 100, seed), 500)
switch end.Outcome {
case OutcomeWon:
wins++
if end.Payout != end.Pays() {
t.Fatalf("seed %d: paid %d, quoted %d", seed, end.Payout, end.Pays())
}
case OutcomeLost:
losses++
case OutcomeStuck:
stuck++
default:
t.Fatalf("seed %d ended as %q", seed, end.Outcome)
end := playOut(t, deal(t, tier, seed), 500)
if end.Phase != PhaseHandOver {
t.Fatalf("seed %d ended in phase %q", seed, end.Phase)
}
if len(end.Hands[end.winnerSeat()]) != 0 && end.Outcome != OutcomeStuck {
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)
}
}
// Playing the first legal card is a poor strategy against bots that hold their
// +4s back, so this is not a fairness assertion — it's a check that both
// outcomes actually happen. A table that never pays is a bug in the bots.
if wins == 0 || losses == 0 {
t.Fatalf("100 games gave %d wins, %d losses, %d stuck — one side never happens", wins, losses, stuck)
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 games: %d won, %d lost, %d stuck", wins, losses, stuck)
t.Logf("100 hands: %d to you, %d to others, %d tied", yous, others, ties)
}
// winnerSeat is the seat the settle event named — only used by the tests.
func (s State) winnerSeat() int {
best := 0
for i := range s.Hands {
if len(s.Hands[i]) < len(s.Hands[best]) {
best = i
}
}
return best
}
// The same seed deals the same game and the bots make the same choices — which
// is what lets a disputed game be replayed exactly as it fell.
// 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(), 250, 42), 500)
b := playOut(t, deal(t, full(), 250, 42), 500)
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.Outcome == "" {
if a.Winner < 0 && a.Outcome != OutcomeTie {
t.Fatal("the replay didn't finish")
}
}
// A game in progress survives a redeploy: it is a plain value, so it round-trips
// through the JSON it is stored as.
// A game in progress survives a redeploy: it round-trips through its JSON.
func TestStateSurvivesStorage(t *testing.T) {
s := deal(t, table(), 100, 9)
s, _, err := ApplyMove(s, Move{Kind: MoveDraw})
s := deal(t, table(), 9)
s, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
@@ -686,12 +712,10 @@ func TestStateSurvivesStorage(t *testing.T) {
if string(again) != string(blob) {
t.Fatal("a stored game came back different")
}
// And it plays on from there.
playOut(t, back, 500)
}
// A move the engine refuses leaves the caller's state exactly as it was — no
// card half-played, no turn half-passed.
// 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)
@@ -703,7 +727,7 @@ func TestARefusedMoveChangesNothing(t *testing.T) {
{Kind: MovePass}, // nothing drawn
{Kind: "shuffle-the-deck-in-my-favour"}, // no
} {
if _, _, err := ApplyMove(s, m); err == nil {
if _, _, err := ApplyMove(s, You, m); err == nil {
t.Fatalf("%+v should have been refused", m)
}
}
@@ -713,13 +737,12 @@ func TestARefusedMoveChangesNothing(t *testing.T) {
}
}
// The bots choose. Two different seeds should not play the same bot game, or the
// bot is a lookup table you can memorise.
// 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(), 100, seed), 500)
b := playOut(t, deal(t, duel(), 100, seed+1000), 500)
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++
}
@@ -729,8 +752,6 @@ func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) {
}
}
// botPick holds its +4 back while it's comfortable, and reaches for it when
// somebody is about to go out.
func TestBotSavesTheDrawFour(t *testing.T) {
hand := []Card{{Wild, WildDrawFour}, {Red, Five}}
top, color := Card{Red, Nine}, Red
@@ -763,7 +784,6 @@ func TestBotPicksItsBestColor(t *testing.T) {
if got := botColor(hand, rng); got != Blue {
t.Errorf("the bot holds two blues: it should call blue, got %v", got)
}
// A hand of nothing but wilds still has to name something.
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)

View File

@@ -94,6 +94,13 @@ func runMigrations(d *sql.DB) error {
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.

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

View File

@@ -398,12 +398,38 @@ func Stake(user string, amount int64) error {
// 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
}
now := nowUnix()
if _, err := Get().Exec(
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
@@ -431,11 +457,28 @@ type Hand struct {
// 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 {
if _, err := Get().Exec(
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), nowUnix(),
int64(h.Seed1), int64(h.Seed2), now,
); err != nil {
return fmt.Errorf("games: record hand: %w", err)
}
@@ -520,6 +563,12 @@ type LiveHand struct {
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.
@@ -566,16 +615,17 @@ 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 FROM game_live_hands WHERE matrix_user = ?`, user,
).Scan(&h.Game, &state, &s1, &s2)
`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 = []byte(state), uint64(s1), uint64(s2)
h.State, h.Seed1, h.Seed2, h.TableID = []byte(state), uint64(s1), uint64(s2), tableID.String
return h, nil
}
@@ -588,6 +638,127 @@ func ClearLiveHand(user string) error {
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) {

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

View File

@@ -2,6 +2,7 @@ package storage
import (
"database/sql"
"encoding/json"
"log/slog"
)
@@ -18,6 +19,10 @@ type RosterEntry struct {
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.
@@ -38,16 +43,20 @@ func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
}
stmt, err := tx.Prepare(`
INSERT INTO adventure_roster
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
(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); err != nil {
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt, detail); err != nil {
return err
}
}
@@ -91,6 +100,31 @@ func LoadRoster() ([]RosterEntry, int64, error) {
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.

View File

@@ -52,6 +52,83 @@ CREATE TABLE IF NOT EXISTS adventure_roster_meta (
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,
@@ -239,9 +316,103 @@ CREATE TABLE IF NOT EXISTS game_live_hands (
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.
--

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

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

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.

View File

@@ -35,13 +35,18 @@ func TestDevCasino(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
fundUser(t, bobPlayer, 5000)
seedTriviaBank(t)
payload, _ := json.Marshal(SessionUser{
Sub: "sub-1", Username: "reala", Name: "Reala",
Exp: time.Now().Add(24 * time.Hour).Unix(),
})
cookie := s.auth.sign(payload)
// 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 {
@@ -57,19 +62,31 @@ func TestDevCasino(t *testing.T) {
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.
// 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), 0o600); err != nil {
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\n\n", addr, sessionCookie, cookie)
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.
//

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

View File

@@ -0,0 +1,143 @@
package web
import (
"encoding/json"
"strings"
"testing"
"pete/internal/games/holdem"
)
// The security boundary of the whole multiplayer table, tested the way the plan
// insists it must be: render every seat's view at every street, and string-search
// the JSON for any card that belongs to another seat. Zero hits, for every seat,
// at every point before a showdown turns cards over.
//
// This is not belt-and-braces. After SSE the view a seat renders fans to that
// seat's stream, so a single missed redaction does not leak one card to one
// player through one handler bug — it broadcasts a hole card to the whole felt,
// continuously. The engine deliberately emits every seat's cards now (it cannot
// know who a shared stream is for), which makes this function the only thing
// standing between the deck and the network tab.
// holeLabel is a seat's two cards as they would be rendered — the exact strings a
// leak would put in the JSON.
func holeLabels(g holdem.State, seat int) []string {
h := g.Seats[seat].Hole
return []string{viewCard(h[0]).Label, viewCard(h[1]).Label}
}
func TestHoldemViewNeverLeaksAnotherSeatsCards(t *testing.T) {
tier := holdem.Tiers[0]
// Two humans (0, 2) and two bots (1, 3), so the test covers a seat seeing both
// another human and a bot, and a human sitting at a non-zero index.
seats := []holdem.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},
}
g, _, err := holdem.New(tier, seats, tier.RakePct, 5, 6)
if err != nil {
t.Fatal(err)
}
// Deal, then walk the hand to the river without anyone folding — every seat
// stays in, so every seat has cards that must not leak to the others. Checking
// and calling keeps everyone live; the redaction is checked after every step.
g, dealEvs, err := holdem.ApplyMove(g, 0, holdem.Move{Kind: holdem.Deal})
if err != nil {
t.Fatal(err)
}
// The deal script carries every hole, so it is the sharpest test of event
// redaction: for each viewer, no other seat's cards may survive.
assertEventsRedacted(t, g, dealEvs)
assertViewsRedacted(t, g)
for step := 0; g.Phase == holdem.PhaseBetting && step < 80; step++ {
seat := g.ToAct
if g.Seats[seat].Bot {
t.Fatalf("advance stopped on a bot at seat %d", seat)
}
move := holdem.Move{Kind: holdem.Check}
if g.Owed(seat) > 0 {
move = holdem.Move{Kind: holdem.Call}
}
var evs []holdem.Event
g, evs, err = holdem.ApplyMove(g, seat, move)
if err != nil {
t.Fatalf("seat %d %s: %v", seat, move.Kind, err)
}
assertEventsRedacted(t, g, evs)
assertViewsRedacted(t, g)
}
}
// assertViewsRedacted renders every seat's table view and fails if any of them
// carries a card belonging to a seat that is still hiding it.
func assertViewsRedacted(t *testing.T, g holdem.State) {
t.Helper()
shown := g.Street == holdem.Showdown
for viewer := range g.Seats {
blob, err := json.Marshal(viewHoldem(g, viewer))
if err != nil {
t.Fatal(err)
}
payload := string(blob)
for other := range g.Seats {
if other == viewer {
continue // your own cards are yours to see
}
// A seat's cards are legitimately visible only at a showdown, and then only
// if they did not fold. Everywhere else, a hit is a leak.
if shown && g.Seats[other].State != holdem.Folded {
continue
}
if g.Seats[other].State == holdem.Out {
continue
}
for _, label := range holeLabels(g, other) {
if strings.Contains(payload, label) {
t.Fatalf("seat %d's view (%s street) leaks seat %d's card %q",
viewer, g.Street, other, label)
}
}
}
}
}
// assertEventsRedacted renders the event script for every viewer and fails if a
// beat carries another seat's card outside a showdown "show".
func assertEventsRedacted(t *testing.T, g holdem.State, evs []holdem.Event) {
t.Helper()
for viewer := range g.Seats {
blob, err := json.Marshal(viewHoldemEvents(evs, viewer))
if err != nil {
t.Fatal(err)
}
payload := string(blob)
// The engine only puts a non-viewer's cards in a "show" beat and in the hole
// beats it emits for everyone. If any card label of another seat survives the
// redaction *and* there is no show event in this batch, it leaked.
hasShow := false
for _, e := range evs {
if e.Kind == "show" {
hasShow = true
}
}
if hasShow {
continue // a showdown legitimately turns cards over
}
for other := range g.Seats {
if other == viewer || g.Seats[other].State == holdem.Out {
continue
}
for _, label := range holeLabels(g, other) {
if strings.Contains(payload, label) {
t.Fatalf("seat %d's event stream leaks seat %d's card %q", viewer, other, label)
}
}
}
}
}

View File

@@ -0,0 +1,209 @@
package web
import (
"encoding/json"
"time"
"pete/internal/games/holdem"
"pete/internal/storage"
)
// Hold'em as a shared table: the seam the runtime drives it through, and the two
// facts about a hand that only the engine can tell the runtime — when the clock
// must next act, and what a finished hand owes the audit trail.
//
// Everything about *playing* poker is still in the engine. This file is the
// translation layer between a holdem.State and the game-agnostic table runtime:
// it decodes the blob, asks the engine to act, and re-derives the deadline and
// the audit from the state that comes back.
// holdemTable is the tableGame for poker. It holds no state — the table's state
// is the blob in game_tables — so a single value in s.tableGames serves every
// felt in the room.
type holdemTable struct{}
func (holdemTable) name() string { return gameHoldem }
// timeout acts for the human whose clock ran out. At a card table the standing
// courtesy is check if you can, fold if you cannot: a walked-away player never
// puts more chips in, and folding keeps the hand moving for everyone still there.
//
// It marks the seat away so the runtime stops waiting a full clock on it next
// time — an absent human auto-folds on sight after this, rather than holding three
// other people hostage for thirty seconds an orbit.
//
// If, on decode, the seat to act is not a waiting human, the scan raced a real
// move that had not yet bumped the version: errNotDue, and the clock steps aside.
func (holdemTable) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
var g holdem.State
if err := json.Unmarshal(state, &g); err != nil {
return step{}, nil, err
}
if g.Phase != holdem.PhaseBetting {
return step{}, seats, errNotDue
}
seat := g.ToAct
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
return step{}, seats, errNotDue
}
move := holdem.Move{Kind: holdem.Fold}
if g.Owed(seat) == 0 {
move.Kind = holdem.Check
}
next, evs, err := holdem.ApplyMove(g, seat, move)
if err != nil {
return step{}, nil, err
}
changed := markAway(seats, seat)
st, err := holdemStep(g, next, evs, seats)
return st, changed, err
}
// stacks reports the chips in front of each seat, index-aligned with the table's
// seat rows. The abandoned-table reaper reads it to know what to send each
// walked-away human home with, without having to understand poker.
func (holdemTable) stacks(state []byte) ([]int64, error) {
var g holdem.State
if err := json.Unmarshal(state, &g); err != nil {
return nil, err
}
out := make([]int64, len(g.Seats))
for i := range g.Seats {
out[i] = g.Seats[i].Stack
}
return out, nil
}
// holdemStep packages a played-out state for the runtime: the blob to persist,
// the deadline the clock must honour next, and the audit of any hand that just
// ended. prev is the state before the move, which is what lets it work out how
// much rake this one hand took.
func holdemStep(prev, next holdem.State, evs []holdem.Event, seats []storage.Seat) (step, error) {
blob, err := json.Marshal(next)
if err != nil {
return step{}, err
}
st := step{
State: blob,
Phase: string(next.Phase),
HandNo: int64(next.HandNo),
Deadline: holdemDeadline(next, seats),
Audit: holdemAudit(prev, next, evs, seats),
}
return st, nil
}
// holdemDeadline is when the clock must next act, or 0 for never. A clock is only
// ever set on a *present* human whose turn it is: a bot resolves inside the move
// and never waits, and an away human is auto-acted the moment the clock sees them
// rather than waited on. Between hands there is no per-seat clock at all — an
// abandoned table is the reaper's job, not the turn clock's.
func holdemDeadline(g holdem.State, seats []storage.Seat) int64 {
if g.Phase != holdem.PhaseBetting {
return 0
}
seat := g.ToAct
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
return 0
}
if seatAway(seats, seat) {
return 0 // an away human doesn't get waited on; the clock acts next tick
}
return time.Now().Unix() + turnSeconds
}
// holdemAudit is the per-hand record of a finished hand — one row per human who
// was in it. Empty until a hand actually ends, which is exactly when the engine
// emits an "end" beat.
//
// The rake is the trap the plan flagged. game_hands.rake is summed into the
// house's income (HouseTake), so a pot's rake must be recorded once and once
// only. It rides on the winner's row alone; every other seat carries zero. And it
// is this hand's rake, not the session's: next.Paid is cumulative, so the hand's
// take is the amount it climbed by since prev — the part of *this* pot that came
// out of a human's winnings.
func holdemAudit(prev, next holdem.State, evs []holdem.Event, seats []storage.Seat) []storage.Hand {
if !handEnded(evs) {
return nil
}
handRake := next.Paid - prev.Paid
// The human who won the most is who the rake is attributed to: rake only ever
// comes out of a pot a human won, so when handRake is positive there is one.
winner, best := -1, int64(0)
for i := range next.Seats {
if next.Seats[i].Bot {
continue
}
if next.Seats[i].Won > best {
winner, best = i, next.Seats[i].Won
}
}
var audit []storage.Hand
for i := range next.Seats {
p := next.Seats[i]
if p.Bot || i >= len(seats) || seats[i].MatrixUser == "" {
continue
}
if p.Total == 0 && p.Won == 0 {
continue // dealt out, or never in the hand — nothing to record
}
outcome := "lost"
switch {
case p.Won > p.Total:
outcome = "won"
case p.Won == p.Total:
outcome = "push"
}
rake := int64(0)
if i == winner {
rake = handRake
}
audit = append(audit, storage.Hand{
MatrixUser: seats[i].MatrixUser,
Game: gameHoldem,
Bet: p.Total,
Payout: p.Won,
Rake: rake,
Outcome: outcome,
Seed1: next.Seed1,
Seed2: next.Seed2,
})
}
return audit
}
// handEnded reports whether a hand finished in this batch of events. endHand is
// the only thing that emits "end", and it emits exactly one, so this is the clean
// signal that Won and Total on the seats are this hand's final numbers.
func handEnded(evs []holdem.Event) bool {
for _, e := range evs {
if e.Kind == "end" {
return true
}
}
return false
}
// ---- seat bookkeeping ------------------------------------------------------
// seatAway reports whether the seat at an index is a human who has walked away.
func seatAway(seats []storage.Seat, seat int) bool {
return seat >= 0 && seat < len(seats) && seats[seat].Away
}
// markAway returns the one seat row the clock needs to write back: the timed-out
// seat, flagged away, with its last_seen left exactly as it was. Preserving
// last_seen is the whole point — the reaper measures abandonment from when a
// human last acted *for themselves*, and an auto-fold is not that.
func markAway(seats []storage.Seat, seat int) []storage.Seat {
if seat < 0 || seat >= len(seats) {
return nil
}
s := seats[seat]
s.Away = true
return []storage.Seat{s}
}

View File

@@ -0,0 +1,239 @@
package web
import (
"testing"
"pete/internal/games/holdem"
"pete/internal/storage"
)
// Sitting down is the only time chips leave your stack at this table, and getting
// up is the only time they come back. Everything in between is inside the engine.
func TestHoldemSitTakesTheBuyInAndNothingElse(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
v, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 2, "buyin": 600}))
if code != 200 {
t.Fatalf("sit = %d, want 200", code)
}
if v.Chips != 4400 {
t.Fatalf("chips after a 600 buy-in = %d, want 4400", v.Chips)
}
if v.Game != gameHoldem || v.Holdem == nil {
t.Fatalf("sit returned no table: game=%q", v.Game)
}
g := v.Holdem
if g.Stack != 600 {
t.Errorf("you sat down with %d, want the 600 you bought in for", g.Stack)
}
if len(g.Seats) != 3 {
t.Fatalf("two bots and you is three seats, got %d", len(g.Seats))
}
if g.Phase != "handover" {
t.Errorf("phase %q — a table you just sat at has no hand on it yet", g.Phase)
}
// Play a whole hand, then check that not one chip has crossed the border.
deal, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
map[string]any{"move": "deal"}))
if code != 200 {
t.Fatalf("deal = %d, want 200", code)
}
for i := 0; deal.Holdem != nil && deal.Holdem.Phase == "betting" && i < 60; i++ {
move := "check"
if deal.Holdem.Owed > 0 {
move = "fold"
}
deal, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
map[string]any{"move": move}))
if code != 200 {
t.Fatalf("%s = %d, want 200", move, code)
}
}
if deal.Chips != 4400 {
t.Errorf("chips moved during a hand: %d, want the 4400 that were left after the buy-in — "+
"a pot is settled inside the engine, not across the border", deal.Chips)
}
}
// The wall. A bot's hole cards are the game; a browser that held them would make
// this unplayable, and the payload is where that has to be true.
func TestHoldemNeverSendsABotsCards(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
v, _ := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "micro", "bots": 3, "buyin": 200}))
if v.Holdem == nil {
t.Fatal("no table")
}
// Play hands until one of them ends without a showdown, checking every payload
// on the way. Folding is the case that matters: nobody has earned the right to
// see anybody's cards, so nobody's may be in there.
for hand := 0; hand < 8; hand++ {
v, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
map[string]any{"move": "deal"}))
if code != 200 {
t.Fatalf("deal = %d", code)
}
noBotCards(t, v)
for i := 0; v.Holdem != nil && v.Holdem.Phase == "betting" && i < 60; i++ {
move := "check"
if v.Holdem.Owed > 0 {
move = "call"
}
v, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
map[string]any{"move": move}))
if code != 200 {
t.Fatalf("%s = %d", move, code)
}
noBotCards(t, v)
}
if v.Holdem == nil || v.Holdem.Phase == "done" {
return // busted out; the wall held all the way
}
}
}
// noBotCards checks a payload. A bot's cards may appear in exactly one place: a
// seat that is being shown down, on a board that reached a showdown.
func noBotCards(t *testing.T, v tableView) {
t.Helper()
g := v.Holdem
if g == nil {
return
}
shown := g.Street == "showdown"
for i, seat := range g.Seats {
if i == 0 || len(seat.Cards) == 0 {
continue
}
if !shown {
t.Fatalf("seat %d (%s) sent %d cards on the %s — nobody has shown down",
i, seat.Name, len(seat.Cards), g.Street)
}
if seat.State == "folded" {
t.Fatalf("seat %d (%s) folded and its cards were sent anyway", i, seat.Name)
}
}
for _, e := range v.HoldemEvents {
if e.Seat > 0 && len(e.Cards) > 0 && e.Kind != "show" {
t.Fatalf("a %q event carries seat %d's cards — that's a bot's hand", e.Kind, e.Seat)
}
}
}
// Getting up is what pays. Everything the session did lands in one movement.
func TestHoldemLeavingBringsTheStackBack(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
v, _ := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 1, "buyin": 500}))
if v.Chips != 4500 || v.Holdem == nil {
t.Fatalf("sit: chips=%d holdem=%v", v.Chips, v.Holdem)
}
stack := v.Holdem.Stack
v, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
map[string]any{"move": "leave"}))
if code != 200 {
t.Fatalf("leave = %d, want 200", code)
}
if v.Chips != 4500+stack {
t.Errorf("chips after getting up = %d, want %d (the %d that was in front of us)",
v.Chips, 4500+stack, stack)
}
if v.Game != "" {
t.Errorf("still at a table after getting up: %q", v.Game)
}
// And there is no table left to play at.
_, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
map[string]any{"move": "deal"}))
if code != 409 {
t.Errorf("dealt a hand at a table we got up from: %d, want 409", code)
}
}
// You cannot walk out on a hand you have chips riding on.
func TestHoldemCannotLeaveMidHand(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}))
v, _ := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
map[string]any{"move": "deal"}))
if v.Holdem == nil || v.Holdem.Phase != "betting" {
t.Skip("the hand ended before we could act")
}
_, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
map[string]any{"move": "leave"}))
if code != 400 {
t.Errorf("left in the middle of a hand: %d, want 400", code)
}
// And the chips are still on the table, not back on the stack.
after, err := storage.Chips("@reala:parodia.dev")
if err != nil {
t.Fatal(err)
}
if after.Chips != 4500 {
t.Errorf("chips = %d, want 4500 — the buy-in is still on the table", after.Chips)
}
}
// A buy-in outside the table's range is not a buy-in, and it must not take chips.
func TestHoldemRefusesABadBuyIn(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
tier, _ := holdem.TierBySlug("low")
for _, amount := range []int64{tier.MinBuy - 1, tier.MaxBuy + 1, 0} {
_, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 2, "buyin": amount}))
if code != 400 {
t.Errorf("buy-in of %d at a %d%d table = %d, want 400", amount, tier.MinBuy, tier.MaxBuy, code)
}
}
st, err := storage.Chips("@reala:parodia.dev")
if err != nil {
t.Fatal(err)
}
if st.Chips != 5000 {
t.Errorf("a refused buy-in took %d chips", 5000-st.Chips)
}
}
// A top-up is real money crossing the border. If the engine refuses it, the chips
// come straight back — the same order, and the same reason, as doubling down.
func TestHoldemTopUpRefundsWhenRefused(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
// Sitting down at the maximum means there is no room to top up into.
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 1, "buyin": 1000}))
_, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
map[string]any{"move": "topup", "amount": 100}))
if code != 400 {
t.Errorf("topped up over the table maximum: %d, want 400", code)
}
st, err := storage.Chips("@reala:parodia.dev")
if err != nil {
t.Fatal(err)
}
if st.Chips != 4000 {
t.Errorf("chips = %d, want 4000 — a refused top-up must give the chips back", st.Chips)
}
}

117
internal/web/games_hub.go Normal file
View File

@@ -0,0 +1,117 @@
package web
import (
"sync"
"sync/atomic"
)
// The SSE hub: how a move one player makes reaches the phones of everyone else
// at the felt.
//
// It is in-memory and it is intentionally dumb. It holds no game state and makes
// no decisions — it is a fan-out of opaque byte frames, keyed by table id. The
// authority is always the database; a frame is a nudge that says "the table at
// this version changed, come and look", and a subscriber that misses one (a
// dropped send, a reconnect) refetches the table, which is authoritative anyway.
// So a lost frame is a cosmetic hiccup, never a wrong balance.
//
// Two rules hold it together, and both are load-bearing:
//
// 1. **Sends are non-blocking.** A subscriber's channel is buffered, and a send
// that would block is dropped, not waited on. The publish happens under the
// table lock (which is what orders frames correctly for free), so a blocking
// send would hold that lock while one phone on a train stalls — and the turn
// clock behind that lock stalls with it, for the whole casino. A dropped frame
// costs that one subscriber a refetch; a held lock costs everyone the room.
//
// 2. **The publisher never touches the database.** The hub is reached only after
// the DB work is done and the connection released. Holding a *sql.Rows or a tx
// open for the life of a stream would hold the one connection in the pool
// forever, and a single subscriber would brick the whole application.
// hubFrame is what goes down the wire: an opaque payload the browser knows how to
// read (a JSON table view), tagged with the version it represents so a subscriber
// can tell a frame it already has from one it missed.
type hubFrame struct {
Version int64
Data []byte
}
// tableSub is one open EventSource: a buffered channel and the id that lets the
// subscriber unregister itself when the stream closes.
type tableSub struct {
id int64
ch chan hubFrame
}
// gamesHub fans table frames out to whoever is watching each table.
type gamesHub struct {
mu sync.Mutex
tables map[string]map[int64]*tableSub
nextID atomic.Int64
}
func newGamesHub() *gamesHub {
return &gamesHub{tables: make(map[string]map[int64]*tableSub)}
}
// subChanBuffer is how many frames a slow subscriber can fall behind before the
// hub starts dropping theirs. A few is plenty: a subscriber that far behind is
// going to refetch the authoritative table anyway, so buffering more just delays
// that with staler frames.
const subChanBuffer = 8
// subscribe registers a new watcher of a table and returns its channel plus the
// unsubscribe to defer. The channel is buffered so a publish never blocks on a
// reader that is mid-write to its socket.
func (h *gamesHub) subscribe(tableID string) (<-chan hubFrame, func()) {
sub := &tableSub{id: h.nextID.Add(1), ch: make(chan hubFrame, subChanBuffer)}
h.mu.Lock()
subs := h.tables[tableID]
if subs == nil {
subs = make(map[int64]*tableSub)
h.tables[tableID] = subs
}
subs[sub.id] = sub
h.mu.Unlock()
return sub.ch, func() { h.unsubscribe(tableID, sub.id) }
}
func (h *gamesHub) unsubscribe(tableID string, id int64) {
h.mu.Lock()
defer h.mu.Unlock()
subs := h.tables[tableID]
if subs == nil {
return
}
delete(subs, id)
if len(subs) == 0 {
delete(h.tables, tableID)
}
}
// publish pushes a frame to everyone watching a table, dropping it for any
// subscriber whose buffer is full rather than waiting on them. See rule 1: this
// is called under the table lock, so it must never block.
func (h *gamesHub) publish(tableID string, f hubFrame) {
h.mu.Lock()
defer h.mu.Unlock()
for _, sub := range h.tables[tableID] {
select {
case sub.ch <- f:
default:
// Full buffer: this subscriber is behind. Dropping is correct — they will
// refetch the authoritative table when they next read a version gap.
}
}
}
// watchers reports how many streams are open on a table. Used by the caller that
// decides whether a frame is worth rendering at all.
func (h *gamesHub) watchers(tableID string) int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.tables[tableID])
}

View File

@@ -0,0 +1,94 @@
package web
import (
"sync"
"testing"
)
func TestHub_DeliversToSubscribers(t *testing.T) {
h := newGamesHub()
ch, done := h.subscribe("t1")
defer done()
h.publish("t1", hubFrame{Version: 3, Data: []byte("hi")})
f := <-ch
if f.Version != 3 || string(f.Data) != "hi" {
t.Fatalf("got %+v", f)
}
}
func TestHub_OnlyToTheRightTable(t *testing.T) {
h := newGamesHub()
ch1, d1 := h.subscribe("t1")
defer d1()
ch2, d2 := h.subscribe("t2")
defer d2()
h.publish("t1", hubFrame{Version: 1})
select {
case <-ch2:
t.Fatal("t2 should not have received t1's frame")
default:
}
if f := <-ch1; f.Version != 1 {
t.Fatalf("t1 got %+v", f)
}
}
// TestHub_PublishNeverBlocks is the load-bearing property: a subscriber that
// never reads must not be able to hold up a publish, because publish happens
// under the table lock and a blocked publish stalls the turn clock for everyone.
func TestHub_PublishNeverBlocks(t *testing.T) {
h := newGamesHub()
_, done := h.subscribe("t1") // never read from
defer done()
// Far more than the buffer. If any of these blocked, the test would hang.
blocked := make(chan struct{})
go func() {
for i := 0; i < subChanBuffer*10; i++ {
h.publish("t1", hubFrame{Version: int64(i)})
}
close(blocked)
}()
<-blocked
}
func TestHub_UnsubscribeStopsDelivery(t *testing.T) {
h := newGamesHub()
ch, done := h.subscribe("t1")
done()
if h.watchers("t1") != 0 {
t.Fatalf("watchers should be 0 after unsubscribe, got %d", h.watchers("t1"))
}
h.publish("t1", hubFrame{Version: 1})
select {
case _, ok := <-ch:
if ok {
t.Fatal("a frame arrived after unsubscribe")
}
default:
}
}
func TestHub_ConcurrentSubscribers(t *testing.T) {
h := newGamesHub()
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ch, done := h.subscribe("t1")
defer done()
<-ch
}()
}
// Let them all register, then flood so every one of them reads at least one.
for h.watchers("t1") < 50 {
}
for i := 0; i < subChanBuffer; i++ {
h.publish("t1", hubFrame{Version: int64(i)})
}
wg.Wait()
}

View File

@@ -0,0 +1,58 @@
package web
import (
"hash/fnv"
"sync"
)
// The striped table lock, and why it is only ever an optimisation.
//
// The database's version column is the real concurrency authority: every write
// to a table is conditional on the version the writer read, so two writers that
// race produce one winner and one ErrStaleTable no matter what happens in
// memory. This lock exists purely to make the loser lose *before* it does the
// work, rather than after — it serialises the read-modify-write on a table so the
// common case doesn't burn an engine step and a marshal only to be told it was
// stale.
//
// It is a fixed array hashed on table id, never a map you can delete from, and
// that is deliberate. A map of mutexes keyed by table id, cleaned up when a table
// empties, will hand two goroutines two different mutex objects for the same
// table across a delete-and-recreate — which is no lock at all. A fixed array has
// no lifecycle: the same id always hashes to the same mutex, forever. The only
// cost is that two unrelated tables can collide onto one stripe and briefly wait
// on each other, which is harmless.
//
// A redeploy is the case that proves the version column has to be the authority:
// during a drain two processes are running, each with its own array, so a table
// is "locked" by two mutexes that know nothing about each other. The version
// column is the only thing both processes share, and it is what keeps them
// correct while the mutexes are useless.
// lockStripes is how many mutexes the array holds. A power of two so the mask is
// clean; large enough that collisions between live tables are rare.
const lockStripes = 256
type stripedLocks struct {
m [lockStripes]sync.Mutex
}
func newStripedLocks() *stripedLocks { return &stripedLocks{} }
// forTable returns the mutex a given table hashes onto. The same id always
// returns the same mutex.
func (s *stripedLocks) forTable(id string) *sync.Mutex {
h := fnv.New32a()
_, _ = h.Write([]byte(id))
return &s.m[h.Sum32()&(lockStripes-1)]
}
// withTable runs fn while holding the table's stripe. The lock is released when
// fn returns — it never spans a network read or an SSE send, only the
// read-modify-write against the database.
func (s *stripedLocks) withTable(id string, fn func() error) error {
mu := s.forTable(id)
mu.Lock()
defer mu.Unlock()
return fn()
}

444
internal/web/games_og.go Normal file
View File

@@ -0,0 +1,444 @@
package web
import (
"bytes"
_ "embed"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"log/slog"
"math"
"net/http"
"sync"
"time"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"golang.org/x/image/vector"
)
// The card that shows up when somebody pastes the casino into a chat window.
//
// It is drawn here, in Go, rather than checked in as a picture, because the
// casino has two names on a clock and the share card keeps the joke: paste the
// link in daylight and you get Casinopolis on green felt; paste it after six and
// the neon is on and the sign says Casino Night Zone. Same tables, different room,
// same rule as roomAt() everywhere else.
//
// The clock that decides is the *server's*, because an unfurl bot has no evening
// of its own. That's the one place the room rule can't be the player's own clock.
//go:embed assets/fonts/Fredoka-SemiBold.ttf
var fredokaTTF []byte
// Share cards are 1200x630: the size every unfurler crops to and the one thing
// about Open Graph that everybody agrees on.
const (
ogWidth = 1200
ogHeight = 630
)
// ogPalette is a room's colours, lifted from the CSS block of the same name in
// input.css. Two copies of a palette is a thing that drifts, so if you retune a
// room, retune it here as well — TestTheShareCardKnowsBothRooms will not catch a
// colour, only a missing room.
type ogPalette struct {
bg color.RGBA // the room, behind the table
feltA color.RGBA // the felt, lit
feltC color.RGBA // the felt, in shadow
ink color.RGBA // type
accent color.RGBA // the sign, and the lamp over the table
}
var ogPalettes = map[string]ogPalette{
roomDay.Slug: {
bg: rgb(0x16, 0x21, 0x1c),
feltA: rgb(0x2f, 0x7d, 0x5b),
feltC: rgb(0x1c, 0x4d, 0x3c),
ink: rgb(0xf6, 0xec, 0xd8),
accent: rgb(0xf2, 0xb5, 0x3d),
},
roomNight.Slug: {
bg: rgb(0x14, 0x0f, 0x2e),
feltA: rgb(0x4a, 0x2f, 0xa8),
feltC: rgb(0x24, 0x16, 0x59),
ink: rgb(0xf2, 0xec, 0xff),
accent: rgb(0xff, 0xcc, 0x2f),
},
}
// A card is drawn once per room and then kept. There are two of them, they never
// change, and an unfurl bot is not worth a rasterizer.
var (
ogOnce sync.Once
ogCards map[string][]byte // room slug -> PNG
ogErr error
)
func (s *Server) handleGamesOG(w http.ResponseWriter, r *http.Request) {
if !s.gamesReady() {
http.NotFound(w, r)
return
}
// No requirePlayer here, and that is the whole point: the thing fetching this
// is a chat server's link preview, which has never signed in and never will.
ogOnce.Do(func() { ogCards, ogErr = drawShareCards() })
room := roomAt(time.Now().Hour())
card := ogCards[room.Slug]
if ogErr != nil || len(card) == 0 {
// A room with no card is a room somebody added to roomAt and not to
// ogPalettes. Say so rather than serving a zero-byte PNG, which an unfurler
// will happily cache as the casino's face.
slog.Error("games: no share card", "room", room.Slug, "err", ogErr)
http.Error(w, "share card unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/png")
// Long enough that a busy channel isn't redrawing it, short enough that the
// room actually turns over: the lights come on at six and the card follows
// within the hour.
w.Header().Set("Cache-Control", "public, max-age=900")
// ServeContent sets Content-Length itself, and gets it right for a range
// request, which a hand-written one would not.
http.ServeContent(w, r, "og.png", time.Time{}, bytes.NewReader(card))
}
// drawShareCards renders every room's card up front. If the font is broken every
// card is broken, so they fail together or not at all.
func drawShareCards() (map[string][]byte, error) {
f, err := opentype.Parse(fredokaTTF)
if err != nil {
return nil, fmt.Errorf("parse fredoka: %w", err)
}
out := make(map[string][]byte, len(ogPalettes))
for _, rm := range []room{roomDay, roomNight} {
img, err := drawShareCard(f, rm, ogPalettes[rm.Slug])
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("encode %s: %w", rm.Slug, err)
}
out[rm.Slug] = buf.Bytes()
}
return out, nil
}
func drawShareCard(f *opentype.Font, rm room, p ogPalette) (image.Image, error) {
title, err := face(f, 96)
if err != nil {
return nil, err
}
defer title.Close()
line, err := face(f, 33)
if err != nil {
return nil, err
}
defer line.Close()
small, err := face(f, 25)
if err != nil {
return nil, err
}
defer small.Close()
img := image.NewRGBA(image.Rect(0, 0, ogWidth, ogHeight))
draw.Draw(img, img.Bounds(), &image.Uniform{p.bg}, image.Point{}, draw.Src)
// The table: a rounded rect of felt, lit from the top and falling into shadow
// at the rail, which is the same trick the room's CSS plays with a gradient.
table := func(rz *vector.Rasterizer) {
roundRect(rz, 36, 36, ogWidth-72, ogHeight-72, 44, nil)
}
fillPath(img, verticalGradient(p.feltA, p.feltC), table)
// The lamp over it. Nothing else in the picture explains where the light is
// coming from, and a felt with no lamp reads as a green rectangle. It is
// painted through the table's own shape, because light that spills onto the
// floor outside the rail is not a lamp, it's a mistake.
fillPath(img, lamp(ogWidth/2, 40, 520, p.accent, 0.26), table)
// The sign over the door.
centerText(img, title, p.accent, rm.Name, ogHeight/2-42)
centerText(img, line, alpha(p.ink, 0.92), "Blackjack, Hold'em, UNO, Trivia, Hangman, Solitaire", ogHeight/2+26)
centerText(img, small, alpha(p.ink, 0.62), "Played for real gogobee euros", ogHeight/2+78)
// Bottom left: two cards, one face down and one face up, because a casino that
// shows you only the backs is a casino that isn't dealing.
drawCardBack(img, p, 118, 430, -13)
drawCardFace(img, 196, 420, 7)
// Bottom right: what you're playing for.
chips := []color.RGBA{p.accent, rgb(0xd9, 0x4f, 0x4f), rgb(0xf6, 0xf1, 0xe6), p.accent}
for i, c := range chips {
drawChip(img, 1042, float32(536-i*23), 46, c, p)
}
// The address, small, in the corner. A share card is also a signpost.
rightText(img, small, alpha(p.ink, 0.45), "games.parodia.dev", ogWidth-70, 96)
return img, nil
}
// ---- shapes -----------------------------------------------------------------
//
// Everything below draws through a vector.Rasterizer the size of the whole card,
// which is wasteful and completely fine: it happens twice, at boot, forever.
// xform moves a point before it's rasterized. It is how anything here gets to sit
// at an angle — font.Drawer can't rotate, but a path can be rotated on its way in.
type xform func(x, y float32) (float32, float32)
func rotate(cx, cy, deg float32) xform {
rad := float64(deg) * math.Pi / 180
sin, cos := float32(math.Sin(rad)), float32(math.Cos(rad))
return func(x, y float32) (float32, float32) {
dx, dy := x-cx, y-cy
return cx + dx*cos - dy*sin, cy + dx*sin + dy*cos
}
}
func (t xform) at(x, y float32) (float32, float32) {
if t == nil {
return x, y
}
return t(x, y)
}
// fillPath rasterizes a path and paints src through it.
func fillPath(dst *image.RGBA, src image.Image, path func(*vector.Rasterizer)) {
rz := vector.NewRasterizer(ogWidth, ogHeight)
path(rz)
rz.Draw(dst, dst.Bounds(), src, image.Point{})
}
func fill(dst *image.RGBA, c color.Color, path func(*vector.Rasterizer)) {
fillPath(dst, &image.Uniform{c}, path)
}
// roundRect lays a rounded rectangle into the rasterizer, optionally through a
// transform, so the same helper draws a table and a card held at an angle.
func roundRect(rz *vector.Rasterizer, x, y, w, h, r float32, t xform) {
move := func(px, py float32) { rz.MoveTo(t.at(px, py)) }
line := func(px, py float32) { rz.LineTo(t.at(px, py)) }
quad := func(cx, cy, px, py float32) {
qx, qy := t.at(cx, cy)
ex, ey := t.at(px, py)
rz.QuadTo(qx, qy, ex, ey)
}
move(x+r, y)
line(x+w-r, y)
quad(x+w, y, x+w, y+r)
line(x+w, y+h-r)
quad(x+w, y+h, x+w-r, y+h)
line(x+r, y+h)
quad(x, y+h, x, y+h-r)
line(x, y+r)
quad(x, y, x+r, y)
rz.ClosePath()
}
func circle(rz *vector.Rasterizer, cx, cy, r float32) {
// Four cubics, the usual 0.5523 magic number for a circle out of beziers.
const k = 0.5523
rz.MoveTo(cx+r, cy)
rz.CubeTo(cx+r, cy+r*k, cx+r*k, cy+r, cx, cy+r)
rz.CubeTo(cx-r*k, cy+r, cx-r, cy+r*k, cx-r, cy)
rz.CubeTo(cx-r, cy-r*k, cx-r*k, cy-r, cx, cy-r)
rz.CubeTo(cx+r*k, cy-r, cx+r, cy-r*k, cx+r, cy)
rz.ClosePath()
}
// hexagon is Pete's mark — the same six-sided badge as the favicon, which is what
// the back of the house's cards is printed with.
func hexagon(rz *vector.Rasterizer, cx, cy, r float32, t xform) {
for i := 0; i < 6; i++ {
ang := float64(i)*math.Pi/3 - math.Pi/2
x := cx + r*float32(math.Cos(ang))
y := cy + r*float32(math.Sin(ang))
px, py := t.at(x, y)
if i == 0 {
rz.MoveTo(px, py)
} else {
rz.LineTo(px, py)
}
}
rz.ClosePath()
}
const (
cardW = 132
cardH = 186
)
// drawCardBack is the house's card: dark, with the hexagon on it.
func drawCardBack(dst *image.RGBA, p ogPalette, x, y, deg float32) {
t := rotate(x+cardW/2, y+cardH/2, deg)
fill(dst, color.RGBA{0, 0, 0, 70}, func(rz *vector.Rasterizer) {
roundRect(rz, x+5, y+8, cardW, cardH, 14, t)
})
fill(dst, alpha(p.ink, 0.96), func(rz *vector.Rasterizer) {
roundRect(rz, x, y, cardW, cardH, 14, t)
})
fill(dst, darken(p.feltC, 0.55), func(rz *vector.Rasterizer) {
roundRect(rz, x+9, y+9, cardW-18, cardH-18, 9, t)
})
fill(dst, p.accent, func(rz *vector.Rasterizer) {
hexagon(rz, x+cardW/2, y+cardH/2, 34, t)
})
fill(dst, darken(p.feltC, 0.55), func(rz *vector.Rasterizer) {
hexagon(rz, x+cardW/2, y+cardH/2, 20, t)
})
}
// drawCardFace is the one that's been turned over: an ace of hearts, near enough.
func drawCardFace(dst *image.RGBA, x, y, deg float32) {
t := rotate(x+cardW/2, y+cardH/2, deg)
red := rgb(0xd9, 0x3b, 0x3b)
fill(dst, color.RGBA{0, 0, 0, 80}, func(rz *vector.Rasterizer) {
roundRect(rz, x+5, y+9, cardW, cardH, 14, t)
})
fill(dst, rgb(0xfb, 0xf7, 0xef), func(rz *vector.Rasterizer) {
roundRect(rz, x, y, cardW, cardH, 14, t)
})
heart(dst, red, x+cardW/2, y+cardH/2+4, 40, t)
// The pip in the corner, which is how you know it's a card and not a tile.
heart(dst, red, x+24, y+30, 11, t)
}
// heart, as two lobes and a point.
func heart(dst *image.RGBA, c color.RGBA, cx, cy, r float32, t xform) {
fill(dst, c, func(rz *vector.Rasterizer) {
move := func(px, py float32) { rz.MoveTo(t.at(px, py)) }
cube := func(ax, ay, bx, by, px, py float32) {
a1, a2 := t.at(ax, ay)
b1, b2 := t.at(bx, by)
e1, e2 := t.at(px, py)
rz.CubeTo(a1, a2, b1, b2, e1, e2)
}
bottom := cy + r*0.95
move(cx, bottom)
cube(cx-r*1.15, cy+r*0.18, cx-r*1.0, cy-r*0.95, cx, cy-r*0.28)
cube(cx+r*1.0, cy-r*0.95, cx+r*1.15, cy+r*0.18, cx, bottom)
rz.ClosePath()
})
}
// drawChip is a chip seen face on: a disc, a ring of notches around the rim, and a
// pale centre. Stack four and it reads as money.
func drawChip(dst *image.RGBA, cx, cy, r float32, c color.RGBA, p ogPalette) {
fill(dst, color.RGBA{0, 0, 0, 60}, func(rz *vector.Rasterizer) { circle(rz, cx+3, cy+5, r) })
fill(dst, c, func(rz *vector.Rasterizer) { circle(rz, cx, cy, r) })
// Six notches on the rim, in the room's ink, the way a real chip is edge-marked.
for i := 0; i < 6; i++ {
ang := float64(i) * math.Pi / 3
nx := cx + (r-7)*float32(math.Cos(ang))
ny := cy + (r-7)*float32(math.Sin(ang))
fill(dst, alpha(p.ink, 0.85), func(rz *vector.Rasterizer) { circle(rz, nx, ny, 6) })
}
fill(dst, alpha(p.ink, 0.28), func(rz *vector.Rasterizer) { circle(rz, cx, cy, r-15) })
fill(dst, darken(c, 0.82), func(rz *vector.Rasterizer) { circle(rz, cx, cy, r-21) })
}
// lamp is the light over the table: the accent colour, brightest under the bulb
// and gone by the rail. It's an image rather than a paint, so it can be laid down
// through the table's shape and stop at the felt's edge.
//
// Note the alpha() on the way out. color.RGBA is *alpha-premultiplied*, and the
// first version of this wrote the raw channels next to a low alpha — which is not
// a dim honey glow, it's an invalid colour, and image/draw ran it straight past
// 255 and wrapped it. The card came out with a blue dome over a green stripe. If
// something here ever turns an impossible colour, look for a premultiply first.
func lamp(cx, cy, radius float32, c color.RGBA, strength float64) image.Image {
img := image.NewRGBA(image.Rect(0, 0, ogWidth, ogHeight))
for y := 0; y < ogHeight; y++ {
for x := 0; x < ogWidth; x++ {
dx, dy := float64(float32(x)-cx), float64(float32(y)-cy)
d := math.Sqrt(dx*dx+dy*dy) / float64(radius)
if d >= 1 {
continue
}
// Squared falloff: a lamp is bright under itself and dim at the edges.
img.SetRGBA(x, y, alpha(c, strength*(1-d)*(1-d)))
}
}
return img
}
// verticalGradient is the felt: lit at the top, in shadow at the rail.
func verticalGradient(top, bottom color.RGBA) image.Image {
g := image.NewRGBA(image.Rect(0, 0, 1, ogHeight))
for y := 0; y < ogHeight; y++ {
f := float64(y) / float64(ogHeight-1)
g.SetRGBA(0, y, color.RGBA{
R: lerp(top.R, bottom.R, f),
G: lerp(top.G, bottom.G, f),
B: lerp(top.B, bottom.B, f),
A: 255,
})
}
// One column, stretched sideways forever: the felt doesn't change across the table.
return &stretched{g}
}
type stretched struct{ src *image.RGBA }
func (s *stretched) ColorModel() color.Model { return s.src.ColorModel() }
func (s *stretched) Bounds() image.Rectangle { return image.Rect(0, 0, ogWidth, ogHeight) }
func (s *stretched) At(x, y int) color.Color { return s.src.At(0, y) }
// ---- type -------------------------------------------------------------------
func face(f *opentype.Font, size float64) (font.Face, error) {
return opentype.NewFace(f, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
}
func centerText(dst *image.RGBA, f font.Face, c color.Color, s string, baseline int) {
w := font.MeasureString(f, s)
drawText(dst, f, c, s, (ogWidth-w.Round())/2, baseline)
}
func rightText(dst *image.RGBA, f font.Face, c color.Color, s string, right, baseline int) {
w := font.MeasureString(f, s)
drawText(dst, f, c, s, right-w.Round(), baseline)
}
func drawText(dst *image.RGBA, f font.Face, c color.Color, s string, x, baseline int) {
d := &font.Drawer{
Dst: dst,
Src: &image.Uniform{c},
Face: f,
Dot: fixed.P(x, baseline),
}
d.DrawString(s)
}
// ---- colour -----------------------------------------------------------------
func rgb(r, g, b uint8) color.RGBA { return color.RGBA{r, g, b, 255} }
// alpha returns c at a fraction of its opacity, pre-multiplied, which is what
// image/draw wants.
func alpha(c color.RGBA, f float64) color.RGBA {
return color.RGBA{
R: uint8(float64(c.R) * f),
G: uint8(float64(c.G) * f),
B: uint8(float64(c.B) * f),
A: uint8(255 * f),
}
}
func darken(c color.RGBA, f float64) color.RGBA {
return color.RGBA{
R: uint8(float64(c.R) * f),
G: uint8(float64(c.G) * f),
B: uint8(float64(c.B) * f),
A: c.A,
}
}
func lerp(a, b uint8, f float64) uint8 {
return uint8(float64(a) + (float64(b)-float64(a))*f)
}

View File

@@ -0,0 +1,166 @@
package web
import (
"bytes"
"image/png"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
)
// The casino had no share card for as long as it existed, and the reason is worth
// keeping in a test rather than in a memory: every route was behind sign-in, so a
// link pasted into a chat window unfurled as whatever the *auth screen* said. Meta
// tags on a page nobody unauthenticated can fetch are meta tags nobody reads.
//
// So the two things these tests hold down are: a stranger gets a real page at the
// front door, and a stranger gets the picture. Everything else stays shut.
func TestTheDoorIsOpenToStrangers(t *testing.T) {
s := newCasino(t)
mux := http.NewServeMux()
s.casinoRoutes(mux)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/games", nil))
if w.Code != http.StatusOK {
t.Fatalf("GET /games as a stranger = %d, want 200: the front door has to be a page an unfurl bot can read", w.Code)
}
body := w.Body.String()
for _, want := range []string{
`property="og:image"`,
`property="og:title"`,
`property="og:description"`,
`name="twitter:card"`,
"og.png",
"Sign in",
} {
if !strings.Contains(body, want) {
t.Errorf("the door is missing %q", want)
}
}
if strings.Contains(body, "unknown page") {
t.Fatal("games_door is not in the games template set in server.go")
}
// A door you can play from is not a door.
if strings.Contains(body, `data-move=`) {
t.Error("the door is serving a table to somebody who has not signed in")
}
}
func TestTheTablesStayShutToStrangers(t *testing.T) {
s := newCasino(t)
mux := http.NewServeMux()
s.casinoRoutes(mux)
for _, path := range []string{"/games/blackjack", "/games/holdem", "/games/uno", "/games/trivia", "/games/hangman", "/games/solitaire"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != http.StatusFound {
t.Errorf("GET %s as a stranger = %d, want a 302 to sign-in", path, w.Code)
}
}
}
func TestTheShareCardNeedsNoSignIn(t *testing.T) {
s := newCasino(t)
mux := http.NewServeMux()
s.casinoRoutes(mux)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/games/og.png", nil))
if w.Code != http.StatusOK {
t.Fatalf("GET /games/og.png as a stranger = %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "image/png" {
t.Errorf("Content-Type = %q, want image/png", ct)
}
img, err := png.Decode(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("the share card is not a PNG: %v", err)
}
if b := img.Bounds(); b.Dx() != ogWidth || b.Dy() != ogHeight {
t.Errorf("share card is %dx%d, want %dx%d — every unfurler crops to that", b.Dx(), b.Dy(), ogWidth, ogHeight)
}
}
// An og:image that 404s is worth exactly as much as no og:image, and the two ways
// the casino is reachable disagree about where the picture lives: on the games host
// it's /og.png (hostRouter puts the /games back on), and anywhere else it's
// /games/og.png. So take the URL the page actually advertises and go and get it.
func TestTheAdvertisedShareCardIsReallyThere(t *testing.T) {
for _, host := range []string{"", "games.parodia.dev"} {
name := "no games host"
if host != "" {
name = host
}
t.Run(name, func(t *testing.T) {
s := newCasino(t)
s.cfg.Games.Host = host
mux := http.NewServeMux()
s.casinoRoutes(mux)
srv := s.hostRouter(mux) // what production actually serves
// Ask the door where its picture is.
w := httptest.NewRecorder()
door := httptest.NewRequest("GET", "/games", nil)
if host != "" {
door.Host = host
}
srv.ServeHTTP(w, door)
img := ogImageURL(t, w.Body.String())
// Then go and fetch it, the way a chat server would.
u, err := url.Parse(img)
if err != nil {
t.Fatalf("og:image %q is not a URL: %v", img, err)
}
if u.Host == "" {
t.Fatalf("og:image %q is relative — no unfurler will resolve it", img)
}
w = httptest.NewRecorder()
get := httptest.NewRequest("GET", u.Path, nil)
get.Host = u.Host
srv.ServeHTTP(w, get)
if w.Code != http.StatusOK {
t.Fatalf("the door advertises og:image %s, and fetching it gives %d", img, w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "image/png" {
t.Errorf("og:image %s served %q, want image/png", img, ct)
}
})
}
}
func ogImageURL(t *testing.T, body string) string {
t.Helper()
m := regexp.MustCompile(`property="og:image" content="([^"]+)"`).FindStringSubmatch(body)
if m == nil {
t.Fatal("the door serves no og:image at all")
}
return m[1]
}
// Both rooms have to have a card, because the room is picked at request time and a
// missing one serves zero bytes rather than an error.
func TestTheShareCardKnowsBothRooms(t *testing.T) {
cards, err := drawShareCards()
if err != nil {
t.Fatal(err)
}
for _, rm := range []room{roomDay, roomNight} {
if len(cards[rm.Slug]) == 0 {
t.Errorf("no share card for %s", rm.Slug)
}
}
if len(cards) != len(ogPalettes) {
t.Errorf("drew %d cards for %d palettes", len(cards), len(ogPalettes))
}
}

View File

@@ -2,10 +2,12 @@ package web
import (
"net/http"
"strings"
"time"
"pete/internal/games/blackjack"
"pete/internal/games/hangman"
"pete/internal/games/holdem"
"pete/internal/games/klondike"
"pete/internal/games/trivia"
"pete/internal/games/uno"
@@ -28,9 +30,10 @@ type gameTeaser struct {
Blurb string
}
var comingSoon = []gameTeaser{
{Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
}
// comingSoon is empty, and that is the point: every game the plan named is now on
// the felt. Leave it here — the lobby renders nothing for an empty list, and the
// next game to be dreamed up goes in it.
var comingSoon = []gameTeaser{}
// betDenominations are the chips you build a bet out of.
var betDenominations = []int64{5, 25, 100, 500}
@@ -65,7 +68,12 @@ func roomAt(hour int) room {
// Giving it its own page struct is what stops the news app's furniture drifting
// back in one convenient field at a time.
type gamesPage struct {
Room room
Room room
// URL and OGImage are here for the share card, and they are absolute because
// Open Graph will not resolve a relative one: the thing reading those tags is
// a chat server, and it has no page to resolve against. See casinoURL.
URL string // this page, at the address a player would type
OGImage string // the share card, at the same
User *SessionUser
Cap int64
RakePct int
@@ -78,6 +86,10 @@ type gamesPage struct {
Quizzes []trivia.Tier // trivia's three difficulties
Rungs int // how long the trivia ladder is
Tables []uno.Tier // uno's three tables, and how many bots sit at each
NoMercy []uno.Tier // the same three, playing the other rules
MercyLimit int // the hand that ends you in No Mercy
Stakes []holdem.Tier // hold'em's three tables, by blinds
MaxBots int // how many seats hold'em will fill with bots
}
// casinoRoutes hangs every table off the mux.
@@ -88,11 +100,13 @@ type gamesPage struct {
// silently stops including the newest game.
func (s *Server) casinoRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /games", s.handleLobby)
mux.HandleFunc("GET /games/og.png", s.handleGamesOG) // the share card, and the one games page with no door on it
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /games/hangman", s.handleHangman)
mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
mux.HandleFunc("GET /games/trivia", s.handleTrivia)
mux.HandleFunc("GET /games/uno", s.handleUno)
mux.HandleFunc("GET /games/holdem", s.handleHoldem)
mux.HandleFunc("GET /api/games/table", s.handleTable)
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
@@ -110,8 +124,21 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart)
mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer)
mux.HandleFunc("POST /api/games/uno/start", s.handleUnoStart)
mux.HandleFunc("POST /api/games/uno/sit", s.handleUnoSit)
mux.HandleFunc("POST /api/games/uno/move", s.handleUnoMove)
mux.HandleFunc("POST /api/games/uno/leave", s.handleUnoLeave)
mux.HandleFunc("GET /api/games/uno/tables", s.handleUnoLobby)
mux.HandleFunc("GET /api/games/uno/stream", s.handleTableStream)
mux.HandleFunc("GET /api/games/uno/chat", s.handleTableChat)
mux.HandleFunc("POST /api/games/uno/say", s.handleTableSay)
mux.HandleFunc("POST /api/games/holdem/sit", s.handleHoldemSit)
mux.HandleFunc("POST /api/games/holdem/move", s.handleHoldemMove)
mux.HandleFunc("POST /api/games/holdem/leave", s.handleHoldemLeave)
mux.HandleFunc("GET /api/games/holdem/tables", s.handleHoldemLobby)
mux.HandleFunc("GET /api/games/holdem/stream", s.handleTableStream)
mux.HandleFunc("GET /api/games/holdem/chat", s.handleTableChat)
mux.HandleFunc("POST /api/games/holdem/say", s.handleTableSay)
}
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
@@ -130,10 +157,38 @@ func (s *Server) requirePlayer(w http.ResponseWriter, r *http.Request) bool {
return false
}
// casinoURL turns a route on the mux ("/games/uno") into the absolute address a
// player would actually type, which is what an unfurl bot has to be handed.
//
// The two halves of it are the same fact seen twice. On the games host the casino
// sits at the root and hostRouter puts the /games prefix back on the way in, so
// the public address of a page is its route with that prefix taken off. Without a
// games host — development, and only development — there is no rewrite and no
// prefix to remove, and the route *is* the address. Getting this backwards points
// og:image at a URL that 404s, which is exactly as visible as no og:image at all.
func (s *Server) casinoURL(r *http.Request, route string) string {
if h := strings.TrimSpace(s.cfg.Games.Host); h != "" {
path := strings.TrimPrefix(route, "/games")
if path == "" {
path = "/"
}
return "https://" + h + path
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return scheme + "://" + r.Host + route
}
func (s *Server) gamesPage(r *http.Request) gamesPage {
return gamesPage{
Room: roomAt(time.Now().Hour()),
User: s.auth.userFromRequest(r), // requirePlayer ran first, so this is non-nil
Room: roomAt(time.Now().Hour()),
URL: s.casinoURL(r, r.URL.Path),
OGImage: s.casinoURL(r, "/games/og.png"),
// Nil on the front door, and only there — every other page ran requirePlayer
// first. A template that reads .User has to guard it.
User: s.auth.userFromRequest(r),
Cap: storage.MaxChipsOnTable,
RakePct: int(blackjack.DefaultRules().RakePct * 100),
Soon: comingSoon,
@@ -145,11 +200,28 @@ func (s *Server) gamesPage(r *http.Request) gamesPage {
Quizzes: trivia.Tiers,
Rungs: trivia.Rungs,
Tables: uno.Tiers,
NoMercy: uno.NoMercyTiers,
MercyLimit: uno.MercyLimit,
Stakes: holdem.Tiers,
MaxBots: holdem.MaxBots,
}
}
// handleLobby is the one page in the casino that answers a stranger.
//
// Every table bounces an anonymous visitor straight to sign-in, which is right:
// there is money on them. But the front door cannot do that, because the front
// door is what people paste into a chat window, and a 302 to an auth screen has
// nothing in it to make a preview out of — the casino unfurled as the bare word
// "parodia.dev" for as long as it has existed. So the door is a real page, served
// to anybody, carrying the share card and a way in. You still can't play from it.
func (s *Server) handleLobby(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
if !s.gamesReady() {
http.NotFound(w, r)
return
}
if u := s.auth.userFromRequest(r); u == nil || u.MatrixUser(s.cfg.Games.MatrixServer) == "" {
s.render(w, "games_door", s.gamesPage(r))
return
}
s.render(w, "games", s.gamesPage(r))
@@ -189,3 +261,10 @@ func (s *Server) handleUno(w http.ResponseWriter, r *http.Request) {
}
s.render(w, "uno", s.gamesPage(r))
}
func (s *Server) handleHoldem(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "holdem", s.gamesPage(r))
}

View File

@@ -13,6 +13,7 @@ import (
"pete/internal/games/blackjack"
"pete/internal/games/cards"
"pete/internal/games/hangman"
"pete/internal/games/holdem"
"pete/internal/games/klondike"
"pete/internal/games/trivia"
"pete/internal/games/uno"
@@ -90,35 +91,62 @@ func viewCard(c cards.Card) cardView {
// genuinely not in the payload. A field the browser is told to ignore is a field
// somebody reads in devtools.
type handView struct {
Phase string `json:"phase"`
Phase string `json:"phase"`
Bet int64 `json:"bet"` // everything staked this deal, across every hand
Hands []spotView `json:"hands"`
Active int `json:"active"`
Dealer []cardView `json:"dealer"`
Hole bool `json:"hole"` // true: the dealer has a face-down card
DTotal int `json:"dealer_total"` // what the *shown* dealer cards add to
Outcome string `json:"outcome,omitempty"`
Payout int64 `json:"payout,omitempty"`
Rake int64 `json:"rake,omitempty"`
Net int64 `json:"net"`
Double bool `json:"can_double"`
Split bool `json:"can_split"`
}
// spotView is one of the player's hands: its cards, its own chips, its own
// verdict. Before split there was only ever one and it was flattened into the
// hand itself; a split is the moment that stops being true.
type spotView struct {
Cards []cardView `json:"cards"`
Bet int64 `json:"bet"`
Player []cardView `json:"player"`
Dealer []cardView `json:"dealer"`
Hole bool `json:"hole"` // true: the dealer has a face-down card
Total int `json:"total"`
Soft bool `json:"soft"`
DTotal int `json:"dealer_total"` // what the *shown* dealer cards add to
Doubled bool `json:"doubled"`
Done bool `json:"done"`
Outcome string `json:"outcome,omitempty"`
Payout int64 `json:"payout,omitempty"`
Rake int64 `json:"rake,omitempty"`
Net int64 `json:"net"`
Double bool `json:"can_double"`
}
func viewHand(st blackjack.State) handView {
v := handView{
Phase: string(st.Phase),
Bet: st.Bet,
Active: st.Active,
Outcome: string(st.Outcome),
Payout: st.Payout,
Rake: st.Rake,
Net: st.Net(),
Double: st.CanDouble(),
Split: st.CanSplit(),
}
for _, c := range st.Player {
v.Player = append(v.Player, viewCard(c))
for _, h := range st.Hands {
s := spotView{
Bet: h.Bet,
Doubled: h.Doubled,
Done: h.Done,
Outcome: string(h.Outcome),
Payout: h.Payout,
}
for _, c := range h.Cards {
s.Cards = append(s.Cards, viewCard(c))
}
s.Total, s.Soft = h.Value()
v.Hands = append(v.Hands, s)
}
v.Total, v.Soft = blackjack.HandValue(st.Player)
dealer := st.Dealer
if st.Phase == blackjack.PhasePlayer && len(dealer) > 1 {
@@ -138,6 +166,7 @@ func viewHand(st blackjack.State) handView {
type eventView struct {
Kind string `json:"kind"`
Card *cardView `json:"card,omitempty"`
Hand int `json:"hand"` // which of the player's hands the card landed on
Text string `json:"text,omitempty"`
}
@@ -148,7 +177,7 @@ func viewEvents(evs []blackjack.Event, phase blackjack.Phase) []eventView {
out := make([]eventView, 0, len(evs))
dealerCards := 0
for _, e := range evs {
v := eventView{Kind: e.Kind, Text: e.Text}
v := eventView{Kind: e.Kind, Text: e.Text, Hand: e.Hand}
if e.Card != nil {
c := viewCard(*e.Card)
v.Card = &c
@@ -197,6 +226,9 @@ type tableView struct {
Uno *unoView `json:"uno,omitempty"`
UnoEvents []unoEventView `json:"uno_events,omitempty"`
Holdem *holdemView `json:"holdem,omitempty"`
HoldemEvents []holdemEventView `json:"holdem_events,omitempty"`
Rake float64 `json:"rake_pct"`
}
@@ -258,12 +290,69 @@ func (s *Server) table(user string) (tableView, error) {
tv := viewTrivia(g, time.Now())
v.Trivia = &tv
case gameUno:
// A seated UNO player's cards are in game_tables, not here — this row is only
// their occupancy claim. Load the table and render it as their own seat sees it.
if live.TableID == "" {
return s.dropUnreadable(user, v, fmt.Errorf("uno row with no table"))
}
t, tableSeats, err := storage.LoadTable(live.TableID)
if errors.Is(err, storage.ErrNoSuchTable) {
return s.dropUnreadable(user, v, fmt.Errorf("uno table %s gone", live.TableID))
}
if err != nil {
return tableView{}, err
}
_, seat, err := storage.PlayerSeat(user)
if err != nil {
return tableView{}, err
}
var g uno.State
if err := json.Unmarshal(live.State, &g); err != nil {
if err := json.Unmarshal(t.State, &g); err != nil {
return s.dropUnreadable(user, v, err)
}
uv := viewUno(g)
uv := viewUno(g, seat)
for _, ts := range tableSeats {
if ts.Seat == seat {
uv.BoughtIn = ts.Staked
break
}
}
v.Uno = &uv
case gameHoldem:
// A seated hold'em player's cards are in game_tables, not here — this row is
// only their occupancy claim, so its state is empty. Load the table and render
// it as their own seat sees it.
if live.TableID == "" {
return s.dropUnreadable(user, v, fmt.Errorf("holdem row with no table"))
}
t, tableSeats, err := storage.LoadTable(live.TableID)
if errors.Is(err, storage.ErrNoSuchTable) {
// The table closed under them (reaped, or the last hand cashed them out).
// Their claim is stale; clear it so they can sit down again.
return s.dropUnreadable(user, v, fmt.Errorf("holdem table %s gone", live.TableID))
}
if err != nil {
return tableView{}, err
}
_, seat, err := storage.PlayerSeat(user)
if err != nil {
return tableView{}, err
}
var g holdem.State
if err := json.Unmarshal(t.State, &g); err != nil {
return s.dropUnreadable(user, v, err)
}
hv := viewHoldem(g, seat)
// bought_in is a per-player figure — "you bought in for X" — but the engine's
// BoughtIn is the table's total across every human. The player's own stake is
// border accounting, which lives in storage; take it from their seat row.
for _, ts := range tableSeats {
if ts.Seat == seat {
hv.BoughtIn = ts.Staked
break
}
}
v.Holdem = &hv
default:
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
}
@@ -481,31 +570,45 @@ func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) {
move := blackjack.Move(req.Move)
// A double doubles the stake, so the extra chips have to be taken before the
// move is applied — and if they aren't there, the move simply isn't legal.
// Take them first: if the engine then refuses the move, they go straight back.
doubled := false
if move == blackjack.Double {
// Double and split are the two moves that put more chips on the table *after*
// the cards are out, so the money has to move before the move does — and if the
// chips aren't there, the move simply isn't legal. Take them first: if the
// engine then refuses the move, they go straight back.
//
// Both cost the active hand's bet, not the whole stake. Once a hand can be
// split those are different numbers, and doubling the third hand of a split for
// the total of all three would be quite a thing to discover in production.
var staked int64
switch move {
case blackjack.Double:
if !st.CanDouble() {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only double on the first two cards"})
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only double on the first two cards of a hand"})
return
}
if err := storage.Stake(user, st.Bet); err != nil {
staked = st.DoubleCost()
case blackjack.Split:
if !st.CanSplit() {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only split two cards of the same rank, and only up to four hands"})
return
}
staked = st.SplitCost()
}
if staked > 0 {
if err := storage.Stake(user, staked); err != nil {
if errors.Is(err, storage.ErrInsufficientChips) {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to double"})
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that"})
return
}
slog.Error("games: stake double", "user", user, "err", err)
slog.Error("games: stake raise", "user", user, "move", move, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
doubled = true
}
next, evs, err := blackjack.ApplyMove(st, move)
if err != nil {
if doubled {
_ = storage.Award(user, st.Bet) // the move didn't happen; neither did the raise
if staked > 0 {
_ = storage.Award(user, staked) // the move didn't happen; neither did the raise
}
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that move isn't legal here"})
return
@@ -521,6 +624,7 @@ const (
gameSolitaire = "solitaire"
gameTrivia = "trivia"
gameUno = "uno"
gameHoldem = "holdem"
)
// finished is what commit needs to know about a game it's writing back: enough
@@ -539,58 +643,44 @@ type finished struct {
}
// commit writes a game back and settles it if it's over. It is the money path,
// and both games go through it so that neither has to re-derive an ordering
// that took a while to get right.
// and every game goes through it so that none has to re-derive an ordering that
// took a while to get right.
//
// The ordering now lives in storage.CommitHand, which does the whole thing —
// seat, pay, record, clear, touch — in one transaction. It used to be four
// autocommit statements here, carefully sequenced so that a crash between them
// cost the player as little as possible. That was survivable for a game owned by
// one player. It is not survivable for a game with a pot in it, 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 them twice.
//
// It returns the table as it now stands. ok is false when it has already
// written an error response and the caller must simply return.
func (s *Server) commit(w http.ResponseWriter, user string, f finished) (tableView, bool) {
// Seat the game before doing anything else with it — even one that is already
// over, because a blackjack natural settles the instant it's dealt. The insert
// is what enforces one game at a time, and it has to happen for *every* new
// one: a natural dealt on top of a game already in progress would otherwise
// settle, clear the felt, and take the other game's stake down with it.
live := storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2}
save := storage.SaveLiveHand
if f.Fresh {
save = storage.StartLiveHand
}
if err := save(user, live); err != nil {
if errors.Is(err, storage.ErrHandInProgress) {
// Somebody was already sitting here. This game was never seated, so the
// chips it staked go back: the player is in one game, not two.
_ = storage.Award(user, f.Bet)
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"})
return tableView{}, false
}
slog.Error("games: save game", "user", user, "game", f.Game, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return tableView{}, false
}
if f.Done {
// Pay first, then clear. If Pete dies between the two, the player has been
// paid and the worst case is a settled game still showing on the felt —
// which reads as done and can be cleared. The other order loses them a win.
if err := storage.Award(user, f.Payout); err != nil {
slog.Error("games: award", "user", user, "payout", f.Payout, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return tableView{}, false
}
if err := storage.RecordHand(storage.Hand{
err := storage.CommitHand(user, storage.Commit{
Live: storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2},
Fresh: f.Fresh,
Stake: f.Bet,
Done: f.Done,
Payout: f.Payout,
Audit: storage.Hand{
MatrixUser: user, Game: f.Game,
Bet: f.Bet, Payout: f.Payout, Rake: f.Rake,
Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2,
}); err != nil {
slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's game
}
if err := storage.ClearLiveHand(user); err != nil {
slog.Error("games: clear game", "user", user, "err", err)
}
},
})
switch {
case errors.Is(err, storage.ErrHandInProgress):
// Somebody was already sitting here. The game was never seated and the chips
// it staked have gone back — in the same transaction that refused it.
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"})
return tableView{}, false
case err != nil:
slog.Error("games: commit", "user", user, "game", f.Game, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return tableView{}, false
}
storage.Touch(user)
v, err := s.table(user)
if err != nil {
slog.Error("games: table", "user", user, "err", err)

View File

@@ -99,8 +99,8 @@ func TestDealTakesTheStakeAndHidesTheHoleCard(t *testing.T) {
if v.Hand == nil {
t.Fatal("deal returned no hand")
}
if len(v.Hand.Player) != 2 {
t.Fatalf("player was dealt %d cards, want 2", len(v.Hand.Player))
if len(v.Hand.Hands[0].Cards) != 2 {
t.Fatalf("player was dealt %d cards, want 2", len(v.Hand.Hands[0].Cards))
}
// A natural settles on the spot and legitimately shows both dealer cards.
@@ -181,12 +181,14 @@ func TestOneHandAtATime(t *testing.T) {
t.Fatalf("the live hand went missing: %v", err)
}
var st struct {
Player []struct{} `json:"player"`
Hands []struct {
Cards []struct{} `json:"cards"`
} `json:"hands"`
}
if err := json.Unmarshal(live.State, &st); err != nil {
t.Fatal(err)
}
if len(st.Player) != len(first.Hand.Player) {
if len(st.Hands) != 1 || len(st.Hands[0].Cards) != len(first.Hand.Hands[0].Cards) {
t.Fatal("the refused deal replaced the hand in progress")
}
}

View File

@@ -23,6 +23,7 @@ func TestEveryCasinoPageRenders(t *testing.T) {
"/games/solitaire",
"/games/trivia",
"/games/uno",
"/games/holdem",
}
mux := http.NewServeMux()

View File

@@ -0,0 +1,89 @@
package web
import (
"errors"
"pete/internal/storage"
)
// errNotDue is a timeout that turned out to have nothing to do. The clock scanned
// a table as expired, took the lock, and found — on decoding the state — that the
// seat to act is not in fact a waiting human: a real move landed in the instant
// between the scan and the lock and had not yet bumped the version the clock
// checked. It is not an error, it is the race resolving the right way, so the
// clock swallows it silently rather than logging.
var errNotDue = errors.New("games: nothing to time out")
// The table runtime: the game-agnostic half of a shared table.
//
// A shared table has two writers where a solo game had one — an HTTP move, and a
// turn clock acting for whoever walked away — and the whole job of this layer is
// to let those two coexist without either trusting the other. The rule that makes
// that work is the database's version column: every write is conditional on the
// version its writer read, so the two can race freely and exactly one wins.
//
// Everything specific to a game — how a move advances it, whose turn it is now,
// what a settled hand pays — lives behind the tableGame interface. The clock, the
// lock discipline and the SSE publish do not know whether they are driving poker
// or UNO, and that is what lets Phase B ship before any engine is multiway.
// turnSeconds is how long a human has to act before the clock acts for them. Long
// enough to read the table and think, short enough that a walked-away player does
// not hold three others hostage.
const turnSeconds = 30
// bootGrace is how far the turn clock shoves every live deadline out on boot. A
// deploy takes the in-memory clock with it, so without this the first tick after
// a restart would find every deadline in the casino already past and auto-act the
// whole room at once.
const bootGrace = 30
// step is what a game hands back after a move or a timeout: the new state, ready
// to persist, plus everything the runtime needs to settle and to schedule.
//
// The chips are inside State — a hand ending moves the pot within the blob and
// credits nobody — so there is no payout field. What comes out is the state, the
// audit of any hand that just ended, and the clock's next deadline.
type step struct {
// State is the engine's whole state, marshalled, ready for the table blob.
State []byte
// Phase is lifted out of the state so the lobby can read it without decoding.
Phase string
// HandNo is the hand this state is on. It advances when a new hand is dealt,
// and it is the audit key now that a seed no longer reproduces a shared hand.
HandNo int64
// Deadline is when the clock must next act, or 0 for none. It is nonzero only
// when the turn has landed on a *present* human: a bot resolves inside the move
// and an away human is auto-acted on sight, so neither is ever waited for.
Deadline int64
// Audit is the per-seat record of a hand that ended in this step. Empty if no
// hand settled.
Audit []storage.Hand
// Events is the script the felt plays back — the same shape every solo game
// already returns. It is what the SSE frame and the acting player both animate.
Events any
}
// tableGame is everything the runtime needs from an engine to run it at a shared
// table. Each multiplayer game implements it; the clock and the handlers hold it
// as an interface so they stay game-agnostic.
type tableGame interface {
// name is the storage key and the lobby label: "holdem", "uno", "blackjack".
name() string
// timeout acts for the human whose clock has expired — check if the rules
// allow it, fold otherwise — and advances the table as far as the next
// decision, exactly as a real move would. seats is the current roster, so the
// engine can mark the timed-out player away.
//
// It returns ErrNotDue (via a nil step, see runClockTable) if, on decode, the
// seat to act is not in fact a waiting human — which happens when a real move
// landed in the same instant the clock fired and the version had not yet been
// bumped when the clock scanned.
timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error)
// stacks reports the chips in front of each seat, index-aligned with the
// table's seat rows, so the abandoned-table reaper can cash out a walked-away
// player without knowing how their game is played.
stacks(state []byte) ([]int64, error)
}

299
internal/web/games_table.go Normal file
View File

@@ -0,0 +1,299 @@
package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"pete/internal/storage"
)
// The runtime's public surface: the lobby a player finds a table in, the stream
// that keeps their felt live while other people play on it, and the chat that
// runs along the rail. None of this knows poker from UNO — it is keyed on table
// id and moves opaque frames — which is what lets it serve every shared table.
// ---- the lobby -------------------------------------------------------------
// handleHoldemLobby and handleUnoLobby list the tables of their game with a seat
// going spare. A table with every chair taken is not shown, because a lobby you
// cannot join from is just a list; bots keep every open table populated, so there
// is always something to sit down at.
func (s *Server) handleHoldemLobby(w http.ResponseWriter, r *http.Request) {
s.lobby(w, r, gameHoldem)
}
func (s *Server) handleUnoLobby(w http.ResponseWriter, r *http.Request) {
s.lobby(w, r, gameUno)
}
func (s *Server) lobby(w http.ResponseWriter, r *http.Request, game string) {
if _, ok := s.player(w, r); !ok {
return
}
tables, err := storage.LobbyTables(game, 50)
if err != nil {
slog.Error("games: lobby", "game", game, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
open := make([]storage.TableSummary, 0, len(tables))
for _, t := range tables {
if t.Humans < t.Seats {
open = append(open, t)
}
}
writeJSON(w, map[string]any{"tables": open})
}
// ---- the live stream -------------------------------------------------------
// streamPing is how often the stream sends a comment down an idle connection.
// EventSource reconnects itself, but a proxy will hang up a stream that has gone
// quiet, and a heartbeat well under the usual idle timeout keeps it open.
const streamPing = 25 * time.Second
// handleHoldemStream is the player's live view of their table: a Server-Sent
// Events stream that carries a nudge every time the table changes and every line
// of chat as it is said.
//
// It obeys the one rule that keeps a stream from bricking the app (rule from
// games_hub.go): it touches the database exactly once, at the top, to find which
// table the player is at. After that it only ever reads its channel. Holding a
// query open for the life of a stream would hold the single pooled connection for
// the life of a stream, and one idle subscriber would take the whole site down.
func (s *Server) handleTableStream(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
tableID, err := storage.TableOf(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: stream table of", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // tell nginx-likes not to buffer us
w.WriteHeader(http.StatusOK)
ch, unsubscribe := s.hub.subscribe(tableID)
defer unsubscribe()
// Nudge the client to fetch straight away, so a stream that opens after a change
// already missed does not sit blank until the next one.
fmt.Fprint(w, "event: sync\ndata: {}\n\n")
flusher.Flush()
ping := time.NewTicker(streamPing)
defer ping.Stop()
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case <-ping.C:
fmt.Fprint(w, ": ping\n\n")
flusher.Flush()
case f, open := <-ch:
if !open {
return
}
fmt.Fprintf(w, "data: %s\n\n", f.Data)
flusher.Flush()
}
}
}
// ---- chat ------------------------------------------------------------------
// handleHoldemChat reads the recent rail of a player's table, oldest first, with
// their own lines flagged so the felt can lay them out on the right.
func (s *Server) handleTableChat(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
tableID, err := storage.TableOf(user)
if errors.Is(err, storage.ErrNoLiveHand) {
writeJSON(w, map[string]any{"chat": []storage.ChatLine{}})
return
}
if err != nil {
slog.Error("games: chat table of", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
lines, err := storage.Chat(tableID, 50)
if err != nil {
slog.Error("games: chat", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
for i := range lines {
lines[i].Mine = false // filled below; the DB does not know who is asking
}
name := s.displayName(r, user)
for i := range lines {
lines[i].Mine = lines[i].Name == name
}
writeJSON(w, map[string]any{"chat": lines})
}
// handleHoldemSay records a line of chat and fans it to the table. The line is
// stamped with the hand it was said during — the one question chat at a money
// table ever really raises — and pushed to every open stream so it lands on the
// rail in real time.
func (s *Server) handleTableSay(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req struct {
Body string `json:"body"`
}
if err := decodeJSON(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
tableID, err := storage.TableOf(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: say table of", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
name := s.displayName(r, user)
line, err := storage.Say(tableID, user, name, req.Body)
if errors.Is(err, storage.ErrBadAmount) {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "say something"})
return
}
if err != nil {
slog.Error("games: say", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.publishChat(tableID, line)
line.Mine = true
writeJSON(w, line)
}
// publishChat fans a chat line to everyone watching a table. Like publishTable it
// is a non-blocking send after the database work is done — the line is already
// saved, this only delivers it — so a slow subscriber costs itself a missed line
// (which its next chat fetch recovers), never the lock.
func (s *Server) publishChat(tableID string, line storage.ChatLine) {
if s.hub.watchers(tableID) == 0 {
return
}
data, err := json.Marshal(map[string]any{"type": "chat", "line": line})
if err != nil {
return
}
s.hub.publish(tableID, hubFrame{Data: data})
}
// ---- the abandoned-table reaper --------------------------------------------
// runTableReaper cashes out tables that everyone has walked away from, on the
// same slow timer as the session reaper. It is the seated-player counterpart to
// that loop: a walked-away poker player's chips are inside a table blob, where
// the session reaper (which reads the game_chips stack) cannot see them, so
// without this they would sit in limbo until the player happened to come back.
func (s *Server) runTableReaper(ctx context.Context) {
ticker := time.NewTicker(reaperInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.reapAbandonedTables()
}
}
}
// reapAbandonedTables finds the tables nobody is coming back to and closes each,
// sending every seated human home with whatever is in front of them.
func (s *Server) reapAbandonedTables() {
cutoff := time.Now().Unix() - int64(storage.SessionIdleAfter.Seconds())
refs, err := storage.AbandonedTables(cutoff)
if err != nil {
slog.Error("games: abandoned tables", "err", err)
return
}
for _, ref := range refs {
s.reapTable(ref)
}
}
// reapTable cashes out and closes one abandoned table, under its lock and only if
// it is still the version the scan saw — so a player who wandered back to the
// felt in the same instant keeps their seat and their chips.
func (s *Server) reapTable(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
}
if err != nil {
return err
}
if t.Version != ref.Version {
return nil // somebody came back between the scan and here
}
game := s.games()[t.Game]
if game == nil {
slog.Error("games: reaper over unknown game", "game", t.Game, "table", t.ID)
return nil
}
stacks, err := game.stacks(t.State)
if err != nil {
return err
}
var humans []storage.ReapSeat
for _, seat := range seats {
if seat.MatrixUser == "" {
continue
}
amount := int64(0)
if seat.Seat >= 0 && seat.Seat < len(stacks) {
amount = stacks[seat.Seat]
}
humans = append(humans, storage.ReapSeat{
Seat: seat.Seat, MatrixUser: seat.MatrixUser, Amount: amount,
})
}
if err := storage.ReapTable(storage.Reap{TableID: t.ID, Version: t.Version, Humans: humans}); err != nil {
if errors.Is(err, storage.ErrStaleTable) {
return nil
}
return err
}
slog.Info("games: reaped abandoned table", "table", t.ID, "humans", len(humans))
return nil
})
if err != nil {
slog.Error("games: reap table", "table", ref.ID, "err", err)
}
}

View File

@@ -5,56 +5,59 @@ import (
"errors"
"log/slog"
"net/http"
"time"
"pete/internal/games/blackjack"
"pete/internal/games/uno"
"pete/internal/storage"
)
// UNO, played for chips against bots.
// UNO, played for chips at a shared table.
//
// The seam is the same as every other table, but there is one thing here that no
// other table has: opponents. The obvious way to give a browser opponents is a
// socket, and the plan says solo UNO must not need one — so it doesn't. A move
// goes up, and what comes back is the player's move *plus every bot turn it
// handed off to*, as a script of events. One request, one round of the table.
// Like hold'em, this is a session: you sit down with a stack, ante into a pot each
// hand, and leave with what is in front of you. Chips cross the border twice —
// sit-down and get-up — and every ante and pot in between moves inside the state
// blob. Solo play is just a table nobody else has joined.
//
// What the browser is allowed to see: its own hand, the card in play, the colour
// in play, and how many cards each bot is holding. Not the deck, not a bot's
// hand, not even the face of a card a bot drew. That last one is most of the
// deck, and it is the thing that would turn a game of counting cards into a game
// of reading the network tab.
// The seam is the same as hold'em: one request plays a human's move plus every
// bot turn it hands off to, returned as a script the felt animates. What a viewer
// is allowed to see is their own hand, the card and colour in play, the pot, and
// how many cards each other seat holds — never the deck, another seat's hand, or
// the face of a card a bot drew. The engine emits every seat's hand (a shared
// stream cannot know who is watching); the redaction that keeps a hand private is
// here, and a missed case fans it to every subscriber.
// unoCardView is one card, ready to draw. The browser gets the colour and the
// face as words, not as the engine's integers — the same bargain the blackjack
// table makes, and for the same reason: the browser draws faces, not logic.
// unoCardView is one card, ready to draw: colour and face as words, not the
// engine's integers.
type unoCardView struct {
Color string `json:"color"` // "red" | "blue" | "yellow" | "green" | "wild"
Value string `json:"value"` // "0"…"9" | "skip" | "reverse" | "+2" | "wild" | "+4"
Wild bool `json:"wild"` // it's a wild, whatever colour it was played as
Color string `json:"color"`
Value string `json:"value"`
Wild bool `json:"wild"`
}
func viewUnoCard(c uno.Card) unoCardView {
return unoCardView{
Color: c.Color.String(),
Value: c.Value.String(),
Wild: c.IsWild(),
}
return unoCardView{Color: c.Color.String(), Value: c.Value.String(), Wild: c.IsWild()}
}
// unoSeatView is one seat at the table: a name, and a number of cards. A bot's
// cards are a *count*. There is no field here for what they are.
// unoSeatView is one seat at the table: a name, a card count, and a stack. A
// seat's cards are a *count* — there is no field here for what they are.
type unoSeatView struct {
Name string `json:"name"`
Cards int `json:"cards"`
You bool `json:"you"`
Uno bool `json:"uno"` // down to one card
Name string `json:"name"`
Bot bool `json:"bot"`
You bool `json:"you"`
Cards int `json:"cards"`
Stack int64 `json:"stack"`
Ante int64 `json:"ante,omitempty"`
Uno bool `json:"uno"` // down to one card
Called bool `json:"called"` // …and said so. Uno true and this false is a seat you can catch
Out bool `json:"out"` // not in this hand — mercy-killed, or sitting one out
}
// unoView is a game as its player may see it.
// unoView is a table as one seat may see it.
type unoView struct {
Tier uno.Tier `json:"tier"`
Seats []unoSeatView `json:"seats"`
Tier uno.Tier `json:"tier"`
YourSeat int `json:"your_seat"`
Seats []unoSeatView `json:"seats"`
Hand []unoCardView `json:"hand"` // yours, and only yours
Playable []int `json:"playable"` // which of them can legally go down
@@ -62,86 +65,138 @@ type unoView struct {
Color string `json:"color"` // the colour in play, which a wild renames
Deck int `json:"deck"` // cards left to draw
Turn int `json:"turn"`
Dir int `json:"dir"`
UnoAt []int `json:"uno_at"` // your cards that would leave you on one
Catchable []int `json:"catchable"` // seats sitting on one card they never called
Bet int64 `json:"bet"`
Pays int64 `json:"pays"` // what going out right now would actually pay
Phase string `json:"phase"`
Outcome string `json:"outcome,omitempty"`
Turn int `json:"turn"`
Dir int `json:"dir"`
Dealer int `json:"dealer"`
HandNo int `json:"hand_no"`
Pending int `json:"pending"` // No Mercy: the bill a stack has run up
Pot int64 `json:"pot"` // the antes riding on this hand
Ante int64 `json:"ante"` // what each seat puts in
Stack int64 `json:"stack"` // what's in front of you
BoughtIn int64 `json:"bought_in"` // your own buy-in, from the border ledger
Phase string `json:"phase"`
// The last hand's verdict, so the felt can land it between hands.
Winner int `json:"winner"`
Payout int64 `json:"payout,omitempty"`
LastPot int64 `json:"last_pot,omitempty"`
Rake int64 `json:"rake,omitempty"`
Net int64 `json:"net"`
Outcome string `json:"outcome,omitempty"`
}
func viewUno(g uno.State) unoView {
// viewUno renders the table as one seat may see it. viewer is which seat is
// looking — their hand is the only one it will ever put in the payload.
//
// This is the security boundary. The same view fans to every subscriber's stream,
// so a seat that renders anyone else's cards fans them to the whole table.
// TestUnoViewNeverLeaksAnotherSeatsCards renders every seat's view and greps for
// cards that are not theirs.
func viewUno(g uno.State, viewer int) unoView {
v := unoView{
Tier: g.Tier,
Top: viewUnoCard(g.Top()),
Color: g.Color.String(),
Deck: g.Left(),
Turn: g.Turn,
Dir: g.Dir,
Bet: g.Bet,
Pays: g.Pays(),
Phase: string(g.Phase),
Outcome: string(g.Outcome),
Winner: -1,
Payout: g.Payout,
Rake: g.Rake,
Net: g.Net(),
Tier: g.Tier,
YourSeat: viewer,
Top: viewUnoCard(g.Top()),
Color: g.Color.String(),
Deck: g.Left(),
Turn: g.Turn,
Dir: g.Dir,
Dealer: g.Dealer,
HandNo: g.HandNo,
Pending: g.Pending,
Pot: g.Pot,
Ante: g.Tier.Ante,
BoughtIn: g.BoughtIn, // a table total; the caller overrides it with this seat's own stake
Phase: string(g.Phase),
Winner: g.Winner,
LastPot: g.LastPot,
Rake: g.Rake,
Outcome: string(g.Outcome),
}
for i, n := range g.Counts() {
seat := unoSeatView{Cards: n, You: i == uno.You, Uno: n == 1}
if i == uno.You {
seat.Name = "You"
} else if i-1 < len(g.Bots) {
seat.Name = g.Bots[i-1]
if viewer >= 0 && viewer < len(g.Seats) {
v.Stack = g.Seats[viewer].Stack
}
counts := g.Counts()
for i := range g.Seats {
p := g.Seats[i]
live := g.Live(i)
seat := unoSeatView{
Name: p.Name,
Bot: p.Bot,
You: i == viewer,
Cards: counts[i],
Stack: p.Stack,
Ante: p.Ante,
Uno: live && counts[i] == 1,
Called: i < len(g.Called) && g.Called[i],
Out: !live,
}
v.Seats = append(v.Seats, seat)
if n == 0 {
v.Winner = i
}
// The wall. Only the viewer's own hand crosses the wire.
if viewer >= 0 && viewer < len(g.Hands) {
for _, c := range g.Hands[viewer] {
v.Hand = append(v.Hand, viewUnoCard(c))
}
}
for _, c := range g.Hands[uno.You] {
v.Hand = append(v.Hand, viewUnoCard(c))
if v.Hand == nil {
v.Hand = []unoCardView{}
}
v.Playable = g.Playable()
// Empty arrays, never null: the felt indexes into these.
v.Playable = g.Playable(viewer)
if v.Playable == nil {
v.Playable = []int{}
}
v.UnoAt = g.UnoAt(viewer)
if v.UnoAt == nil {
v.UnoAt = []int{}
}
v.Catchable = g.Catchable(viewer)
if v.Catchable == nil {
v.Catchable = []int{}
}
return v
}
// unoEventView is one beat of the script the table plays back: a card going
// down, a seat eating a +4, the turn coming round. The engine's own events carry
// engine types, so they are re-rendered here rather than shipped raw — and this
// is also the wall where a bot's drawn card is dropped on the floor.
// unoEventView is one beat of the script the felt plays back. The engine attaches
// every seat's hand and drawn cards; this is the wall where the ones the viewer
// isn't entitled to are stripped.
type unoEventView struct {
Kind string `json:"kind"`
Seat int `json:"seat"`
Card *unoCardView `json:"card,omitempty"`
Color string `json:"color,omitempty"`
N int `json:"n,omitempty"`
Left int `json:"left"` // never omitempty: a seat that goes out leaves zero, and zero is the number the table has to draw
Text string `json:"text,omitempty"`
Kind string `json:"kind"`
Seat int `json:"seat"`
Card *unoCardView `json:"card,omitempty"`
Color string `json:"color,omitempty"`
N int `json:"n,omitempty"`
Left int `json:"left"`
By int `json:"by"`
Text string `json:"text,omitempty"`
Hand []unoCardView `json:"hand,omitempty"`
}
func viewUnoEvents(evs []uno.Event) []unoEventView {
func viewUnoEvents(evs []uno.Event, viewer int) []unoEventView {
out := make([]unoEventView, 0, len(evs))
for _, e := range evs {
v := unoEventView{Kind: e.Kind, Seat: e.Seat, N: e.N, Left: e.Left, Text: e.Text}
v := unoEventView{Kind: e.Kind, Seat: e.Seat, N: e.N, Left: e.Left, By: e.By, Text: e.Text}
if e.Color != uno.Wild {
v.Color = e.Color.String()
}
// A hand rides an event only if it is the viewer's own. The engine stamps every
// seat's hand; this strips the rest.
if e.Seat == viewer && e.Hand != nil {
v.Hand = make([]unoCardView, 0, len(e.Hand))
for _, c := range e.Hand {
v.Hand = append(v.Hand, viewUnoCard(c))
}
}
// A card rides an event only if it is a card played face up, or one the viewer
// drew. A bot's (or another human's) drawn card never carries a face.
if e.Card != nil {
// The engine only ever attaches a card to an event the seat is entitled
// to see it in — a card played face up, or one *you* drew. This check is
// the belt to that pair of braces: a bot's draw never carries a face,
// whatever the engine thinks it's doing.
if e.Kind == uno.EvDraw || e.Kind == uno.EvForced {
if e.Seat == uno.You {
if e.Seat == viewer {
c := viewUnoCard(*e.Card)
v.Card = &c
}
@@ -155,52 +210,227 @@ func viewUnoEvents(evs []uno.Event) []unoEventView {
return out
}
// handleUnoStart takes the bet and deals. Same order as every other table: the
// chips are staked first, in the same statement that checks they exist, so two
// deals fired at once cannot bet the same chip.
func (s *Server) handleUnoStart(w http.ResponseWriter, r *http.Request) {
// ---- sitting down ----------------------------------------------------------
// unoSeatRows mirrors the engine's seats into the storage rows that shadow them,
// index for index. A human's staked is the buy-in that crossed the border; a
// bot's is zero.
func unoSeatRows(g uno.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
}
// handleUnoSit seats a player at a fresh table of their own or at an open chair on
// somebody else's.
func (s *Server) handleUnoSit(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"`
Tier string `json:"tier"`
BuyIn int64 `json:"buyin"`
Table string `json:"table"`
Seat *int `json:"seat"`
}
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
if err := decodeJSON(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
tier, err := uno.TierBySlug(req.Tier)
if req.Table != "" {
s.joinUno(w, r, user, req.Table, req.Seat, req.BuyIn)
return
}
s.openUno(w, r, user, req.Tier, req.BuyIn)
}
// openUno opens a fresh table with the player in seat zero and bots in the rest —
// the old solo flow, now a real shared table with no other humans on it yet.
func (s *Server) openUno(w http.ResponseWriter, r *http.Request, user, tierSlug string, buyIn int64) {
tier, err := uno.TierBySlug(tierSlug)
if err != nil {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
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: uno stake", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
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
}
name := s.displayName(r, user)
seed1, seed2 := newSeeds()
g, evs, err := uno.New(req.Bet, tier, blackjack.DefaultRules().RakePct, seed1, seed2)
g, _, err := uno.New(tier, uno.TableSeats(tier, name, tier.Bots, buyIn), blackjack.DefaultRules().RakePct, seed1, seed2)
if err != nil {
// The game never happened, so the stake never should have left.
_ = storage.Award(user, req.Bet)
slog.Error("games: uno deal", "user", user, "err", err)
slog.Error("games: uno open", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.persistUno(w, user, g, evs, seed1, seed2, true)
blob, err := json.Marshal(g)
if err != nil {
slog.Error("games: marshal new uno", "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: gameUno, Tier: tier.Slug, State: blob,
Seed1: seed1, Seed2: seed2, Phase: string(g.Phase), HandNo: int64(g.HandNo),
}
err = storage.OpenSoloTable(t, unoSeatRows(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 uno table", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.writeUnoTable(w, user, nil)
}
// handleUnoMove plays one turn: a card, a draw, or passing on the card you drew.
// The bots' turns come back with it.
// pickOpenUnoSeat chooses a chair to join: the one asked for if it is a bot's,
// otherwise the first bot seat. Returns -1 if there is nowhere to sit.
func pickOpenUnoSeat(g uno.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
}
// joinUno sits a player at an open chair on an existing table, one transaction
// under the table lock, so two people racing for the last seat cannot both win it.
func (s *Server) joinUno(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 != gameUno {
respErr = uno.ErrUnknownMove
return nil
}
var g uno.State
if err := json.Unmarshal(t.State, &g); err != nil {
return err
}
if unoPlaying(g) {
respErr = uno.ErrHandLive // you join between hands
return nil
}
seat := pickOpenUnoSeat(g, wantSeat)
if seat < 0 {
respErr = uno.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 uno", "user", user, "table", tableID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if respErr != nil {
writeJSONStatus(w, unoJoinStatus(respErr), map[string]string{"error": unoJoinMessage(respErr)})
return
}
s.writeUnoTable(w, user, nil)
}
func unoJoinStatus(err error) int {
switch {
case errors.Is(err, storage.ErrNoSuchTable), errors.Is(err, uno.ErrTableFull),
errors.Is(err, storage.ErrSeatTaken), errors.Is(err, uno.ErrHandLive):
return http.StatusConflict
default:
return http.StatusBadRequest
}
}
func unoJoinMessage(err error) string {
switch {
case errors.Is(err, storage.ErrNoSuchTable):
return "that table has closed"
case errors.Is(err, uno.ErrTableFull), errors.Is(err, storage.ErrSeatTaken):
return "that seat is taken"
case errors.Is(err, uno.ErrHandLive):
return "a hand is in play — sit down when it's over"
case errors.Is(err, uno.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 --------------------------------------------------------
// handleUnoMove plays one move at the player's table: a hand move, or dealing the
// next hand. Leaving is its own endpoint.
func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
@@ -211,74 +441,237 @@ func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if move.Kind == uno.MoveLeave {
s.leaveUno(w, user)
return
}
live, err := storage.LoadLiveHand(user)
tableID, seat, err := storage.PlayerSeat(user)
if errors.Is(err, storage.ErrNoLiveHand) {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"})
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
return
}
if err != nil {
slog.Error("games: uno load", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if live.Game != gameUno {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
return
}
var g uno.State
if err := json.Unmarshal(live.State, &g); err != nil {
slog.Error("games: unreadable uno game", "user", user, "err", err)
slog.Error("games: uno move seat", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
next, evs, err := uno.ApplyMove(g, move)
if err != nil {
// The refusals a player can actually cause, said in words rather than as
// "that move isn't legal here" — which, in a game with this many rules, is
// the table refusing to explain itself.
msg := "that move isn't legal here"
switch {
case errors.Is(err, uno.ErrCantPlay):
msg = "that card doesn't go on this one"
case errors.Is(err, uno.ErrNeedColor):
msg = "pick a colour for the wild"
case errors.Is(err, uno.ErrMustPlayNow):
msg = "play the card you drew, or pass"
case errors.Is(err, uno.ErrCantPass):
msg = "draw first, then you can pass"
var respErr error
var respEvents []uno.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
}
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
if err != nil {
return err
}
var g uno.State
if err := json.Unmarshal(t.State, &g); err != nil {
return err
}
next, evs, aerr := uno.ApplyMove(g, seat, move)
if aerr != nil {
respErr = aerr
return nil
}
// A solo session that just ended (the one human got up or busted at the deal)
// is not a table any more: cash the seat out and close the felt. Only a solo
// table reaches PhaseDone; a shared table plays on.
if next.Phase == uno.PhaseDone {
if err := s.settleUnoLeave(t, next, seat, user); err != nil {
if errors.Is(err, storage.ErrStaleTable) {
respErr = storage.ErrStaleTable
return nil
}
return err
}
respEvents = evs
s.publishTable(tableID)
return nil
}
st, err := unoStep(next, evs, seats)
if err != nil {
return err
}
acting := seats[seat]
acting.Away = false
acting.LastSeen = time.Now().Unix()
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) {
respErr = storage.ErrStaleTable
return nil
}
return err
}
respEvents = evs
s.publishTable(tableID)
return nil
})
if err != nil {
slog.Error("games: uno move", "user", user, "table", tableID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.persistUno(w, user, next, evs, live.Seed1, live.Seed2, false)
if respErr != nil {
writeJSONStatus(w, unoMoveStatus(respErr), map[string]string{"error": unoMoveMessage(respErr)})
return
}
s.writeUnoTable(w, user, respEvents)
}
// persistUno writes the game back and answers the browser.
func (s *Server) persistUno(w http.ResponseWriter, user string, g uno.State, evs []uno.Event, seed1, seed2 uint64, fresh bool) {
blob, err := json.Marshal(g)
if err != nil {
slog.Error("games: marshal uno", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
func unoMoveStatus(err error) int {
switch {
case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable):
return http.StatusConflict
default:
return http.StatusBadRequest
}
done := g.Phase == uno.PhaseDone
v, ok := s.commit(w, user, finished{
Game: gameUno, Blob: blob,
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
Outcome: string(g.Outcome), Done: done,
Seed1: seed1, Seed2: seed2, Fresh: fresh,
})
}
func unoMoveMessage(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, uno.ErrHandLive):
return "finish the hand first"
case errors.Is(err, uno.ErrNoHand):
return "there's no hand in play — deal one"
case errors.Is(err, uno.ErrNotYourTurn):
return "it isn't your turn"
case errors.Is(err, uno.ErrCantPlay):
return "that card doesn't go on this one"
case errors.Is(err, uno.ErrNeedColor):
return "pick a colour for the wild"
case errors.Is(err, uno.ErrMustPlayNow):
return "play the card you drew, or pass"
case errors.Is(err, uno.ErrCantPass):
return "draw first, then you can pass"
case errors.Is(err, uno.ErrMustStack):
return "answer the stack with a draw card, or take it"
case errors.Is(err, uno.ErrNoStack):
return "there's nothing pointed at you to take"
case errors.Is(err, uno.ErrNoCatch):
return "there's nobody in that seat to catch"
default:
return "that move isn't legal here"
}
}
// ---- getting up ------------------------------------------------------------
// handleUnoLeave 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.
func (s *Server) handleUnoLeave(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
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 land the verdict on.
if done {
uv := viewUno(g)
v.Uno = &uv
s.leaveUno(w, user)
}
// leaveUno gets a player up from their table, turning what is in front of them
// back into chips. It refuses mid-hand and closes the felt behind the last human.
func (s *Server) leaveUno(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: uno 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 uno.State
if err := json.Unmarshal(t.State, &g); err != nil {
return err
}
if unoPlaying(g) {
respErr = uno.ErrHandLive
return nil
}
if err := s.settleUnoLeave(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: uno leave", "user", user, "table", tableID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if respErr != nil {
writeJSONStatus(w, unoMoveStatus(respErr), map[string]string{"error": unoMoveMessage(respErr)})
return
}
s.writeUnoTable(w, user, nil)
}
// settleUnoLeave vacates a seat, credits the stack home, and closes the table if
// nobody human is left — all inside the caller's lock.
func (s *Server) settleUnoLeave(t storage.Table, g uno.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
}
return storage.CloseTable(t.ID)
}
// ---- the response ----------------------------------------------------------
// writeUnoTable 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.
func (s *Server) writeUnoTable(w http.ResponseWriter, user string, evs []uno.Event) {
v, err := s.table(user)
if err != nil {
slog.Error("games: uno 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.UnoEvents = viewUnoEvents(evs, seat)
}
}
v.UnoEvents = viewUnoEvents(evs)
writeJSON(w, v)
}

View File

@@ -0,0 +1,187 @@
package web
import (
"encoding/json"
"time"
"pete/internal/games/uno"
"pete/internal/storage"
)
// UNO as a shared table: the seam the runtime drives it through, and the two
// facts about a hand only the engine can tell the runtime — when the clock must
// next act, and what a finished hand owes the audit trail.
//
// Everything about *playing* UNO is still in the engine. This file is the
// translation layer between a uno.State and the game-agnostic table runtime.
// unoTable is the tableGame for UNO. It holds no state — the table's state is the
// blob in game_tables — so a single value serves every felt in the room.
type unoTable struct{}
func (unoTable) name() string { return gameUno }
// timeout plays a passive move for the human whose clock ran out and marks them
// away, so the runtime auto-acts them on sight after this rather than waiting a
// full clock every orbit. The move is the gentlest one that still advances the
// turn: give in to a stack, pass a drawn card (or play it where the rules force
// it), otherwise play a legal card if there is one, or draw.
//
// If, on decode, the seat to act is not a waiting human, the scan raced a real
// move that had not yet bumped the version: errNotDue, and the clock steps aside.
func (unoTable) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
var g uno.State
if err := json.Unmarshal(state, &g); err != nil {
return step{}, nil, err
}
if !unoPlaying(g) {
return step{}, seats, errNotDue
}
seat := g.Turn
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
return step{}, seats, errNotDue
}
move := unoIdleMove(g, seat)
next, evs, err := uno.ApplyMove(g, seat, move)
if err != nil {
return step{}, nil, err
}
changed := markAway(seats, seat)
st, err := unoStep(next, evs, seats)
return st, changed, err
}
// unoIdleMove is the move the clock makes for a walked-away seat: the most passive
// legal one that still hands the turn on.
func unoIdleMove(g uno.State, seat int) uno.Move {
switch g.Phase {
case uno.PhaseStack:
return uno.Move{Kind: uno.MoveTake}
case uno.PhaseDrawn:
if g.Tier.NoMercy {
// No Mercy makes you play the card you drew; it is the last in the hand.
return uno.Move{Kind: uno.MovePlay, Index: len(g.Hands[seat]) - 1, Color: uno.Red}
}
return uno.Move{Kind: uno.MovePass}
default:
if p := g.Playable(seat); len(p) > 0 {
return uno.Move{Kind: uno.MovePlay, Index: p[0], Color: uno.Red}
}
return uno.Move{Kind: uno.MoveDraw}
}
}
// stacks reports the chips in front of each seat, index-aligned with the table's
// seat rows, so the abandoned-table reaper can cash out a walked-away human.
func (unoTable) stacks(state []byte) ([]int64, error) {
var g uno.State
if err := json.Unmarshal(state, &g); err != nil {
return nil, err
}
out := make([]int64, len(g.Seats))
for i := range g.Seats {
out[i] = g.Seats[i].Stack
}
return out, nil
}
// unoStep packages a played-out state for the runtime: the blob to persist, the
// deadline the clock must honour next, and the audit of any hand that just ended.
func unoStep(next uno.State, evs []uno.Event, seats []storage.Seat) (step, error) {
blob, err := json.Marshal(next)
if err != nil {
return step{}, err
}
st := step{
State: blob,
Phase: string(next.Phase),
HandNo: int64(next.HandNo),
Deadline: unoDeadline(next, seats),
Audit: unoAudit(next, evs, seats),
}
return st, nil
}
// unoPlaying reports whether a hand is in progress — the only phase a turn clock
// has anything to do in.
func unoPlaying(g uno.State) bool {
switch g.Phase {
case uno.PhasePlay, uno.PhaseDrawn, uno.PhaseStack:
return true
}
return false
}
// unoDeadline is when the clock must next act, or 0 for never. A clock is only set
// on a *present* human whose turn it is: a bot resolves inside the move, an away
// human is auto-acted on sight, and between hands there is no per-seat clock —
// dealing the next hand is a seated player's call, and an abandoned table is the
// reaper's job.
func unoDeadline(g uno.State, seats []storage.Seat) int64 {
if !unoPlaying(g) {
return 0
}
seat := g.Turn
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
return 0
}
if seatAway(seats, seat) {
return 0
}
return time.Now().Unix() + turnSeconds
}
// unoAudit is the per-hand record of a finished hand — one row per human who was
// in it. Empty until a hand actually ends, which is exactly when a settle beat is
// emitted.
//
// The rake rides on the winner's row alone, and every other seat carries zero, so
// game_hands.rake (which HouseTake sums) records a pot's rake once and once only.
// A seat's ante is its bet; a refunded tie pays each seat its ante straight back.
func unoAudit(g uno.State, evs []uno.Event, seats []storage.Seat) []storage.Hand {
if !unoHandEnded(evs) {
return nil
}
var audit []storage.Hand
for i := range g.Seats {
p := g.Seats[i]
if p.Bot || i >= len(seats) || seats[i].MatrixUser == "" {
continue
}
if p.Ante == 0 {
continue // sat this hand out — nothing to record
}
outcome, payout, rake := "lost", int64(0), int64(0)
switch {
case i == g.Winner:
outcome, payout, rake = "won", p.Won, g.Rake
case g.Outcome == uno.OutcomeTie:
outcome, payout = "push", p.Ante // the ante came straight back
}
audit = append(audit, storage.Hand{
MatrixUser: seats[i].MatrixUser,
Game: gameUno,
Bet: p.Ante,
Payout: payout,
Rake: rake,
Outcome: outcome,
Seed1: g.Seed1,
Seed2: g.Seed2,
})
}
return audit
}
// unoHandEnded reports whether a hand finished in this batch of events. A settle
// beat is emitted exactly once, when a hand ends, so it is the clean signal that
// the seats' Ante/Won are this hand's final numbers.
func unoHandEnded(evs []uno.Event) bool {
for _, e := range evs {
if e.Kind == uno.EvSettle {
return true
}
}
return false
}

View File

@@ -4,57 +4,54 @@ import (
"testing"
"pete/internal/games/uno"
"pete/internal/storage"
)
// The one thing this table cannot get wrong: the stake leaves the stack, and no
// card a player is not entitled to see leaves the server. At UNO that is not one
// hole card — it is the deck and every bot's hand, which is most of the game.
func TestUnoStartTakesTheStakeAndKeepsTheCards(t *testing.T) {
// Sitting down is the only time chips leave your stack at this table, and getting
// up is the only time they come back. The ante and the pot move inside the engine.
func TestUnoSitTakesTheBuyIn(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
fund(t, 5000)
v, code := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
map[string]any{"bet": 100, "tier": "full"}))
v, code := call(t, s, s.handleUnoSit, as(t, s, "reala", "POST", "/api/games/uno/sit",
map[string]any{"tier": "full", "buyin": 1000}))
if code != 200 {
t.Fatalf("start = %d, want 200", code)
t.Fatalf("sit = %d, want 200", code)
}
if v.Chips != 900 {
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
if v.Chips != 4000 {
t.Fatalf("chips after a 1000 buy-in = %d, want 4000", v.Chips)
}
if v.Game != gameUno || v.Uno == nil {
t.Fatalf("start returned no uno game: game=%q", v.Game)
t.Fatalf("sit returned no table: game=%q", v.Game)
}
g := v.Uno
if len(g.Hand) != uno.HandSize {
t.Errorf("you were dealt %d cards, want %d", len(g.Hand), uno.HandSize)
if g.Stack != 1000 {
t.Errorf("you sat down with %d, want the 1000 you bought in for", g.Stack)
}
if len(g.Seats) != 4 {
t.Fatalf("a full house is four seats, got %d", len(g.Seats))
t.Fatalf("three bots and you is four seats, got %d", len(g.Seats))
}
// A bot is a name and a number. There is no field here that could carry a
// card, which is the point — this is a compile-time guarantee, and the test
// exists to make deleting it loud.
for i, seat := range g.Seats {
if i == 0 {
if !seat.You || seat.Name != "You" {
t.Errorf("seat 0 should be you: %+v", seat)
}
continue
}
if seat.You || seat.Name == "" {
t.Errorf("seat %d should be a named bot: %+v", i, seat)
}
if seat.Cards != uno.HandSize {
t.Errorf("seat %d holds %d cards, want %d", i, seat.Cards, uno.HandSize)
}
if g.Phase != "handover" {
t.Errorf("phase %q — a table you just sat at has no hand on it yet", g.Phase)
}
if g.Top.Value == "" {
t.Error("no card in play")
// Deal a hand: chips do not move (the ante is within the blob), and you are dealt
// seven cards and to act.
deal, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "deal"}))
if code != 200 {
t.Fatalf("deal = %d, want 200", code)
}
if g.Turn != 0 {
t.Errorf("you play first, turn is %d", g.Turn)
if deal.Chips != 4000 {
t.Errorf("chips moved on the deal: %d, want 4000 — the ante is inside the engine", deal.Chips)
}
if deal.Uno == nil || len(deal.Uno.Hand) != uno.HandSize {
t.Fatalf("you were dealt the wrong hand: %+v", deal.Uno)
}
if deal.Uno.Turn != 0 {
t.Errorf("you act first at your own table, turn is %d", deal.Uno.Turn)
}
if deal.Uno.Pot != deal.Uno.Ante*int64(len(deal.Uno.Seats)) {
t.Errorf("pot is %d, want one ante per seat", deal.Uno.Pot)
}
}
@@ -62,16 +59,16 @@ func TestUnoStartTakesTheStakeAndKeepsTheCards(t *testing.T) {
// back never carries a bot's drawn card.
func TestUnoMovePlaysTheWholeLap(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
fund(t, 5000)
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
map[string]any{"bet": 100, "tier": "table"}))
if v.Uno == nil {
t.Fatal("no game")
call(t, s, s.handleUnoSit, as(t, s, "reala", "POST", "/api/games/uno/sit",
map[string]any{"tier": "table", "buyin": 1000}))
deal, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "deal"}))
if code != 200 || deal.Uno == nil {
t.Fatalf("deal = %d", code)
}
// Draw, which is legal from any hand: the turn passes to the bots and comes
// back, unless the card drawn happens to be playable.
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "draw"}))
if code != 200 {
@@ -93,55 +90,148 @@ func TestUnoMovePlaysTheWholeLap(t *testing.T) {
}
}
// You cannot play a wild without naming a colour — and the zero value of the
// colour field is not red, so a move that simply omits it is refused rather than
// quietly played as one.
func TestUnoWildWithNoColourIsRefused(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
// TestUnoViewNeverLeaksAnotherSeatsCards is the security boundary of the whole
// multiplayer table. There is no showdown in UNO, so the rule is absolute: no
// seat's hand, and no card a seat drew, ever reaches another viewer. Rendered for
// every seat, after every step of a hand played to its end.
//
// After SSE the view a seat renders fans to that seat's stream, so a single missed
// redaction broadcasts a hand to the whole felt. The engine emits every seat's
// cards (it cannot know who a shared stream is for), which makes viewUno and
// viewUnoEvents the only thing between the deck and the network tab.
func TestUnoViewNeverLeaksAnotherSeatsCards(t *testing.T) {
tier, _ := uno.TierBySlug("full")
// Two humans (0, 2) and two bots (1, 3), so the test covers a viewer seeing both
// another human and a bot, and a human at a non-zero index.
seats := []uno.SeatConfig{
{Name: "Ana", Stack: 2000},
{Name: "Bot A", Bot: true, Stack: 2000},
{Name: "Bo", Stack: 2000},
{Name: "Bot B", Bot: true, Stack: 2000},
}
g, _, err := uno.New(tier, seats, tier.RakePct, 5, 6)
if err != nil {
t.Fatal(err)
}
g, evs, err := uno.ApplyMove(g, 0, uno.Move{Kind: uno.MoveDeal})
if err != nil {
t.Fatal(err)
}
assertUnoRedacted(t, g, evs)
// Deal until a wild lands in the opening hand: it's four cards in 108, so it
// doesn't take long, and this is the only way to get one without rigging the
// deck through a door the server doesn't have.
var wild = -1
for try := 0; try < 40 && wild < 0; try++ {
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
map[string]any{"bet": 10, "tier": "duel"}))
if v.Uno == nil {
t.Fatal("no game")
for step := 0; unoPlaying(g) && step < 200; step++ {
seat := g.Turn
if g.Seats[seat].Bot {
t.Fatalf("advance stopped on a bot at seat %d", seat)
}
for i, c := range v.Uno.Hand {
if c.Wild {
wild = i
break
}
move := unoHumanMove(g, seat)
var stepEvs []uno.Event
g, stepEvs, err = uno.ApplyMove(g, seat, move)
if err != nil {
t.Fatalf("seat %d %s: %v", seat, move.Kind, err)
}
if wild < 0 {
// Abandon this deal and take another. The live row is keyed on the
// player, so it has to go before the next start can be seated.
if err := storage.ClearLiveHand(testPlayer); err != nil {
t.Fatal(err)
}
}
}
if wild < 0 {
t.Skip("40 deals and not one wild — vanishingly unlikely, but not a failure")
}
_, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "play", "index": wild}))
if code != 400 {
t.Fatalf("a wild with no colour = %d, want 400", code)
}
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "play", "index": wild, "color": int(uno.Green)}))
if code != 200 {
t.Fatalf("a wild played as green = %d, want 200", code)
}
if v.Uno != nil && v.Uno.Phase != "done" && v.Uno.Color != "green" && v.Uno.Color != "" {
// The bots have played since, so the colour may have moved on — what must
// not happen is the wild going down as anything we didn't ask for.
t.Logf("colour in play after the bots: %s", v.Uno.Color)
assertUnoRedacted(t, g, stepEvs)
}
}
// unoHumanMove picks a legal move for the human to act: play a legal card, take a
// stack you can't answer, pass a drawn card, otherwise draw.
func unoHumanMove(g uno.State, seat int) uno.Move {
if p := g.Playable(seat); len(p) > 0 {
m := uno.Move{Kind: uno.MovePlay, Index: p[0], Color: uno.Red}
for _, at := range g.UnoAt(seat) {
if at == p[0] {
m.Uno = true
}
}
return m
}
switch g.Phase {
case uno.PhaseStack:
return uno.Move{Kind: uno.MoveTake}
case uno.PhaseDrawn:
return uno.Move{Kind: uno.MovePass}
default:
return uno.Move{Kind: uno.MoveDraw}
}
}
// assertUnoRedacted renders every seat's view and event script and fails if any of
// them carries a card belonging to another seat.
func assertUnoRedacted(t *testing.T, g uno.State, evs []uno.Event) {
t.Helper()
for viewer := range g.Seats {
v := viewUno(g, viewer)
// The view's hand is the viewer's own, exactly — no more, no fewer.
if len(v.Hand) != len(g.Hands[viewer]) {
t.Fatalf("seat %d's view carries %d cards, but the seat holds %d",
viewer, len(v.Hand), len(g.Hands[viewer]))
}
// The event script: a hand rides only the viewer's own beat, and a drawn card
// only the viewer's own draw. (A card a bot plays is public — it lands on the
// pile — so only a *drawn* card is a secret, which is why this is scoped to the
// draw and forced beats.) Undo either guard in viewUnoEvents and this fails.
for _, e := range viewUnoEvents(evs, viewer) {
if len(e.Hand) > 0 && e.Seat != viewer {
t.Fatalf("seat %d's event stream carries seat %d's hand", viewer, e.Seat)
}
if e.Card != nil && (e.Kind == uno.EvDraw || e.Kind == uno.EvForced) && e.Seat != viewer {
t.Fatalf("seat %d's event stream carries seat %d's drawn card", viewer, e.Seat)
}
}
}
}
// A seat the mercy rule has buried holds no cards; so does one that just went out.
// The view tells them apart by asking the engine (Live), not by inferring from a
// count of zero.
func TestABuriedSeatIsRenderedOut(t *testing.T) {
tier, _ := uno.TierBySlug("nm-full")
g, _, err := uno.New(tier, uno.SoloSeats(tier, tier.Bots, 1000), 0.05, 7, 9)
if err != nil {
t.Fatal(err)
}
g, _, err = uno.ApplyMove(g, 0, uno.Move{Kind: uno.MoveDeal})
if err != nil {
t.Fatal(err)
}
// Bury seat 2 the way the engine does: no cards, and out.
g.Hands[2] = nil
g.Out[2] = true
v := viewUno(g, 0)
if !v.Seats[2].Out {
t.Error("a buried seat must say so; the felt draws it as a grave")
}
if v.Seats[2].Uno {
t.Error("a buried seat is not one card away from going out")
}
if v.Winner != -1 {
t.Errorf("nobody has taken the pot mid-hand, so there is no winner: got %d", v.Winner)
}
}
// The bill a stack has run up is the only thing on the table while it stands, so it
// has to reach the browser.
func TestTheStackBillCrossesTheWire(t *testing.T) {
tier, _ := uno.TierBySlug("nm-duel")
g, _, err := uno.New(tier, uno.SoloSeats(tier, tier.Bots, 1000), 0.05, 3, 4)
if err != nil {
t.Fatal(err)
}
g, _, err = uno.ApplyMove(g, 0, uno.Move{Kind: uno.MoveDeal})
if err != nil {
t.Fatal(err)
}
g.Pending = 6
g.Phase = uno.PhaseStack
v := viewUno(g, 0)
if v.Pending != 6 {
t.Errorf("the felt was told the bill is %d, want 6", v.Pending)
}
if v.Phase != string(uno.PhaseStack) {
t.Errorf("phase = %q, want %q", v.Phase, uno.PhaseStack)
}
}

262
internal/web/mischief.go Normal file
View File

@@ -0,0 +1,262 @@
package web
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
"pete/internal/storage"
)
// buyerLocalpart normalises a session's Authentik username to the Matrix
// localpart gogobee keys everything on. Matrix localparts are lowercase; an
// Authentik display of the same name may not be, and gogobee pushes balances and
// resolves orders under the lowercase form. Lowercasing on both sides is what
// keeps the buyer's balance lookup and their order's identity pointing at the
// same person.
func buyerLocalpart(u *SessionUser) string {
return strings.ToLower(strings.TrimSpace(u.Username))
}
// The mischief storefront's web seam.
//
// Two audiences, two auth models, same file. Browsers hit the OIDC-gated buy
// endpoints: a signed-in buyer picks a mark off the anonymous board and places a
// hit, and reads back their own orders' standing. gogobee hits the bearer-authed
// pair: it polls pending orders and pushes a verdict, exactly like the escrow
// seam in games.go and under the same standing rule — Pete never calls gogobee,
// never moves money, never runs a game rule. It records intent and files a
// verdict; everything real happens on the game box.
// mischiefBurstWindow / mischiefBurstMax are Pete's own anti-spam guard, and
// nothing more. The real economic caps — two contracts a day, one live per mark,
// a boss a week — are gogobee's and are enforced at claim time. This only stops a
// signed-in buyer from spooling the orders table with a stuck mouse button.
const (
mischiefBurstWindow = time.Hour
mischiefBurstMax = 20
)
// mischiefOrderReq is the browser's buy request. Price and eligibility are not
// the buyer's to assert: they send a tier key and a mark, never a number.
type mischiefOrderReq struct {
TargetToken string `json:"target_token"`
Tier string `json:"tier"`
Signed bool `json:"signed"`
}
// handleMischiefOrder places a pending order for the signed-in buyer. It asserts
// only what Pete can honestly know: the buyer is signed in with a username the
// game box can resolve, the tier is one gogobee currently offers, and the mark is
// actually on the live board. Money and the full rulebook are gogobee's, checked
// when it claims the order.
func (s *Server) handleMischiefOrder(w http.ResponseWriter, r *http.Request) {
u := s.requireUser(w, r)
if u == nil {
return
}
// A session minted before the storefront existed carries no username, and
// without one gogobee can't name the buyer to its ledger. Send them back
// through sign-in to mint a fresh one rather than place an unfulfillable order.
buyer := buyerLocalpart(u)
if buyer == "" {
writeMischiefError(w, http.StatusConflict, "please sign in again")
return
}
var req mischiefOrderReq
if !decodeStateBody(w, r, &req) {
return
}
if req.TargetToken == "" {
writeMischiefError(w, http.StatusBadRequest, "no target")
return
}
tier, ok, err := storage.MischiefTierByKey(req.Tier)
if err != nil {
slog.Error("mischief: tier lookup", "err", err)
writeMischiefError(w, http.StatusInternalServerError, "internal error")
return
}
if !ok {
writeMischiefError(w, http.StatusBadRequest, "no such tier")
return
}
mark, ok, err := storage.RosterEntryByToken(req.TargetToken)
if err != nil {
slog.Error("mischief: target lookup", "err", err)
writeMischiefError(w, http.StatusInternalServerError, "internal error")
return
}
if !ok {
// The board moved on, or the token was never real. Either way there is no
// one to send trouble to.
writeMischiefError(w, http.StatusNotFound, "that adventurer is no longer on the board")
return
}
// Burst guard. Keyed on the OIDC subject so a username change can't reset it.
since := time.Now().Add(-mischiefBurstWindow).Unix()
if n, err := storage.CountMischiefOrdersSince(u.Sub, since); err != nil {
slog.Error("mischief: burst count", "err", err)
writeMischiefError(w, http.StatusInternalServerError, "internal error")
return
} else if n >= mischiefBurstMax {
writeMischiefError(w, http.StatusTooManyRequests, "slow down — too many orders in a short while")
return
}
order, err := storage.InsertMischiefOrder(u.Sub, buyer, mark.Token, mark.Name, tier.Key, req.Signed)
if err != nil {
slog.Error("mischief: insert order", "err", err)
writeMischiefError(w, http.StatusInternalServerError, "internal error")
return
}
slog.Info("mischief: order placed", "guid", order.GUID, "buyer", buyer, "tier", tier.Key, "signed", req.Signed)
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, order)
}
// handleMischiefOrders returns the signed-in buyer's own orders for the status
// panel, newest first. Scoped to their OIDC subject — a buyer sees their history
// and no one else's.
func (s *Server) handleMischiefOrders(w http.ResponseWriter, r *http.Request) {
u := s.requireUser(w, r)
if u == nil {
return
}
orders, err := storage.MischiefOrdersByBuyer(u.Sub, 20)
if err != nil {
slog.Error("mischief: orders by buyer", "err", err)
writeMischiefError(w, http.StatusInternalServerError, "internal error")
return
}
if orders == nil {
orders = []storage.MischiefOrder{}
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, orders)
}
// mischiefCatalogResp is what the storefront reads to draw itself: the live price
// list plus the signed-in buyer's own advisory balance, so it can grey out tiers
// they plainly can't afford. HasBalance distinguishes "gogobee says €0" from
// "gogobee has never mentioned this buyer" — the latter shows no affordability
// hint rather than a discouraging, possibly-wrong zero.
type mischiefCatalogResp struct {
Tiers []storage.MischiefTier `json:"tiers"`
Euro float64 `json:"euro"`
HasBalance bool `json:"has_balance"`
}
// handleMischiefCatalog serves the price list and the buyer's balance together.
func (s *Server) handleMischiefCatalog(w http.ResponseWriter, r *http.Request) {
u := s.requireUser(w, r)
if u == nil {
return
}
tiers, err := storage.MischiefTiers()
if err != nil {
slog.Error("mischief: catalog", "err", err)
writeMischiefError(w, http.StatusInternalServerError, "internal error")
return
}
if tiers == nil {
tiers = []storage.MischiefTier{}
}
resp := mischiefCatalogResp{Tiers: tiers}
if lp := buyerLocalpart(u); lp != "" {
euro, has, err := storage.UserEuro(lp)
if err != nil {
slog.Error("mischief: balance", "err", err)
} else {
resp.Euro, resp.HasBalance = euro, has
}
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, resp)
}
// ---- the gogobee wire: bearer-authed, idempotent -------------------------------
// handleMischiefPending is gogobee's poll: every order still waiting to be acted
// on. A pending order carries no intermediate state, so unlike escrow there is no
// stale-reoffer window — a gogobee that dies mid-claim simply leaves the order
// pending to be offered again, and the guid makes the replay a no-op.
func (s *Server) handleMischiefPending(w http.ResponseWriter, r *http.Request) {
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
orders, err := storage.PendingMischiefOrders(mischiefPollLimit)
if err != nil {
slog.Error("mischief: pending", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if orders == nil {
orders = []storage.MischiefOrder{}
}
writeJSON(w, orders)
}
// mischiefPollLimit caps one poll, matching the escrow seam.
const mischiefPollLimit = 50
// mischiefVerdict is gogobee's answer on a claimed order: the terminal status and
// a human note to render.
type mischiefVerdict struct {
GUID string `json:"guid"`
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
}
// handleMischiefClaim files gogobee's verdict against a pending order. Idempotent:
// gogobee's poll loop retries, so the same verdict can arrive more than once and
// only the first one moves the order. An unknown guid is a 400 — by this point
// gogobee has already debited the buyer for an order Pete has no record of, and
// under the seam's contract a 400 parks the row for a human rather than retrying
// forever against a row that will never exist.
func (s *Server) handleMischiefClaim(w http.ResponseWriter, r *http.Request) {
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var v mischiefVerdict
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
}
order, err := storage.ResolveMischiefOrder(v.GUID, v.Status, v.Detail)
if errors.Is(err, storage.ErrNoSuchOrder) {
slog.Error("mischief: verdict for an order we have never heard of — "+
"gogobee may have debited a buyer for it", "guid", v.GUID, "status", v.Status)
http.Error(w, "no such order", http.StatusBadRequest)
return
}
if err != nil {
// A malformed verdict status lands here — also a contract mismatch, so 400.
slog.Error("mischief: resolve", "guid", v.GUID, "status", v.Status, "err", err)
http.Error(w, "bad verdict", http.StatusBadRequest)
return
}
slog.Info("mischief: order resolved", "guid", order.GUID, "status", order.Status)
writeJSON(w, order)
}
func writeMischiefError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

View File

@@ -0,0 +1,222 @@
package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"pete/internal/storage"
)
// newStore is a server with the adventure seam on, a signed-in buyer, and a
// board + catalog already pushed. Auth is built by hand for the same reason the
// casino tests do it: the OIDC handshake is a network call and none of what's
// under test is about it.
func newStore(t *testing.T) *Server {
t.Helper()
s, _ := newAdvServer(t, "tok")
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
now := time.Now().Unix()
push := rosterPush{
SnapshotAt: now,
Adventurers: []storage.RosterEntry{entry("tok-josie", "Josie", "expedition", "holymachina")},
Tiers: []storage.MischiefTier{
{Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50},
{Key: "boss", Display: "Boss", Fee: 1200, SignedFee: 1500},
},
Balances: []storage.MischiefBalance{{Username: "reala", Euro: 500}},
}
if w := postRoster(t, s, "tok", push); w.Code != 200 {
t.Fatalf("seed roster = %d", w.Code)
}
return s
}
// jsonReq builds a bearer-authed (or unauthed, empty token) machine request.
func jsonReq(t *testing.T, method, path, token string, body any) *http.Request {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatal(err)
}
}
r := httptest.NewRequest(method, path, &buf)
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
return r
}
func placeOrder(t *testing.T, s *Server, username string, req mischiefOrderReq) *httptest.ResponseRecorder {
t.Helper()
r := as(t, s, username, "POST", "/api/mischief/order", req)
w := httptest.NewRecorder()
s.handleMischiefOrder(w, r)
return w
}
// TestMischiefOrderHappyPath: a signed-in buyer names a mark on the board and a
// tier gogobee offers, and gets a pending order back.
func TestMischiefOrderHappyPath(t *testing.T) {
s := newStore(t)
w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt", Signed: false})
if w.Code != 200 {
t.Fatalf("order = %d body=%s", w.Code, w.Body.String())
}
var o storage.MischiefOrder
if err := json.Unmarshal(w.Body.Bytes(), &o); err != nil {
t.Fatal(err)
}
if o.Status != storage.MischiefPending || o.BuyerUsername != "reala" || o.TargetName != "Josie" {
t.Fatalf("order = %+v", o)
}
if pending, _ := storage.PendingMischiefOrders(10); len(pending) != 1 {
t.Fatalf("order didn't land in the pending set")
}
}
func TestMischiefOrderRejections(t *testing.T) {
s := newStore(t)
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "dragon"}); w.Code != 400 {
t.Errorf("unknown tier = %d, want 400", w.Code)
}
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "ghost", Tier: "grunt"}); w.Code != 404 {
t.Errorf("off-board target = %d, want 404", w.Code)
}
if w := placeOrder(t, s, "reala", mischiefOrderReq{Tier: "grunt"}); w.Code != 400 {
t.Errorf("empty target = %d, want 400", w.Code)
}
// Signed in, but no username the ledger can name (a pre-storefront session).
if w := placeOrder(t, s, "", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 409 {
t.Errorf("usernameless session = %d, want 409", w.Code)
}
}
func TestMischiefOrderBurstGuard(t *testing.T) {
s := newStore(t)
for i := 0; i < mischiefBurstMax; i++ {
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 200 {
t.Fatalf("order %d = %d, want 200", i, w.Code)
}
}
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 429 {
t.Fatalf("over-cap order = %d, want 429", w.Code)
}
}
func TestMischiefOrderRequiresAuth(t *testing.T) {
s := newStore(t)
r := httptest.NewRequest("POST", "/api/mischief/order", nil)
w := httptest.NewRecorder()
s.handleMischiefOrder(w, r)
if w.Code != 401 {
t.Fatalf("anonymous order = %d, want 401", w.Code)
}
}
func TestMischiefCatalogCarriesBalance(t *testing.T) {
s := newStore(t)
r := as(t, s, "reala", "GET", "/api/mischief/catalog", nil)
w := httptest.NewRecorder()
s.handleMischiefCatalog(w, r)
if w.Code != 200 {
t.Fatalf("catalog = %d", w.Code)
}
var resp mischiefCatalogResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Tiers) != 2 || !resp.HasBalance || resp.Euro != 500 {
t.Fatalf("catalog resp = %+v", resp)
}
}
// ---- the bearer wire -----------------------------------------------------------
func TestMischiefPendingAndClaimBearer(t *testing.T) {
s := newStore(t)
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "boss", Signed: true}); w.Code != 200 {
t.Fatalf("seed order = %d", w.Code)
}
// Missing bearer is rejected.
{
w := httptest.NewRecorder()
s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "", nil))
if w.Code != 401 {
t.Fatalf("no-bearer pending = %d, want 401", w.Code)
}
}
// Poll returns the order.
var pending []storage.MischiefOrder
{
w := httptest.NewRecorder()
s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "tok", nil))
if w.Code != 200 {
t.Fatalf("pending = %d", w.Code)
}
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil {
t.Fatal(err)
}
}
if len(pending) != 1 {
t.Fatalf("pending count = %d, want 1", len(pending))
}
guid := pending[0].GUID
// Claim it placed.
{
w := httptest.NewRecorder()
s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok",
mischiefVerdict{GUID: guid, Status: storage.MischiefPlaced, Detail: "out for delivery"}))
if w.Code != 200 {
t.Fatalf("claim = %d body=%s", w.Code, w.Body.String())
}
}
if got, _ := storage.MischiefOrderByGUID(guid); got.Status != storage.MischiefPlaced {
t.Fatalf("order status after claim = %q", got.Status)
}
// And it's gone from the pending set.
{
w := httptest.NewRecorder()
s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "tok", nil))
var again []storage.MischiefOrder
_ = json.Unmarshal(w.Body.Bytes(), &again)
if len(again) != 0 {
t.Fatalf("placed order still polled: %d", len(again))
}
}
}
func TestMischiefClaimUnknownGUIDis400(t *testing.T) {
s := newStore(t)
w := httptest.NewRecorder()
s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok",
mischiefVerdict{GUID: "nope", Status: storage.MischiefPlaced}))
if w.Code != 400 {
t.Fatalf("unknown-guid claim = %d, want 400 (so gogobee parks it)", w.Code)
}
}
func TestMischiefClaimBadVerdictIs400(t *testing.T) {
s := newStore(t)
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 200 {
t.Fatal("seed")
}
pending, _ := storage.PendingMischiefOrders(10)
w := httptest.NewRecorder()
s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok",
mischiefVerdict{GUID: pending[0].GUID, Status: "exploded"}))
if w.Code != 400 {
t.Fatalf("bad-verdict claim = %d, want 400", w.Code)
}
}

View File

@@ -41,13 +41,26 @@ const (
)
// rosterPush is the payload gogobee POSTs to /api/ingest/roster.
//
// Balances ride the same tick as the board but live in a separate keyspace: they
// are keyed by localpart (a buyer's own sign-in name), not by the anonymous
// roster token, and Pete only ever reads one back for the authenticated user
// asking about themselves. So the board stays anonymous while the storefront can
// still grey out tiers a buyer plainly can't afford.
type rosterPush struct {
SnapshotAt int64 `json:"snapshot_at"`
Adventurers []storage.RosterEntry `json:"adventurers"`
SnapshotAt int64 `json:"snapshot_at"`
Adventurers []storage.RosterEntry `json:"adventurers"`
Balances []storage.MischiefBalance `json:"balances,omitempty"`
Tiers []storage.MischiefTier `json:"tiers,omitempty"`
}
// RosterView is one row as the page renders it.
//
// Token is the mark's anonymous roster token, exposed so the storefront can name
// a target without ever knowing their real handle. It is safe on a public page by
// design — non-reversible, stable, and already how gogobee keys the board.
type RosterView struct {
Token string
Name string
Level int
ClassRace string
@@ -103,7 +116,66 @@ func (s *Server) handleRosterIngest(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers))
// Advisory balances are best-effort: a board that landed is worth keeping
// even if the balances behind it didn't, so a failure here is logged, not
// fatal. Stale affordability only ever bounces an order, never miscounts money.
if err := storage.ReplaceUserEuro(push.Balances, push.SnapshotAt); err != nil {
slog.Error("roster ingest: balance replace failed", "err", err)
}
// The tier catalog is likewise best-effort and only refreshed when the push
// actually carried one, so a gogobee build that predates the storefront (no
// tiers field) can't wipe a catalog a newer one already established.
if len(push.Tiers) > 0 {
if err := storage.ReplaceMischiefTiers(push.Tiers); err != nil {
slog.Error("roster ingest: tier replace failed", "err", err)
}
}
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers), "balances", len(push.Balances))
w.WriteHeader(http.StatusOK)
}
// detailPush is the payload gogobee POSTs to /api/ingest/detail: the private,
// owner-only expansion for every player, in its own keyspace (keyed by localpart)
// and on its own endpoint so a fat inventory can't blow the roster push's budget.
type detailPush struct {
SnapshotAt int64 `json:"snapshot_at"`
Players []storage.PlayerDetail `json:"players"`
}
// handleDetailIngest replaces the private self-detail set with gogobee's latest
// push. Bearer-authed like the roster; the data is owner-private and only ever
// served back to the one authenticated user it belongs to (see PlayerDetailByOwner).
func (s *Server) handleDetailIngest(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
}
// A generous cap: this carries every player's inventory + vault, heavier than
// the board, but still a bounded realm — the ceiling only stops a runaway push.
var push detailPush
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&push); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if len(push.Players) > rosterMaxEntries {
http.Error(w, "detail set too large", http.StatusBadRequest)
return
}
if push.SnapshotAt <= 0 {
push.SnapshotAt = time.Now().Unix()
}
if err := storage.ReplacePlayerDetail(push.Players, push.SnapshotAt); err != nil {
slog.Error("detail ingest: replace failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("detail ingest: self-view set replaced", "players", len(push.Players))
w.WriteHeader(http.StatusOK)
}
@@ -142,6 +214,7 @@ func (s *Server) roster() (views []RosterView, stale bool, snapshotAt int64) {
func toRosterView(e storage.RosterEntry) RosterView {
v := RosterView{
Token: e.Token,
Name: e.Name,
Level: e.Level,
ClassRace: e.ClassRace,

View File

@@ -76,6 +76,14 @@ type Server struct {
metricsMu sync.Mutex
saltDay int64
salt [16]byte
// The shared-table machinery. hub fans SSE frames out to the phones at a felt;
// tableLocks is the striped optimisation over the DB's version column (see
// games_table.go). Both are nil-safe to construct always: they cost nothing
// until a table is opened.
hub *gamesHub
tableLocks *stripedLocks
tableGames []tableGame // the multiplayer engines, dispatched through games()
}
// New builds the server. Templates are parsed once at startup. Each page gets
@@ -94,8 +102,8 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
shared []string
pages []string
}{
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia", "uno"}},
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "who"}},
{"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
}
tpls := make(map[string]*template.Template)
for _, set := range sets {
@@ -136,7 +144,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
live = append(live, ch)
}
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live}
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}, unoTable{}}}
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
// provider is unreachable at boot we log and serve anonymously rather than
@@ -211,6 +219,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest)
mux.HandleFunc("GET /api/roster", s.handleRosterAPI)
// The private, owner-only self-detail (inventory/house/pets). Bearer-authed
// ingest like the roster; there is no public read — it is only ever served
// back to its owner, inline on the detail page below.
mux.HandleFunc("POST /api/ingest/detail", s.handleDetailIngest)
// One adventurer's detail page and its live re-poll. Both public (stats +
// gear); the page adds the owner's private extras when the signed-in viewer
// owns it. Three path segments, so it never overlaps /adventure/{guid} (two)
// or /adventure/art/{type}.
mux.HandleFunc("GET /adventure/who/{token}", s.handleAdventureWho)
mux.HandleFunc("GET /api/adventure/who/{token}", s.handleAdventureWhoAPI)
// Per-dispatch permalink (the article_url every ingested story points at).
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
// channel listing, registered in the channels loop above).
@@ -228,6 +248,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim)
mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled)
// The mischief storefront's game-box wire: gogobee polls pending orders and
// pushes a verdict. Bearer-authed on the same ingest token, same reason as the
// escrow seam — the caller is a machine on the tailnet. The buyer-facing half
// hangs off the auth block below.
mux.HandleFunc("GET /api/mischief/pending", s.handleMischiefPending)
mux.HandleFunc("POST /api/mischief/claim", s.handleMischiefClaim)
// The casino. Signed-in only — there is money in it — so these hang off the
// auth block, and gamesReady() also insists on a Matrix server name: without
// one, no player can be named to gogobee's ledger and the tables stay shut.
@@ -246,6 +273,16 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
mux.HandleFunc("GET /api/state", s.handleState)
mux.HandleFunc("GET /bookmarks", s.handleBookmarks)
mux.HandleFunc("GET /for-you", s.handleForYou)
// The mischief storefront, buyer side. Signed-in only — an order names a
// buyer to gogobee's ledger — so like the casino it hangs off the auth
// block. Only registered when the adventure seam is on; otherwise there is
// no board to order a hit from.
if s.adv.Enabled {
mux.HandleFunc("GET /api/mischief/catalog", s.handleMischiefCatalog)
mux.HandleFunc("POST /api/mischief/order", s.handleMischiefOrder)
mux.HandleFunc("GET /api/mischief/orders", s.handleMischiefOrders)
}
if s.cfg.Push.Enabled {
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)

View File

@@ -767,6 +767,55 @@ html[data-phase="night"] {
}
.pete-spot[data-live="1"] .pete-spot-label { opacity: 0; }
/* The player's hands at blackjack. There is one of them, until you split.
Then there are up to four, side by side, each with its own chips on its own
spot — because after a split they are not one bet in two piles, they are two
bets that win and lose separately. */
.bj-hands {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem 1rem;
}
.bj-hand {
display: flex;
align-items: center;
gap: 0.85rem;
border-radius: 1.5rem;
padding: 0.4rem 0.7rem 0.4rem 0.4rem;
transition: opacity 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
}
/* Which hand you are actually playing. The rest are still on the table; they
are simply not your problem this second. */
.bj-hand[data-live="0"] { opacity: 0.5; }
.bj-hand[data-live="1"] {
background: rgba(0,0,0,0.16);
box-shadow: 0 0 0 2px rgba(var(--glow, 242,181,61), 0.5);
}
/* Four hands do not fit at one hand's worth of card, and the fix is to tell the
row what a card *is* rather than to squash the box a card is in: everything
about a card is sized off these two vars. The spot shrinks with them, or the
chips end up bigger than the cards they're riding on. */
.bj-hands[data-count="2"] { --card-h: 6.6rem; --card-w: 4.7rem; }
.bj-hands[data-count="3"],
.bj-hands[data-count="4"] { --card-h: 5rem; --card-w: 3.6rem; }
.bj-hands[data-count="2"] .pete-hand { min-height: 6.8rem; }
.bj-hands[data-count="3"] .pete-hand,
.bj-hands[data-count="4"] .pete-hand { min-height: 5.2rem; gap: 0.35rem; }
.bj-hands[data-count="2"] .pete-spot { height: 5.5rem; width: 5.5rem; }
.bj-hands[data-count="3"] .pete-spot,
.bj-hands[data-count="4"] .pete-spot { height: 4.25rem; width: 4.25rem; }
.bj-hands[data-count="3"] .pete-spot-label,
.bj-hands[data-count="4"] .pete-spot-label { display: none; }
/* The card a split is about to take away. It lifts, and then it's gone —
otherwise a card simply teleports into a hand that didn't exist yet. */
.bj-splitting { animation: bj-split-lift 0.32s ease forwards; }
@keyframes bj-split-lift {
from { transform: translateY(0) rotate(0); }
to { transform: translateY(-1.1rem) rotate(6deg); }
}
/* The stack in the spot. Each chip is offset up the pile by its index, so ten
chips look like ten chips rather than one chip with a number on it. */
.pete-stack {
@@ -1563,6 +1612,10 @@ html[data-phase="night"] {
animation-delay: calc(var(--i, 0) * 45ms);
transition: transform 0.16s ease, filter 0.16s ease;
}
/* A card that was already in your hand before this redraw doesn't get dealt to
you again. The hand is redrawn several times a lap now (every event that
changes it), and without this every one of them re-fans the whole thing. */
.pete-uno-hand .pete-uno-card[data-settled="1"] { animation: none; }
.pete-uno-hand .pete-uno-card[data-on="1"] {
cursor: pointer;
transform: translateY(-0.5rem);
@@ -1750,11 +1803,213 @@ html[data-phase="night"] {
.pete-uno-swatch[data-c="yellow"] { --sw: #e0b02c; color: #2b2118; }
.pete-uno-swatch[data-c="green"] { --sw: #46a86b; }
/* ---- calling it, and catching the seat that didn't -------------------------
The call is a button with a clock on it. It wants to be the loudest thing on
the felt for the two-and-a-bit seconds it exists, because the whole rule is
that you can miss it. */
.pete-uno-call {
margin-top: 0.85rem;
padding: 0.9rem 2.6rem;
border-radius: 999px;
background: #e0b02c;
color: #2b2118;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 1.7rem;
font-weight: 700;
letter-spacing: 0.02em;
box-shadow: 0 5px 0 rgba(0,0,0,0.3), 0 0 0 0 rgba(224,176,44,0.55);
animation: pete-uno-call 0.9s ease-in-out infinite;
}
.pete-uno-call:hover { filter: brightness(1.07); }
.pete-uno-call:active { transform: translateY(2px); box-shadow: 0 3px 0 rgba(0,0,0,0.3); }
@keyframes pete-uno-call {
0%, 100% { transform: scale(1); box-shadow: 0 5px 0 rgba(0,0,0,0.3), 0 0 0 0 rgba(224,176,44,0.5); }
50% { transform: scale(1.05); box-shadow: 0 5px 0 rgba(0,0,0,0.3), 0 0 0 0.9rem rgba(224,176,44,0); }
}
/* How long you've got, draining left to right. The duration is set on the
element: the window lives in uno.js, and a bar that disagrees with the clock
it's drawing is worse than no bar. */
.pete-uno-timer {
margin-top: 0.8rem;
height: 0.35rem;
border-radius: 999px;
background: rgba(255,255,255,0.16);
overflow: hidden;
}
.pete-uno-timer > span {
display: block;
height: 100%;
width: 100%;
background: #e0b02c;
transform-origin: left center;
animation: pete-uno-drain linear forwards;
}
@keyframes pete-uno-drain {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
/* A seat on one card that never said so. It gets a button, and the button is
the only thing on the felt that says it — there's no badge, because not
announcing is exactly what it did. */
.pete-uno-catch {
position: absolute;
top: -0.55rem;
left: 50%;
transform: translateX(-50%);
z-index: 6;
padding: 0.22rem 0.7rem;
border-radius: 999px;
background: #cc3d4a;
color: #fff;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.72rem;
font-weight: 700;
white-space: nowrap;
box-shadow: 0 3px 0 rgba(0,0,0,0.25);
animation: pete-uno-catch 1.1s ease-in-out infinite;
transition: filter 0.12s ease;
}
.pete-uno-catch:hover { filter: brightness(1.12); }
.pete-uno-catch:disabled { opacity: 0.45; animation: none; }
@keyframes pete-uno-catch {
0%, 100% { transform: translateX(-50%) scale(1); }
50% { transform: translateX(-50%) scale(1.08); }
}
@media (prefers-reduced-motion: reduce) {
.pete-uno-call, .pete-uno-catch { animation: none; }
}
/* ---- No Mercy -------------------------------------------------------------
A rules dial, not a fourth table, and the felt says so: the same table, the
same cards, plus six faces the normal box doesn't print and one thing that
can be pointed at you — a stack of draw cards with a bill on it.
Sizing note, and it is the rule this table was taught the hard way: a card is
sized by --uno-h/--uno-w and nothing else. These faces set a *font*, never a
box, so a "DISCARD ALL" is the same card as a "7" at every width. */
/* The long faces. "DISCARD ALL" does not fit across an oval at card size, so it
wraps — and the oval is tilted, so it wraps on the tilt, which is what the
printed card does too. Specificity has to clear the wild rule above (a
reverse-draw-four is a wild), hence the .pete-uno-card in front. */
.pete-uno-card .pete-uno-oval[data-size="mid"] { font-size: 1.05rem; }
.pete-uno-card .pete-uno-oval[data-size="words"] {
font-size: 0.5rem;
line-height: 1.1;
letter-spacing: 0.02em;
text-align: center;
padding: 0 0.15rem;
}
/* The wild draws glow.
These are the cards that decide a game, and No Mercy prints a *coloured* +4
right next to the wild one, so at a glance in a hand of twenty they have to be
tellable apart. The aura is a sibling behind the card rather than a shadow on
it: a box-shadow here would fight the one that says which colour a played wild
was named as (data-named, above), and lose.
It sits under the card (z-index -1) and does not take clicks, so the card is
still the button. */
.pete-uno-card[data-glow="1"] { position: relative; z-index: 0; }
.pete-uno-card[data-glow="1"]::before {
content: "";
position: absolute;
inset: -0.35rem;
z-index: -1;
border-radius: 0.9rem;
pointer-events: none;
background: conic-gradient(from 0deg,
#d64545, #e0b02c, #46a86b, #3d7fd6, #d64545);
filter: blur(7px);
opacity: 0.75;
animation: pete-uno-glow 3.2s linear infinite;
}
/* The shimmer across the face itself: a slow sweep of light, the way a foil card
catches the room when you tilt it. */
.pete-uno-card[data-glow="1"] .pete-uno-face::after {
content: "";
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
background: linear-gradient(115deg,
transparent 35%, rgba(255,255,255,0.55) 50%, transparent 65%);
background-size: 260% 100%;
animation: pete-uno-shine 2.6s ease-in-out infinite;
}
@keyframes pete-uno-glow {
to { transform: rotate(1turn); }
}
@keyframes pete-uno-shine {
0% { background-position: 140% 0; }
55% { background-position: -40% 0; }
100% { background-position: -40% 0; }
}
/* The bill. While a stack stands it is the only thing on the table that matters,
so it is loud, and it is red. */
.pete-uno-pending {
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.18rem 0.7rem;
border-radius: 999px;
color: #fff;
background: #cc3d4a;
box-shadow: 0 3px 0 rgba(0,0,0,0.2);
animation: pete-uno-bill 1.1s ease-in-out infinite;
}
@keyframes pete-uno-bill {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.06); }
}
/* A seat the mercy rule buried. It stays at the table — the felt would jump if
it didn't, and you want to see who's gone — but it is plainly out. */
.pete-uno-seat[data-out="1"] {
opacity: 0.4;
filter: grayscale(1);
}
.pete-uno-seat[data-out="1"] .pete-uno-count { color: #ffb3ba; }
/* The rules switch: two dials, and this is the one that isn't the table size. */
.pete-seg {
display: inline-flex;
padding: 0.2rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ink) 6%, transparent);
}
.pete-seg-btn {
padding: 0.3rem 0.9rem;
border-radius: 999px;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.8rem;
font-weight: 700;
color: color-mix(in srgb, var(--ink) 55%, transparent);
transition: background 0.15s ease, color 0.15s ease;
}
.pete-seg-btn:hover { color: var(--ink); }
.pete-seg-btn[data-on="1"] {
background: var(--accent);
color: #fff;
box-shadow: 0 2px 0 rgba(0,0,0,0.15);
}
/* A phone. The cards get smaller so a hand of ten still fits without the felt
scrolling sideways, which is the thing to check at 390px with a game on it. */
scrolling sideways, which is the thing to check at 390px with a game on it.
No Mercy deals bigger hands than that — it is a deck built to bury you — so
this is the table where a twenty-card hand has to wrap and not overflow. */
@media (max-width: 639px) {
.pete-uno-hand { --uno-h: 5.2rem; --uno-w: 3.5rem; gap: 0.3rem; }
.pete-uno-deck { --uno-h: 5.2rem; --uno-w: 3.5rem; }
.pete-uno-card .pete-uno-oval[data-size="words"] { font-size: 0.42rem; }
.pete-uno-card .pete-uno-oval[data-size="mid"] { font-size: 0.9rem; }
/* The vars, not just the box: the card in the discard is a .pete-uno-card and
takes its size from them, so sizing the box alone left a full-size card
hanging out of a small hole, on top of the colour in play. */
@@ -1783,6 +2038,11 @@ html[data-phase="night"] {
.pete-clock[data-hot="1"],
.pete-answer[data-state="wrong"] { animation: none; }
.pete-rung { transition: none; }
/* The wild draws keep their glow — it is what tells the card apart from the
coloured +4 beside it, so it is information — but it stops moving. */
.pete-uno-card[data-glow="1"]::before { animation: none; }
.pete-uno-card[data-glow="1"] .pete-uno-face::after { display: none; }
.pete-uno-pending { animation: none; }
}
}
@@ -1836,22 +2096,30 @@ html[data-room] .cs-room {
radial-gradient(120% 90% at 50% 120%, rgba(0,0,0,0.45), transparent 55%);
}
/* Casino Night is lit by bulbs, so it gets a few of them: a slow chase across
the top of the room. Cheap (one gradient, one transform) and it makes the
place feel plugged in rather than painted. */
/* Casino Night is lit by bulbs, so it gets a row of them: a slow chase across
the top of the room. One radial tile repeated, one transform — each bulb is a
hot white core in a yellow body, and the glow is a pair of drop-shadows so it
actually throws light rather than being a painted dot. */
html[data-room="casino-night"] .cs-room::before {
content: "";
position: absolute;
inset: 0 -50% auto -50%;
height: 0.5rem;
background: repeating-linear-gradient(90deg,
rgba(255,204,47,0.55) 0 0.5rem, transparent 0.5rem 2.25rem);
filter: blur(1px);
animation: cs-bulbs 2.4s linear infinite;
inset: 0.4rem -50% auto -50%;
height: 0.7rem;
background:
radial-gradient(circle at center,
#fffbe6 0 0.14rem,
#ffcc2f 0.14rem 0.26rem,
rgba(255,204,47,0.45) 0.26rem 0.42rem,
transparent 0.44rem)
0.3rem 50% / 1.9rem 100% repeat-x;
filter:
drop-shadow(0 0 3px rgba(255,214,90,0.95))
drop-shadow(0 0 10px rgba(255,204,47,0.6));
animation: cs-bulbs 1.8s linear infinite;
}
@keyframes cs-bulbs {
from { transform: translateX(0); }
to { transform: translateX(2.75rem); }
to { transform: translateX(1.9rem); }
}
/* The house shadow, restruck for a dark room: the news app's soft brown lift is
@@ -1894,3 +2162,278 @@ html[data-room] .pete-felt {
.cs-stack[data-chip="25"] { --chip: #4caf7d; }
.cs-stack[data-chip="100"] { --chip: #2b2118; }
.cs-stack[data-chip="500"] { --chip: #b079d6; }
/* ── Hold'em ────────────────────────────────────────────────────────────────
The one table with other people at it. Everything else in the casino has a
single bet spot, because there was only ever one player; here every seat has
its own, chips travel between them and the pot rather than between you and the
house, and the pot in the middle is the thing they all move toward.
Seats are a wrapping row rather than an ellipse. An oval of six seats is what a
poker table looks like, and it is also what stops fitting the moment a phone is
held up — this keeps the geometry honest at 390px and still reads as a table
because the board and the pot sit in the middle of it, which is where the eye
goes anyway. */
.pete-poker { --seat-w: 7.5rem; }
.pete-poker-seats {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 0.75rem;
}
.pete-seat {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
width: var(--seat-w);
transition: opacity 0.3s ease, filter 0.3s ease;
}
/* A folded seat is still there — you can see they're in the game and out of the
hand, which is information you're entitled to. It just stops competing. */
.pete-seat[data-state="folded"] { opacity: 0.38; filter: grayscale(0.7); }
.pete-seat[data-state="out"] { opacity: 0.2; }
/* Whose turn it is. The ring is the only thing on the felt that moves on its own,
because it is the only thing you are waiting for. */
.pete-seat[data-turn="1"] .pete-seat-plate {
box-shadow: 0 0 0 2px var(--accent), 0 0 1.6rem -0.2rem var(--accent);
animation: pete-seat-wait 1.6s ease-in-out infinite;
}
@keyframes pete-seat-wait {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
.pete-seat-cards {
display: flex;
justify-content: center;
gap: 0.15rem;
min-height: 4.4rem;
--card-h: 4.4rem;
--card-w: 3.15rem;
}
/* A seat that folded throws its cards in. */
.pete-seat-cards[data-mucked="1"] { opacity: 0; transform: translateY(-0.6rem) scale(0.9); transition: all 0.35s ease; }
.pete-seat-plate {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding: 0.3rem 0.4rem;
border-radius: 0.85rem;
background: rgba(0,0,0,0.32);
border: 1px solid rgba(255,255,255,0.12);
transition: box-shadow 0.25s ease;
}
.pete-seat-name {
font-family: var(--font-display, inherit);
font-weight: 700;
font-size: 0.8rem;
line-height: 1.1;
color: rgba(255,255,255,0.92);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pete-seat-stack {
font-weight: 700;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
color: rgba(255,255,255,0.58);
}
.pete-seat[data-state="allin"] .pete-seat-stack::after {
content: " all in";
color: var(--accent);
}
/* The dealer button, and the blinds. Small, and worth having: position is most of
what makes one hand different from the next, and a player who cannot see where
the button is cannot see the game. */
.pete-seat-pos {
position: absolute;
top: -0.5rem;
right: -0.5rem;
display: grid;
place-items: center;
min-width: 1.45rem;
height: 1.45rem;
padding: 0 0.3rem;
border-radius: 999px;
font-size: 0.58rem;
font-weight: 800;
letter-spacing: 0.02em;
background: rgba(255,255,255,0.9);
color: #2b2118;
box-shadow: 0 2px 0 rgba(0,0,0,0.3);
}
.pete-seat-pos[data-pos="BTN"] { background: #f5d76e; }
/* Every seat's bet, sitting between them and the pot.
A .pete-spot is 7rem across, because blackjack has exactly one of them. Six of
those is most of a felt, so a seat's is less than half the size — and so are the
chips in it, which are otherwise bigger than the ring they land in. */
.pete-seat-spot {
height: 3.6rem;
width: 3.6rem;
margin-bottom: 0.5rem; /* the bet total hangs below the ring; leave it somewhere to hang */
}
.pete-seat-spot .pete-disc {
height: 1.6rem;
width: 1.6rem;
margin: -0.8rem 0 0 -0.8rem;
}
.pete-seat-spot .pete-spot-total {
font-size: 0.66rem;
padding: 0.05rem 0.45rem;
}
/* The middle of the table: the board, and the pot under it. */
.pete-poker-middle {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0;
}
/* The pot's chips stack upwards, so they need room under the board or they climb
into the river card. */
.pete-poker-pot { margin-top: 0.9rem; }
/* The board keeps its height when it is empty, so the felt does not jump a hundred
pixels the moment the flop lands. */
.pete-poker-board {
display: flex;
gap: 0.35rem;
min-height: 6rem;
--card-h: 6rem;
--card-w: 4.3rem;
}
.pete-poker-pot {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
}
/* The pot's chips need a box to themselves. .pete-stack is position:absolute with
inset:0, so a pile that shares a box with the number under it lands on top of
the number — which is exactly what it did: the pot showed a chip and no total. */
.pete-poker-pot-pile {
position: relative;
width: 7rem;
height: 3rem;
}
.pete-poker-pot-label {
font-size: 0.62rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255,255,255,0.42);
}
.pete-poker-pot-total {
font-family: var(--font-display, inherit);
font-size: 1.5rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #fff;
text-shadow: 0 2px 0 rgba(0,0,0,0.28);
}
.pete-poker-side {
font-size: 0.68rem;
font-weight: 700;
color: rgba(255,255,255,0.5);
}
/* Your seat. Bigger cards, because they are the two you are actually reading. */
.pete-poker-you .pete-seat { width: auto; }
.pete-poker-you .pete-seat-cards {
--card-h: 6.8rem;
--card-w: 4.85rem;
gap: 0.3rem;
min-height: 6.8rem;
}
.pete-poker-you .pete-seat-plate { padding: 0.4rem 1.1rem; }
.pete-poker-you .pete-seat-name { font-size: 0.95rem; }
.pete-poker-you .pete-seat-stack { font-size: 0.95rem; }
/* What the hand did to you, said once, in the middle of the felt. */
.pete-poker-verdict {
border-radius: 999px;
background: rgba(255,255,255,0.95);
padding: 0.5rem 1.25rem;
font-family: var(--font-display, inherit);
font-size: 1.05rem;
font-weight: 700;
color: #2b2118;
box-shadow: 0 4px 0 rgba(0,0,0,0.18);
}
.pete-poker-verdict[data-tone="win"] { background: #4caf7d; color: #fff; }
.pete-poker-verdict[data-tone="lose"] { background: rgba(255,255,255,0.9); color: #7a5c50; }
/* The rail. Chat runs along the felt — messages only, no typing indicators, and
it never leaves for Matrix. Your own lines lean the other way and take the
accent, so a glance tells you who is talking. */
.pete-chat-line {
display: flex;
gap: 0.4rem;
align-items: baseline;
padding: 0.15rem 0.1rem;
line-height: 1.35;
}
.pete-chat-who {
flex-shrink: 0;
font-weight: 700;
color: var(--accent);
}
.pete-chat-body {
color: color-mix(in srgb, var(--ink) 85%, transparent);
word-break: break-word;
}
.pete-chat-mine .pete-chat-who { color: color-mix(in srgb, var(--ink) 55%, transparent); }
/* The action bar. Raise is a slider, because a raise is a *size* and a text box
makes you type a number you have not thought about. */
.pete-raise {
display: flex;
align-items: center;
gap: 0.6rem;
flex: 1 1 14rem;
min-width: 0;
}
.pete-raise input[type="range"] {
flex: 1;
min-width: 0;
accent-color: var(--accent);
}
.pete-raise-to {
font-family: var(--font-display, inherit);
font-size: 1.15rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
min-width: 3.5rem;
text-align: right;
}
.pete-poker-log {
max-height: 7rem;
overflow-y: auto;
font-size: 0.75rem;
line-height: 1.5;
color: rgba(255,255,255,0.55);
}
.pete-poker-log b { color: rgba(255,255,255,0.85); font-weight: 700; }
@media (max-width: 640px) {
.pete-poker { --seat-w: 5.6rem; }
.pete-seat-cards { --card-h: 3.5rem; --card-w: 2.5rem; min-height: 3.5rem; }
.pete-poker-board { --card-h: 5rem; --card-w: 3.55rem; min-height: 5rem; gap: 0.25rem; }
.pete-poker-you .pete-seat-cards { --card-h: 6rem; --card-w: 4.3rem; min-height: 6rem; }
.pete-poker-pot-total { font-size: 1.25rem; }
}

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,8 @@
// The blackjack table.
//
// The browser holds no game. It sends intents — deal, hit, stand, double — and
// the server answers with the cards you're allowed to see plus the *script* of
// how they got there: one event per card off the shoe, in the order the shoe
// The browser holds no game. It sends intents — deal, hit, stand, double, split
// — and the server answers with the cards you're allowed to see plus the *script*
// of how they got there: one event per card off the shoe, in the order the shoe
// gave them up. This file's job is to play that script back at a human speed
// rather than snapping the finished hand into place.
//
@@ -16,6 +16,10 @@
// taken away. Nothing about the money changes on this table without something
// crossing the felt to make it change — including the count in the chip bar,
// which is deliberately not updated until the chips that justify it have landed.
//
// Since split, "the spot" is not one place. A hand has a spot, and a split makes
// a second hand with a second bet on a second spot — two bets that win and lose
// separately, which is the whole point of splitting and has to look like it.
(function () {
"use strict";
@@ -25,9 +29,7 @@
var FX = window.PeteFX;
var dealerEl = root.querySelector("[data-dealer]");
var playerEl = root.querySelector("[data-player]");
var dTotalEl = root.querySelector("[data-dealer-total]");
var pTotalEl = root.querySelector("[data-player-total]");
var dLabelEl = root.querySelector("[data-dealer-label]");
var verdictEl = root.querySelector("[data-verdict]");
var betting = root.querySelector("[data-betting]");
@@ -36,26 +38,22 @@
var dealBtn = root.querySelector("[data-deal]");
var msgEl = root.querySelector("[data-table-msg]");
// The three places a chip can be: your pile (in the bar above), the spot in
// front of you, and the house's rack on the felt.
// Where the chips live: your pile (in the bar above), a spot in front of each
// hand, and the house's rack on the felt.
var purseEl = document.querySelector("[data-chips]");
var spotEl = root.querySelector("[data-spot]");
var stackEl = root.querySelector("[data-stack]");
var spotTotalEl = root.querySelector("[data-spot-total]");
var houseEl = root.querySelector("[data-house]");
// The spot owns the chips on the felt and the number under them — see PeteFX.
// Nothing is bet until a chip is on it, so `bet` starts at nothing rather than
// at a default stake nobody put down.
var spot = FX.spot({ spot: spotEl, stack: stackEl, total: spotTotalEl });
var handsEl = root.querySelector("[data-hands]");
var handTpl = root.querySelector("[data-hand-template]");
var bet = 0; // what you're building between hands
var base = 0; // what one hand of the last deal cost, for standing it back up
var busy = false; // a request is in flight, or cards are still landing
var hand = null; // the hand as the server last described it
var hand = null; // the deal as the server last described it
var DEAL_MS = 380; // one card's flight, and the gap before the next
var FLIP_MS = 450;
var BEAT_MS = 600; // the dealer thinking before they draw out
var SPLIT_MS = 340; // the card lifting out of the hand it's leaving
var reduced = FX.reduced;
function pace(ms) { return reduced ? 0 : ms; }
@@ -80,46 +78,137 @@
function cardEl(face) { return CARDS.el(face); }
var turnOver = CARDS.turnOver;
// ---- the hands -------------------------------------------------------------
//
// A box per hand: its chips, its cards, its total, its own verdict. There is
// always at least one, and before a deal that one is where you build your bet —
// so the chips you stack are already sitting on the hand they're about to buy.
var hands = [];
function makeHand(at) {
var el = handTpl.content.firstElementChild.cloneNode(true);
var spotEl = el.querySelector("[data-spot]");
var box = {
el: el,
spotEl: spotEl, // where a chip flies to, as opposed to what counts it
cards: el.querySelector("[data-cards]"),
total: el.querySelector("[data-total]"),
verdict: el.querySelector("[data-hand-outcome]"),
ranks: [], // what's in it, for a running total while the cards are landing
spot: FX.spot({
spot: spotEl,
stack: el.querySelector("[data-stack]"),
total: el.querySelector("[data-spot-total]"),
}),
};
if (at === undefined || at >= hands.length) {
handsEl.appendChild(el);
hands.push(box);
} else {
handsEl.insertBefore(el, hands[at].el);
hands.splice(at, 0, box);
}
handsEl.dataset.count = hands.length;
return box;
}
// reset tears the row down to n empty hands. It does not preserve chips: a
// caller that has some to keep (a deal, whose stake is already on the spot)
// reads the amount first and puts it back.
function reset(n) {
handsEl.innerHTML = "";
hands = [];
for (var i = 0; i < (n || 1); i++) makeHand();
}
function ensure(n) { while (hands.length < n) makeHand(); }
// The bet you build between hands is hand zero's.
function betSpot() { return hands[0].spot; }
// live marks the hand you're being asked about, and dims the ones you aren't.
//
// -1 means nobody is acting — the dealer is drawing, or the deal is over — and
// that is *not* the same as every hand being inactive: a settled hand is the
// thing you are reading, so it goes back to full brightness rather than sitting
// there greyed out. Hence the empty string: it matches neither CSS rule.
function live(i) {
hands.forEach(function (h, k) {
h.el.dataset.live = i < 0 ? "" : k === i ? "1" : "0";
});
}
// The running total under a hand, worked out from the cards you can see. The
// server sends totals too, but only with the finished state — and a hand you're
// playing needs to know what it's holding *now*, not once it's over. It's a
// readout, never a decision: every ruling is the server's.
var TEN = { K: 10, Q: 10, J: 10 };
function totalOf(ranks) {
var total = 0, aces = 0;
ranks.forEach(function (r) {
if (r === "A") { aces++; total += 11; }
else total += TEN[r] || parseInt(r, 10) || 0;
});
while (total > 21 && aces > 0) { total -= 10; aces--; }
return { total: total, soft: aces > 0 };
}
function showTotal(box) {
if (!box.ranks.length) { box.total.classList.add("hidden"); return; }
var v = totalOf(box.ranks);
box.total.textContent = v.total + (v.soft ? " (soft)" : "");
box.total.classList.remove("hidden");
}
var SHORT = {
blackjack: "Blackjack", win: "Won", dealer_bust: "Won",
lose: "Lost", bust: "Bust", push: "Push",
};
// A hand's own result, on the hand. With one hand this says the same thing as
// the pill above it and is a bit redundant; with four it is the only way to
// read what happened, because "you win" is not true of all of them.
function showHandVerdict(box, h) {
var text = h && SHORT[h.outcome];
if (!text) { box.verdict.classList.add("hidden"); return; }
box.verdict.textContent = text;
box.verdict.classList.remove("hidden");
}
// ---- the money on the felt -------------------------------------------------
// stake moves chips from your pile onto the spot: the bet you build before a
// deal, and the second bet a double puts down beside it.
function stake(amount, from) {
return spot.pour(from || purseEl, amount);
}
// settleChips is what the felt does about the outcome, after the cards have
// finished telling you what it is. It reads the same two numbers the ledger
// moved: `bet` (already off your pile since the deal) and `payout` (what comes
// back — stake plus winnings less rake, or nothing at all).
// finished telling you what it is. Each hand is paid on its own, in order, left
// to right — because each hand *is* its own bet, and a split that lost one and
// won the other has to be watchable as exactly that.
function settleChips(final) {
var payout = final.payout || 0;
var back = payout - final.bet; // what the house is adding, if anything
var chain = Promise.resolve();
(final.hands || []).forEach(function (h, i) {
chain = chain.then(function () {
var box = hands[i];
if (!box) return;
var payout = h.payout || 0;
if (payout <= 0) {
// The house takes it. The stack goes to the rack and doesn't come back.
return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
}
// The house pays first, into the spot beside your stake, so you watch the
// winnings arrive on top of the bet that earned them.
return spot
.pour(houseEl, back, { gap: 60 })
.then(function () { return wait(back > 0 ? 380 : 200); })
// Paid, then swept up: the whole lot comes back to your pile, and only then
// does the number in the bar move.
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
if (payout <= 0) {
// The house takes it. The stack goes to the rack and doesn't come back.
return box.spot.sweep(houseEl, h.bet, { gap: 45, lift: 0.6, fade: true });
}
// The house pays first, into the spot beside the stake, so you watch the
// winnings arrive on top of the bet that earned them.
var back = payout - h.bet;
return box.spot
.pour(houseEl, back, { gap: 60 })
.then(function () { return wait(back > 0 ? 340 : 160); })
.then(function () { return box.spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
});
});
return chain;
}
function totals(v) {
if (v.total) {
pTotalEl.textContent = v.total + (v.soft ? " (soft)" : "");
pTotalEl.classList.remove("hidden");
} else {
pTotalEl.classList.add("hidden");
}
// While the hole card is down, the dealer's total is only what's showing —
// so say so, rather than printing a number that quietly means something else.
// While the hole card is down, the dealer's total is only what's showing — so
// say so, rather than printing a number that quietly means something else.
function dealerTotal(v) {
if (v.dealer && v.dealer.length) {
dTotalEl.textContent = v.hole ? v.dealer_total + " showing" : String(v.dealer_total);
dTotalEl.classList.remove("hidden");
@@ -128,19 +217,28 @@
}
}
// paint puts a hand on the felt with no animation. This is the resume path:
// you reloaded, or Pete restarted, and your cards are simply there — including
// the stake, which is still on the spot because the server still has it.
// paint puts a deal on the felt with no animation. This is the resume path: you
// reloaded, or Pete restarted, and your cards are simply there — including the
// stakes, which are still on their spots because the server still has them.
function paint(v) {
dealerEl.innerHTML = "";
playerEl.innerHTML = "";
if (!v) { setPhase(null); spot.render(0); return; }
if (!v) { reset(1); setPhase(null); return; }
reset(v.hands.length || 1);
v.hands.forEach(function (h, i) {
var box = hands[i];
(h.cards || []).forEach(function (c) {
box.cards.appendChild(cardEl(c));
box.ranks.push(c.rank);
});
box.spot.render(v.phase === "done" ? 0 : h.bet);
showTotal(box);
showHandVerdict(box, v.phase === "done" ? h : null);
});
v.player.forEach(function (c) { playerEl.appendChild(cardEl(c)); });
v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); });
if (v.hole) dealerEl.appendChild(cardEl(null));
spot.render(v.phase === "done" ? 0 : v.bet);
totals(v);
dealerTotal(v);
setPhase(v);
}
@@ -155,30 +253,46 @@
function verdict(v) {
var text = VERDICTS[v.outcome] || "";
// Across several hands "you win" is a claim about the money, not about the
// cards: you can win one, lose one, and still be up. The pill reports the
// deal; the badge on each hand reports the hand.
if ((v.hands || []).length > 1) {
text = v.net > 0 ? "You're up on the deal." : v.net < 0 ? "Down on the deal." : "All square.";
}
if (!text) { verdictEl.classList.add("hidden"); return; }
if (v.net > 0) text += " +" + v.net.toLocaleString();
else if (v.net < 0) text += " " + v.net.toLocaleString();
verdictEl.textContent = text;
verdictEl.classList.remove("hidden");
playerEl.dataset.won = v.net > 0 ? "1" : v.net < 0 ? "-1" : "0";
// The one thing in this room that gets confetti. A natural is rare, it pays
// 3:2, and if everything celebrated then nothing would.
//
// The *sound* is not so precious: a win is a win and you should hear it. So
// the fanfare rides on the money, not on the confetti.
if (v.outcome === "blackjack") FX.burst(verdictEl, { count: 34 });
else if (v.net > 0) FX.sfx("win");
else if (v.net < 0) FX.sfx("lose");
else FX.sfx("push");
}
// setPhase swaps the controls: bet between hands, act during one.
function setPhase(v) {
hand = v;
var live = !!v && v.phase === "player";
betting.classList.toggle("hidden", live);
actions.classList.toggle("hidden", !live);
var acting = !!v && v.phase === "player";
betting.classList.toggle("hidden", acting);
actions.classList.toggle("hidden", !acting);
if (live) {
if (acting) {
var dbl = actions.querySelector('[data-move="double"]');
var spl = actions.querySelector('[data-move="split"]');
if (dbl) dbl.disabled = !v.can_double;
if (spl) spl.disabled = !v.can_split;
live(v.active || 0);
} else {
live(-1);
}
if (!v || v.phase !== "player") verdictEl.classList.toggle("hidden", !(v && v.outcome));
if (!acting) verdictEl.classList.toggle("hidden", !(v && v.outcome));
}
// ---- the script -----------------------------------------------------------
@@ -203,49 +317,93 @@
if (!settles) money();
// Whatever the server says the stake is, that's what has to be on the spot.
// Two things get here: a double, which puts a second bet down beside the
// first, and a deal whose bet was typed rather than stacked (you kept last
// hand's number and just pressed Deal). Either way the chips go down before
// the card they're buying does.
if (final && final.bet > spot.amount) {
var extra = final.bet - spot.amount;
chain = chain.then(function () { return stake(extra); });
// A deal whose bet was typed rather than stacked (you kept last hand's number
// and just pressed Deal) has chips to put down before the first card does.
// Everything else that costs money — a double, a split — announces itself as
// an event, and pays for itself there.
if (final && final.hands && final.hands.length && events.length && events[0].kind === "deal") {
var want = final.hands[0].bet;
var have = betSpot().amount;
if (want > have) {
chain = chain.then(function () { return betSpot().pour(purseEl, want - have); });
}
}
events.forEach(function (e) {
chain = chain.then(function () {
var box;
switch (e.kind) {
case "deal":
// Clear the felt, but not the stake: those chips are yours, they are
// already on the spot, and they are what this deal is riding on.
var staked = betSpot().amount;
dealerEl.innerHTML = "";
playerEl.innerHTML = "";
playerEl.dataset.won = "0";
reset(1);
betSpot().render(staked);
verdictEl.classList.add("hidden");
FX.sfx("shuffle");
return;
case "player_card":
playerEl.appendChild(cardEl(e.card));
ensure(e.hand + 1);
box = hands[e.hand];
box.cards.appendChild(cardEl(e.card));
box.ranks.push(e.card.rank);
showTotal(box);
live(e.hand);
FX.sfx("deal");
return wait(DEAL_MS);
case "split":
// The second card lifts out of the hand it was in and becomes a hand
// of its own, and the bet that follows it is a second bet: the same
// chips again, out of your pile, onto a spot that didn't exist a
// moment ago.
var src = hands[e.hand];
var moved = src.cards.lastElementChild;
var stake = src.spot.amount;
var fresh = makeHand(e.hand + 1);
if (moved) moved.classList.add("bj-splitting");
FX.sfx("deal", { v: 1 });
return wait(SPLIT_MS).then(function () {
if (moved) {
moved.classList.remove("bj-splitting");
fresh.cards.appendChild(moved);
}
fresh.ranks.push(src.ranks.pop());
showTotal(src);
showTotal(fresh);
return fresh.spot.pour(purseEl, stake);
});
case "double":
// The stake goes down again on that hand, and only that hand.
box = hands[e.hand];
return box.spot.pour(purseEl, box.spot.amount);
case "dealer_card":
// The dealer takes a moment before the first card they draw out.
// Card, card, card with no breath in between is a machine dealing;
// the pause is the only thing on this table that plays as suspense.
var beat = drew ? Promise.resolve() : think();
drew = true;
live(-1);
return beat.then(function () {
dealerEl.appendChild(cardEl(e.card));
FX.sfx("deal", { v: 1 });
return wait(DEAL_MS);
});
case "dealer_hole":
hole = cardEl(null);
dealerEl.appendChild(hole);
FX.sfx("deal", { v: 2 });
return wait(DEAL_MS);
case "reveal":
// The hole card turns over. Its face is in the final hand — this is
// the first moment the server has been willing to say what it was.
live(-1);
if (!hole) hole = dealerEl.querySelector('.pete-card[data-face="down"]');
if (hole && final && final.dealer && final.dealer[1]) {
turnOver(hole, final.dealer[1]);
@@ -261,12 +419,16 @@
return chain.then(function () {
if (!final) { paint(null); money(); return; }
totals(final);
dealerTotal(final);
if (!settles) { setPhase(final); return; }
// The hand is over: nothing is on offer while the money is moving. Hit and
// Stand go now, and Deal comes back at the far end.
// The deal is over: nothing is on offer while the money is moving. The
// actions go now, and Deal comes back at the far end.
actions.classList.add("hidden");
live(-1);
(final.hands || []).forEach(function (h, i) {
if (hands[i]) showHandVerdict(hands[i], h);
});
verdict(final);
// The chips move, and the bar catches up with them when they arrive. The
// betting controls come back last, once the felt is clear: offering Deal
@@ -274,16 +436,30 @@
// has to refuse.
return settleChips(final)
.then(money)
.then(function () { return standing(final.bet); })
.then(function () { return standing(base || unitBet(final.hands[0])); })
.then(function () { setPhase(final); });
});
}
// standing leaves your bet up for the next hand, the way you would at a table:
// the stake that just settled goes straight back on the spot. It costs nothing
// — chips on the spot are a proposal until you press Deal — and it's what keeps
// the number in the panel honest, because otherwise a settled hand leaves
// "your bet: 300" printed over an empty spot.
// one hand's stake goes straight back on the spot. It costs nothing — chips on
// the spot are a proposal until you press Deal — and it's what keeps the number
// in the panel honest, because otherwise a settled hand leaves "your bet: 300"
// printed over an empty spot.
//
// It's one hand's worth, not the whole deal's: a split cost you four hundred,
// and standing four hundred back up on one spot would be betting a stranger's
// money on your behalf.
// unitBet is what one hand of a deal actually cost, read back off a settled
// hand. It exists for the reload: `base` only knows what you pressed Deal with,
// and if you reloaded mid-hand nobody pressed Deal. A doubled hand carries twice
// the stake it was dealt with, so standing its bet back up would quietly put you
// on double the number you thought you were playing.
function unitBet(h) {
if (!h) return 0;
return h.doubled ? h.bet / 2 : h.bet;
}
function standing(amount) {
var money = window.PeteGames.view();
if (!amount || !money || money.chips < amount) {
@@ -293,7 +469,7 @@
}
bet = amount;
showBet();
return stake(amount);
return betSpot().pour(purseEl, amount);
}
// think is the dealer's beat: a pause with something to look at, so it reads as
@@ -321,7 +497,8 @@
say(err.message, "bad");
// Whatever we thought was on the felt, the server is the authority on it.
return window.PeteGames.refresh().then(function (v) {
if (v && !v.hand) spot.render(0);
if (v && !v.hand) { reset(1); setPhase(null); }
else if (v && v.hand) paint(v.hand);
});
})
.then(function () { busy = false; });
@@ -358,8 +535,9 @@
// but the spot's total moves now, so a Deal pressed mid-flight still knows
// the chip is on its way and doesn't put a second one down.
var target = bet;
var spot = betSpot();
spot.amount = bet;
FX.fly(btn, spotEl, { denom: d }).then(function () {
FX.fly(btn, hands[0].spotEl, { denom: d }).then(function () {
if (bet >= target) spot.render(target); // unless Clear got there first
});
});
@@ -368,8 +546,8 @@
var clearBtn = root.querySelector("[data-bet-clear]");
if (clearBtn) {
clearBtn.addEventListener("click", function () {
if (busy || !spot.amount) { bet = 0; showBet(); return; }
spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
if (busy || !betSpot().amount) { bet = 0; showBet(); return; }
betSpot().sweep(purseEl, null, { gap: 40, lift: 0.7 });
bet = 0;
showBet();
});
@@ -378,6 +556,7 @@
if (dealBtn) {
dealBtn.addEventListener("click", function () {
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
base = bet; // one hand's worth, which is what a split doubles and a stand puts back
send("/api/games/blackjack/deal", { bet: bet });
});
}
@@ -393,15 +572,17 @@
if (/^(input|textarea|select)$/i.test((e.target.tagName || ""))) return;
if (!hand || hand.phase !== "player" || busy) return;
var move = { h: "hit", s: "stand", d: "double" }[e.key.toLowerCase()];
var move = { h: "hit", s: "stand", d: "double", p: "split" }[e.key.toLowerCase()];
if (!move) return;
if (move === "double" && !hand.can_double) return;
if (move === "split" && !hand.can_split) return;
e.preventDefault();
send("/api/games/blackjack/move", { move: move });
});
// The money bar owns the first fetch; the table picks up whatever it found,
// including a hand left sitting on the felt by a reload or a redeploy.
// including a deal left sitting on the felt by a reload or a redeploy.
reset(1);
var resumed = false;
window.PeteGames.onUpdate(function (v) {
if (!resumed) {

View File

@@ -155,11 +155,17 @@
}
// turnOver flips a face-down card up, now that we've been told what it is.
//
// Unlike el(), this is only ever a *gesture* — a card that was down and is now
// up, because something happened. It never runs on a repaint, which is why the
// flip sound can live here and be right for every table at once, and why the
// deal sound cannot: el() runs every time a board is redrawn.
function turnOver(wrap, face) {
if (!wrap) return;
paint(wrap.querySelector(".pete-card-front"), face);
wrap.dataset.face = "up";
wrap.dataset.key = face.label;
if (window.PeteSFX) window.PeteSFX.play("flip");
}
window.PeteCards = {

View File

@@ -18,6 +18,14 @@
var reduced =
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// The room's noise, if it's loaded. Everything that flies through this file
// makes a sound when it lands, which is how every table got sound at once
// without any of them being told about it: a chip is a chip wherever it's
// thrown from. A game that wants something more specific says so per call.
function sfx(name, opts) {
if (window.PeteSFX) window.PeteSFX.play(name, opts);
}
var layer = null;
function stage() {
if (!layer) {
@@ -107,13 +115,23 @@
// than averaging itself back down to nothing.
var sMid = Math.max(s0, s1) * 1.12;
var wait = reduced ? 0 : opts.delay || 0;
// It makes a noise when it lands, and the noise is scheduled on the audio
// clock for the moment it lands — not fired by a timer that goes off when the
// animation ends. A timer that drifts thirty milliseconds is a card you hear
// before you see, and that is worse than silence.
if (opts.sound) {
sfx(opts.sound, { delay: (wait + dur) / 1000, v: opts.index || 0 });
}
var anim = node.animate(
[
{ transform: t(a.x, a.y, s0, opts.fromSpin || 0), opacity: 1, offset: 0 },
{ transform: t(midX, midY, sMid, spin * 0.6), opacity: 1, offset: 0.5 },
{ transform: t(b.x, b.y, s1, spin), opacity: opts.fade ? 0 : 1, offset: 1 },
],
{ duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: reduced ? 0 : opts.delay || 0, fill: "both" }
{ duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: wait, fill: "both" }
);
return anim.finished
@@ -127,6 +145,9 @@
// was — UNO wanted the same throw with a card in it, so the throw moved out.
function fly(from, to, opts) {
opts = opts || {};
// A chip clinks. Every table in the room bets, pays and sweeps through this
// one function, so saying it once here is every table having chip sounds.
if (opts.sound === undefined) opts = Object.assign({ sound: "chip" }, opts);
return flyNode(disc(opts.denom || 25), from, to, opts);
}
@@ -225,6 +246,7 @@
var n = amount == null ? api.amount : amount;
var left = api.amount - n;
if (n <= 0) return Promise.resolve();
sfx("sweep"); // the pile leaving the felt, under the chips landing
var chain = flyMany(els.spot, to, chipsFor(n, 8), Object.assign({ gap: 40, lift: 0.8 }, opts || {}));
api.render(left > 0 ? left : 0);
return chain;
@@ -233,8 +255,10 @@
return api;
}
// burst: confetti out of a point. Saved for the things worth celebrating.
// burst: confetti out of a point. Saved for the things worth celebrating — so
// it is also, everywhere in the room, the sound of winning.
function burst(target, opts) {
sfx("win"); // before the reduced-motion bail: no confetti still deserves the fanfare
if (reduced) return Promise.resolve();
opts = opts || {};
var c = centre(target);
@@ -326,6 +350,7 @@
window.PeteFX = {
reduced: reduced,
sfx: sfx, // so a table can make a noise without reaching past this file
chipsFor: chipsFor,
disc: disc,
jitter: jitter,

View File

@@ -0,0 +1,265 @@
// The noise the room makes.
//
// There are no audio files here and there is nothing to download. Every sound in
// this casino 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 short
// slap of noise through a bandpass; a chip is two detuned sines with a click on
// the front; a win is four notes going up. It costs about six kilobytes and no
// round trips, and it means a sound can be pitched, stretched and detuned per
// call instead of being the same wav 300 times.
//
// Two rules hold the whole file up.
//
// **Nothing is built until it's asked for.** A browser will not let a page make a
// noise before the user has touched it, and a page that builds an AudioContext on
// load gets one in the "suspended" state and a warning in the console for its
// trouble. So the context is made on the first sound anybody actually asks for —
// which, in a casino, is a click on a chip.
//
// **Muted means silent, not quiet.** When the room is muted nothing is
// constructed, nothing is scheduled and no context is opened. Mute is the
// default-off switch for the entire file, checked before anything else happens.
//
// Exposed as window.PeteSFX. Nothing in here knows what blackjack is.
(function () {
"use strict";
var KEY = "pete.sfx.off";
var ctx = null;
var master = null;
var noiseBuf = null;
var muted = false;
var listeners = [];
try { muted = localStorage.getItem(KEY) === "1"; } catch (e) {}
// ---- the instrument --------------------------------------------------------
function boot() {
if (ctx) return ctx;
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
try {
ctx = new AC();
} catch (e) {
return null;
}
master = ctx.createGain();
master.gain.value = 0.45; // the room is furniture, not a nightclub
master.connect(ctx.destination);
return ctx;
}
// A second of white noise, made once and played at different speeds and through
// different filters for the rest of the session. Card flicks, riffles, the
// transient on a chip: all of them are this buffer wearing a different coat.
function noise() {
if (noiseBuf) return noiseBuf;
var n = ctx.sampleRate;
noiseBuf = ctx.createBuffer(1, n, n);
var d = noiseBuf.getChannelData(0);
for (var i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
return noiseBuf;
}
// env is the shape of every sound in here: up fast, down slow, and never to
// zero — an exponential ramp to actual zero is undefined, and a browser that
// hits one either throws or clicks.
function env(g, t0, attack, decay, peak) {
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(Math.max(0.0001, peak), t0 + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + attack + decay);
}
// tone is one note.
function tone(t0, freq, o) {
o = o || {};
var osc = ctx.createOscillator();
var g = ctx.createGain();
osc.type = o.type || "sine";
osc.frequency.setValueAtTime(freq, t0);
if (o.to) osc.frequency.exponentialRampToValueAtTime(o.to, t0 + (o.glide || o.decay || 0.1));
env(g, t0, o.attack || 0.004, o.decay || 0.12, o.gain == null ? 0.3 : o.gain);
osc.connect(g).connect(master);
osc.start(t0);
osc.stop(t0 + (o.attack || 0.004) + (o.decay || 0.12) + 0.02);
}
// hiss is a burst of the noise buffer through a filter, which is what a card,
// a riffle and the knock on a chip all are.
function hiss(t0, o) {
o = o || {};
var src = ctx.createBufferSource();
src.buffer = noise();
src.playbackRate.value = o.rate || 1;
var f = ctx.createBiquadFilter();
f.type = o.filter || "bandpass";
f.frequency.setValueAtTime(o.freq || 2000, t0);
if (o.sweepTo) f.frequency.exponentialRampToValueAtTime(o.sweepTo, t0 + (o.decay || 0.1));
f.Q.value = o.q == null ? 1.1 : o.q;
var g = ctx.createGain();
env(g, t0, o.attack || 0.003, o.decay || 0.09, o.gain == null ? 0.3 : o.gain);
src.connect(f).connect(g).connect(master);
src.start(t0);
src.stop(t0 + (o.attack || 0.003) + (o.decay || 0.09) + 0.02);
}
// ---- the sounds ------------------------------------------------------------
//
// Each one takes the time it starts at, and a `v` — a small per-call variation,
// usually the index of the card or chip in a run. Four cards dealt with the
// identical sample four times is a machine gun; four cards each a semitone off
// is a hand dealing.
var SOUNDS = {
// A card landing: mostly air, with a slap on the front of it.
card: function (t, v) {
hiss(t, { freq: 1800 + v * 140, q: 0.9, decay: 0.075, rate: 1 + v * 0.05, gain: 0.3 });
hiss(t, { filter: "highpass", freq: 4200, decay: 0.02, gain: 0.16 });
},
// Dealing is the same card, lower and softer: it's being placed, not thrown.
deal: function (t, v) {
hiss(t, { freq: 1250 + v * 110, q: 1.1, decay: 0.085, rate: 0.9, gain: 0.24 });
},
// A card turning over — shorter, brighter, and it comes in two halves, which
// is what a flip actually sounds like.
flip: function (t) {
hiss(t, { freq: 2600, q: 1.4, decay: 0.035, gain: 0.22 });
hiss(t + 0.045, { freq: 1500, q: 1.0, decay: 0.06, gain: 0.24 });
},
// The riffle. One long sweep of noise with the filter climbing through it,
// and a rattle laid over the top so it isn't just a shhh.
shuffle: function (t) {
hiss(t, { freq: 700, sweepTo: 3400, q: 0.7, attack: 0.02, decay: 0.34, gain: 0.2 });
for (var i = 0; i < 9; i++) {
hiss(t + 0.03 + i * 0.032, { freq: 2400 + i * 130, q: 2.2, decay: 0.02, gain: 0.09 });
}
},
// A chip landing on a chip. Two detuned partials well above the staff, with a
// knock underneath — the knock is most of what makes it read as *clay* rather
// than as a bell.
chip: function (t, v) {
var f = 1750 + v * 55;
tone(t, f, { type: "sine", decay: 0.07, gain: 0.16 });
tone(t, f * 1.34, { type: "sine", decay: 0.05, gain: 0.1 });
hiss(t, { filter: "lowpass", freq: 900, decay: 0.03, gain: 0.3, q: 0.5 });
},
// Chips sliding away across felt.
sweep: function (t) {
hiss(t, { freq: 1600, sweepTo: 500, q: 0.6, attack: 0.015, decay: 0.26, gain: 0.16 });
},
// Four notes up. This is the only sound in the room allowed to be pleased.
win: function (t) {
[523.25, 659.25, 783.99, 1046.5].forEach(function (f, i) {
tone(t + i * 0.085, f, { type: "triangle", decay: 0.26, gain: 0.24 });
});
},
// Two notes down, and the second one is flat. Nobody needs telling twice.
lose: function (t) {
tone(t, 311.13, { type: "triangle", decay: 0.24, gain: 0.22 });
tone(t + 0.15, 233.08, { type: "triangle", decay: 0.42, gain: 0.22 });
},
// Nothing happened and nothing was lost.
push: function (t) {
tone(t, 392, { type: "sine", decay: 0.2, gain: 0.16 });
},
// A button. Short enough that you feel it rather than hear it.
blip: function (t) {
tone(t, 880, { type: "sine", decay: 0.055, gain: 0.12 });
},
// UNO, called. A bright stab plus a zip upwards: it is a shout, and it is the
// one thing on that table you want to feel good about pressing.
uno: function (t) {
[659.25, 830.61, 987.77].forEach(function (f) {
tone(t, f, { type: "triangle", decay: 0.3, gain: 0.2 });
});
tone(t, 500, { type: "sawtooth", to: 1600, glide: 0.16, decay: 0.18, gain: 0.09 });
},
// Got them. A rising snap with a thump on the end — the sound of a finger
// being pointed.
catch: function (t) {
tone(t, 300, { type: "square", to: 900, glide: 0.11, decay: 0.12, gain: 0.13 });
hiss(t + 0.1, { filter: "lowpass", freq: 1100, decay: 0.1, gain: 0.34, q: 0.7 });
tone(t + 0.1, 174.61, { type: "triangle", decay: 0.22, gain: 0.2 });
},
// Something bad landed on you: a stack, a +4, a catch you walked into.
bad: function (t) {
tone(t, 155.56, { type: "square", to: 98, glide: 0.18, decay: 0.22, gain: 0.14 });
hiss(t, { filter: "lowpass", freq: 700, decay: 0.14, gain: 0.3, q: 0.6 });
},
// A clock you can hear. Used sparingly, and never in a run of more than a few.
tick: function (t) {
hiss(t, { freq: 3200, q: 3, decay: 0.02, gain: 0.14 });
},
};
// ---- the door --------------------------------------------------------------
// A browser will not make a noise until the user has touched the page, and a
// context built before that arrives suspended. This wakes it on the first real
// gesture — after which play() can schedule freely.
function wake() {
if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {});
}
["pointerdown", "keydown", "touchstart"].forEach(function (ev) {
window.addEventListener(ev, wake, { passive: true });
});
// play makes a sound, or — if the room is muted — makes nothing at all, opens no
// context and builds no graph.
//
// `v` is the variation: pass the index of the card or chip in a run and the
// sound moves a little each time, which is the difference between a hand and a
// machine. `delay` schedules it ahead in seconds, so a caller that knows a card
// lands in 400ms can say so rather than sleeping.
function play(name, opts) {
if (muted) return;
var s = SOUNDS[name];
if (!s) return;
if (!boot()) return;
wake();
if (ctx.state !== "running") return; // not yet touched: no sound, and no error
try {
s(ctx.currentTime + ((opts && opts.delay) || 0), ((opts && opts.v) || 0));
} catch (e) {
/* a sound is never worth throwing over */
}
}
function setMuted(on) {
muted = !!on;
try {
if (muted) localStorage.setItem(KEY, "1");
else localStorage.removeItem(KEY);
} catch (e) {}
if (window.PetePrefs) window.PetePrefs.push();
listeners.forEach(function (fn) { fn(muted); });
if (!muted) play("blip"); // so you know what you just turned back on
}
window.PeteSFX = {
play: play,
muted: function () { return muted; },
toggle: function () { setMuted(!muted); return muted; },
set: setMuted,
// onChange lets the mute button re-label itself without owning the state.
onChange: function (fn) { listeners.push(fn); fn(muted); },
};
})();

View File

@@ -154,6 +154,25 @@
});
}
// The room's volume. The button lives in the chip bar, which is the one thing
// every table has — the sound belongs to the room, not to any one game — and
// the state itself belongs to PeteSFX, which persists it. This only draws it.
var sfxBtn = document.querySelector("[data-sfx-toggle]");
if (sfxBtn && window.PeteSFX) {
var onIcon = sfxBtn.querySelector("[data-sfx-on]");
var offIcon = sfxBtn.querySelector("[data-sfx-off]");
var sfxLbl = sfxBtn.querySelector("[data-sfx-label]");
window.PeteSFX.onChange(function (muted) {
sfxBtn.setAttribute("aria-pressed", muted ? "true" : "false");
sfxBtn.title = muted ? "Sound is off" : "Sound is on";
if (onIcon) onIcon.classList.toggle("hidden", muted);
if (offIcon) offIcon.classList.toggle("hidden", !muted);
if (sfxLbl) sfxLbl.textContent = muted ? "Turn the sound on" : "Mute the room";
});
sfxBtn.addEventListener("click", function () { window.PeteSFX.toggle(); });
}
window.PeteGames = {
// onUpdate registers a listener called on every fresh view of the money.
onUpdate: function (fn) { listeners.push(fn); if (view) fn(view); },

View File

@@ -117,6 +117,9 @@
at.forEach(function (i, n) {
var t = boardEl.querySelector('.pete-tile[data-at="' + i + '"]');
if (!t) return;
// One note per tile, climbing: a letter that turns up three times should
// sound like it's worth three times as much, because it is.
FX.sfx("tick", { delay: pace(n * 90) / 1000, v: n });
setTimeout(function () {
// Left as it comes: the tile is uppercased in CSS, and doing it here too
// would mean the resume path (which paints the phrase's own casing) and
@@ -126,6 +129,7 @@
t.classList.add("pete-tile-hit");
}, pace(n * 90));
});
FX.sfx("flip");
return wait(FLIP_MS + (at.length - 1) * 90);
}
@@ -292,6 +296,8 @@
// Confetti for a phrase guessed outright — the one call you make on your own
// rather than by grinding the alphabet.
if (v.outcome === "solved" && v.net > 0) FX.burst(verdictEl, { count: 30 });
else if (v.net > 0) FX.sfx("win");
else if (v.net < 0) FX.sfx("lose");
}
function setPhase(v) {
@@ -362,6 +368,7 @@
// they are one event: this is what a wrong guess costs, all of it.
drawGallows(countMisses(events, e), true);
shake();
FX.sfx("bad");
if (final) knock(final);
return wait(MISS_MS);

View File

@@ -0,0 +1,895 @@
// The hold'em table.
//
// Same bargain as every other table in the room: the browser holds no game. It
// sends one move, and what comes back is that move *and every bot action behind
// it* — plus whatever streets that finished and whatever the pot did — as a
// script of events. So a round trip here can be a whole hand: shove all-in and
// the flop, turn, river, showdown and payout all arrive in one response, and this
// file's job is to play them back slowly enough that you can watch it happen to
// you.
//
// The one thing here that no other table has is a *second seat with money on it*.
// Everywhere else the spot is a singleton, because there was only ever you and
// the house. Here every seat has its own, chips move from a seat to its spot and
// from every spot into the pot, and out of the pot to whoever won it. PeteFX.spot
// still owns the rule that the number under a pile is a readout of that pile, so
// none of that arithmetic lives in here.
//
// And the browser never learns a bot's hand. Their cards are backs until a
// showdown turns them over, because a showdown is the only time a player at a
// real table would be looking at them.
(function () {
"use strict";
var root = document.querySelector("[data-holdem]");
if (!root) return;
var FX = window.PeteFX;
var Cards = window.PeteCards;
var seatsEl = root.querySelector("[data-seats]");
var youEl = root.querySelector("[data-you]");
var boardEl = root.querySelector("[data-board]");
var potStack = root.querySelector("[data-pot-stack]");
var potTotal = root.querySelector("[data-pot-total]");
var sideEl = root.querySelector("[data-side]");
var blindsEl = root.querySelector("[data-blinds]");
var tableName = root.querySelector("[data-table-name]");
var verdictEl = root.querySelector("[data-verdict]");
var houseEl = root.querySelector("[data-house]");
var acting = root.querySelector("[data-acting]");
var between = root.querySelector("[data-between]");
var sitting = root.querySelector("[data-sitting]");
var foldBtn = root.querySelector('[data-move="fold"]');
var checkBtn = root.querySelector('[data-move="check"]');
var callBtn = root.querySelector('[data-move="call"]');
var raiseBtn = root.querySelector('[data-move="raise"]');
var callAmt = root.querySelector("[data-call-amount]");
var raiseRow = root.querySelector("[data-raise-row]");
var slider = root.querySelector("[data-raise-slider]");
var raiseTo = root.querySelector("[data-raise-to]");
var raiseLbl = root.querySelector("[data-raise-label]");
var raiseVerb = root.querySelector("[data-raise-verb]");
var dealBtn = root.querySelector("[data-deal]");
var topupBtn = root.querySelector("[data-topup]");
var leaveBtn = root.querySelector("[data-leave]");
var stackEl = root.querySelector("[data-table-stack]");
var boughtEl = root.querySelector("[data-bought-in]");
var rakeEl = root.querySelector("[data-session-rake]");
var chatSection = root.querySelector("[data-chat]");
var chatLog = root.querySelector("[data-chat-log]");
var chatSeen = {}; // chat line ids already on the rail, to drop echoed duplicates
var chatForm = root.querySelector("[data-chat-form]");
var chatInput = root.querySelector("[data-chat-input]");
var lobbyWrap = root.querySelector("[data-lobby-wrap]");
var lobbyEl = root.querySelector("[data-lobby]");
var sitBtn = root.querySelector("[data-sit]");
var buySlider = root.querySelector("[data-buyin-slider]");
var buyLabel = root.querySelector("[data-buyin]");
var buyNote = root.querySelector("[data-buyin-note]");
var botsNote = root.querySelector("[data-bots-note]");
var gameMsg = root.querySelector("[data-game-msg]");
var betweenMsg = root.querySelector("[data-between-msg]");
var tableMsg = root.querySelector("[data-table-msg]");
var view = null; // the table, as the server last described it
var me = 0; // which seat is yours — no longer always zero, at a shared table
var busy = false;
var pendingSync = false; // a pushed frame arrived mid-animation; re-render when free
var stream = null; // the open EventSource, when seated
var seatEls = []; // one per seat: { root, cards, plate, stack, spot }
var shown = []; // what each seat's stack label currently reads
var pot = null; // the middle pile, a PeteFX.spot
var tierBtns = Array.prototype.slice.call(root.querySelectorAll("[data-tier]"));
var botBtns = Array.prototype.slice.call(root.querySelectorAll("[data-bot-count]"));
var tier = null;
var bots = 2;
var buyIn = 0;
function money(n) { return (n || 0).toLocaleString(); }
function say(el, text, tone) {
if (!el) return;
if (!text) { el.classList.add("hidden"); return; }
el.textContent = text;
el.classList.remove("hidden");
el.style.color = tone === "bad" ? "#cc3d4a" : "";
}
// ---- building the felt ----------------------------------------------------
// seat builds one player: their cards, their name and stack, and the spot their
// bet sits on. Bots go in the row along the top; you get your own, bigger.
function seat(s, i, mine) {
var wrap = document.createElement("div");
wrap.className = "pete-seat";
wrap.dataset.seat = i;
wrap.dataset.state = s.state;
var cards = document.createElement("div");
cards.className = "pete-seat-cards";
var plate = document.createElement("div");
plate.className = "pete-seat-plate";
var name = document.createElement("span");
name.className = "pete-seat-name";
name.textContent = s.name;
var stack = document.createElement("span");
stack.className = "pete-seat-stack";
stack.textContent = money(s.stack);
plate.appendChild(name);
plate.appendChild(stack);
// The button, the blinds. It hangs off the name plate rather than the seat,
// because the seat's corner is a different place for you than for a bot — your
// bet spot is above your cards and theirs is below — and a badge that floats
// over an empty betting circle reads as a bug.
if (s.pos) {
var pos = document.createElement("span");
pos.className = "pete-seat-pos";
pos.dataset.pos = s.pos;
pos.textContent = s.pos;
plate.appendChild(pos);
}
var spotEl = document.createElement("div");
spotEl.className = "pete-spot pete-seat-spot";
var pile = document.createElement("div");
pile.className = "pete-stack";
var total = document.createElement("span");
// The shared class, not one of our own: it hangs the number *below* the ring,
// which is what keeps the chips from landing on top of it.
total.className = "pete-spot-total";
spotEl.appendChild(pile);
spotEl.appendChild(total);
// Your bet sits between you and the board, so it goes above your cards; a
// bot's sits between them and the board, so it goes below theirs.
if (mine) {
wrap.appendChild(spotEl);
wrap.appendChild(cards);
wrap.appendChild(plate);
} else {
wrap.appendChild(cards);
wrap.appendChild(plate);
wrap.appendChild(spotEl);
}
var api = FX.spot({ spot: spotEl, stack: pile, total: total });
api.render(s.bet);
paintCards(cards, s, mine);
return { root: wrap, cards: cards, plate: plate, stackEl: stack, spot: api };
}
// paintCards puts the two cards in front of a seat. A bot's are backs, unless
// the server has actually sent us faces — which it only ever does at a showdown.
function paintCards(el, s, mine) {
el.innerHTML = "";
if (s.state === "out") return;
var faces = s.cards || [];
var n = faces.length ? faces.length : (s.state === "folded" ? 0 : 2);
for (var i = 0; i < n; i++) {
el.appendChild(Cards.el(faces[i] || null, { deal: false, tilt: !mine }));
}
}
function render(v) {
view = v;
me = v.your_seat || 0;
// The seats along the top, and you underneath.
seatsEl.innerHTML = "";
youEl.innerHTML = "";
seatEls = [];
shown = [];
v.seats.forEach(function (s, i) {
var mine = i === me;
var built = seat(s, i, mine);
seatEls[i] = built;
shown[i] = s.stack;
(mine ? youEl : seatsEl).appendChild(built.root);
built.root.dataset.turn = (v.phase === "betting" && v.to_act === i) ? "1" : "0";
});
// The board.
boardEl.innerHTML = "";
(v.board || []).forEach(function (c) {
boardEl.appendChild(Cards.el(c, { deal: false, tilt: false }));
});
// The pot. Its chips live in a box of their own — see .pete-poker-pot-pile —
// so the number underneath stays readable.
pot = FX.spot({ spot: potStack.parentNode, stack: potStack, total: null });
pot.render(v.pot);
potTotal.textContent = money(v.pot);
blindsEl.textContent = v.tier.sb + "/" + v.tier.bb;
tableName.textContent = v.tier.name;
if (v.side && v.side.length > 1) {
sideEl.textContent = v.side.map(function (n, i) {
return (i === 0 ? "main " : "side ") + money(n);
}).join(" · ");
sideEl.classList.remove("hidden");
} else {
sideEl.classList.add("hidden");
}
stackEl.textContent = money(v.stack);
boughtEl.textContent = money(v.bought_in);
rakeEl.textContent = money(v.rake);
panels();
}
// panels decides which of the three bars is showing: the one that acts, the one
// between hands, or the one you sit down from.
function panels() {
// A session you have got up from is not a live one: the felt still shows the
// last hand, but the table you sit down at is the one that's open to you.
var live = !!view && view.phase !== "done";
sitting.classList.toggle("hidden", live);
acting.classList.toggle("hidden", !live || view.phase !== "betting" || view.to_act !== me);
between.classList.toggle("hidden", !live || view.phase !== "handover");
if (chatSection) chatSection.classList.toggle("hidden", !live);
if (!live) return;
if (view.phase === "betting" && view.to_act === me) {
checkBtn.classList.toggle("hidden", !view.can_check);
callBtn.classList.toggle("hidden", view.can_check);
callAmt.textContent = money(view.owed);
raiseRow.classList.toggle("hidden", !view.can_raise);
if (view.can_raise) {
slider.min = view.min_raise_to;
slider.max = view.max_raise_to;
slider.step = Math.max(1, view.tier.bb / 2);
slider.value = Math.min(view.max_raise_to, view.min_raise_to);
// "Bet" when nobody has, "Raise to" when somebody has. It is the same
// move and the same button, but calling a bet a raise is how you tell a
// player who has never played that this table is confused.
raiseVerb.textContent = view.owed > 0 ? "Raise to" : "Bet";
showRaise();
}
}
if (view.phase === "handover") {
// The table has room for max_topup, but the button must not promise chips the
// wallet cannot cover — clicking it would only earn a refusal.
var wallet = window.PeteGames.view();
var can = Math.min(view.max_topup, wallet ? wallet.chips : 0);
topupBtn.disabled = can <= 0;
topupBtn.dataset.amount = can;
topupBtn.textContent = can > 0 ? "Top up " + money(can) : "Top up";
}
}
function showRaise() {
var to = Number(slider.value);
raiseTo.textContent = money(to);
raiseLbl.textContent = money(to);
}
// ---- playing the script ---------------------------------------------------
var STREETS = { flop: 1, turn: 1, river: 1 };
function wait(ms) {
return new Promise(function (r) { setTimeout(r, FX.reduced ? 0 : ms); });
}
// play walks the events the server sent, one beat at a time, and only then
// re-renders the table it ended on. Everything the player is meant to see
// happening happens here; render() is the state it settles into.
function play(events, final) {
var chain = Promise.resolve();
if (!events || !events.length) {
// Nothing to animate. Either settle into the table we ended on, or — if the
// session closed and storage cleared it — clear the felt.
if (final) render(final); else render0();
return chain;
}
events.forEach(function (e) {
chain = chain.then(function () { return beat(e, final); });
});
return chain.then(function () {
// A hand can be the last one — a bust closes the table, so there is no state
// to settle into. Play it out and say what it did anyway (the seats are still
// on the felt from the previous render), then clear.
verdict(events, final);
if (final) render(final); else render0();
});
}
function beat(e, final) {
var s = seatEls[e.seat];
switch (e.kind) {
case "hand":
// A new deal: clear the felt before anything lands on it.
boardEl.innerHTML = "";
verdictEl.classList.add("hidden");
seatEls.forEach(function (x) { x.spot.render(0); x.cards.innerHTML = ""; });
pot.render(0);
potTotal.textContent = "0";
sideEl.classList.add("hidden");
FX.sfx("shuffle");
return wait(140);
case "rebuy":
if (s) { shown[e.seat] = e.total; FX.count(s.stackEl, e.total); }
return wait(220);
case "blind":
if (!s) return;
moveStack(e.seat, -e.amount);
return s.spot.pour(s.plate, e.amount);
case "hole":
// Two cards to everybody, round the table, as they are actually dealt.
return dealHoles(final);
case "action":
return action(e, final);
case "flop":
case "turn":
case "river":
return street(e);
case "pot":
// The bets in front of the seats have been swept in. Nothing else in the
// script says so, and the pot is about to be paid out of.
return collect(e.amount);
case "uncalled":
if (!s) return;
return s.spot.sweep(s.plate).then(function () { moveStack(e.seat, e.amount); });
case "show":
if (!s) return;
paintCards(s.cards, { state: "active", cards: e.cards }, e.seat === me);
flash(s.root);
return wait(420);
case "rake":
// The house takes its cut out of the pot, in front of you, so it is a
// thing that visibly happens rather than a number that quietly differs.
return pot.sweep(houseEl, e.amount).then(function () {
potTotal.textContent = money(pot.amount);
return wait(160);
});
case "win":
if (!s) return;
return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () {
potTotal.textContent = money(pot.amount);
moveStack(e.seat, e.amount);
if (e.seat === me && e.amount > 0) FX.burst(s.plate, { count: 18 });
return wait(260);
});
case "end":
return wait(280);
}
return Promise.resolve();
}
// dealHoles puts two cards in front of everyone still in the hand. Yours land
// face up; theirs land as backs, because that is all that came over the wire.
function dealHoles(final) {
var chain = Promise.resolve();
for (var round = 0; round < 2; round++) {
(function (round) {
chain = chain.then(function () {
var beats = [];
final.seats.forEach(function (s, i) {
if (s.state === "out") return;
var built = seatEls[i];
if (!built) return;
var face = (i === me && s.cards) ? s.cards[round] : null;
var card = Cards.el(face, { deal: true, tilt: i !== me });
built.cards.appendChild(card);
FX.sfx("deal", { delay: 0.07 * i, v: i });
beats.push(wait(70 * i));
});
return Promise.all(beats).then(function () { return wait(180); });
});
})(round);
}
return chain;
}
// action animates one seat doing one thing.
function action(e, final) {
var s = seatEls[e.seat];
if (!s) return Promise.resolve();
if (e.text === "fold") {
s.root.dataset.state = "folded";
s.cards.dataset.mucked = "1";
FX.sfx("card", { v: 2 }); // cards going into the muck
return wait(320);
}
if (e.text === "check") {
flash(s.root);
FX.sfx("blip"); // a knuckle on the table
return wait(320);
}
// call, raise, allin: chips leave their stack for their spot.
if (!e.amount) return wait(200);
moveStack(e.seat, -e.amount);
return s.spot.pour(s.plate, e.amount).then(function () {
if (e.text === "allin") flash(s.root);
return wait(180);
});
}
// collect sweeps every seat's bet into the middle. The total is worked out up
// front rather than accumulated as the chips land, because the sweeps run at the
// same time and would otherwise race each other into the pot's counter.
function collect(total) {
var moved = 0;
var sweeps = seatEls.map(function (s) {
if (!s || s.spot.amount <= 0) return Promise.resolve();
moved += s.spot.amount;
return s.spot.sweep(potStack.parentNode, s.spot.amount, { gap: 30 });
});
if (!moved) {
if (total != null) { pot.render(total); potTotal.textContent = money(total); }
return Promise.resolve();
}
var to = total != null ? total : pot.amount + moved;
return Promise.all(sweeps).then(function () {
pot.render(to);
potTotal.textContent = money(to);
return wait(200);
});
}
// street sweeps the bets in, then turns the cards.
function street(e) {
return collect(e.amount).then(function () {
// The board turns one card at a time, even the flop. Three cards appearing
// at once is a screenshot; three cards appearing in a row is a flop.
var chain = Promise.resolve();
(e.cards || []).forEach(function (c, i) {
chain = chain.then(function () {
boardEl.appendChild(Cards.el(c, { deal: true, tilt: false }));
FX.sfx("card", { v: i });
return wait(240);
});
});
return chain;
}).then(function () {
return wait(200);
});
}
// moveStack keeps a seat's stack label honest *while the chips are moving*. The
// authoritative number is always the server's, and render() puts it back at the
// end of the script — but a stack that only updates then would sit unchanged
// through the whole hand and then jump, which reads as the table correcting
// itself rather than as chips being paid.
function moveStack(i, delta) {
var s = seatEls[i];
if (!s) return;
shown[i] = Math.max(0, (shown[i] || 0) + delta);
FX.count(s.stackEl, shown[i]);
}
function flash(el) {
el.animate(
[{ transform: "scale(1)" }, { transform: "scale(1.06)" }, { transform: "scale(1)" }],
{ duration: 320, easing: "ease-out" }
);
}
// verdict says what the hand did to you, once, in words.
function verdict(events, final) {
var won = 0, showed = false, busted = false;
events.forEach(function (e) {
if (e.kind === "win" && e.seat === me) won += e.amount;
if (e.kind === "show") showed = true;
if (e.kind === "bust" && e.seat === me) busted = true;
});
if (busted) {
show("You're out of chips. Sit down again when you're ready.", "lose");
FX.sfx("lose");
return;
}
if (!final || !events.some(function (e) { return e.kind === "end"; })) return;
var mine = final.seats[me];
if (won > 0) {
// The pot coming your way already burst (and so already cheered) back in
// the "win" event. This is only the words.
show(showed
? "You win " + money(won) + " with " + article(handName(events)) + "."
: "They folded. You take " + money(won) + ".", "win");
} else if (mine.state === "folded") {
show("Folded.", "lose");
} else {
show("No good this time.", "lose");
FX.sfx("lose");
}
function show(text, tone) {
verdictEl.textContent = text;
verdictEl.dataset.tone = tone;
verdictEl.classList.remove("hidden");
}
}
function handName(events) {
var mine = events.filter(function (e) { return e.kind === "show" && e.seat === me; })[0];
return mine && mine.text ? mine.text : "the best hand";
}
// "You win 975 with straight" is not a sentence. Most hands take an article and
// the counted ones don't.
function article(desc) {
if (/^(two pair|three of a kind|four of a kind|high card|the best hand)$/.test(desc)) return desc;
return "a " + desc;
}
// ---- talking to the table --------------------------------------------------
// lock is busy, said out loud on the buttons.
//
// send() drops a click that arrives while a move is in flight, and it is right
// to: the board on screen during a script is a board the server has already
// moved past. But the *between-hands* buttons — Deal, Leave, Top up — stayed
// enabled through the whole deal animation, so clicking Leave while the cards
// were still flying did nothing at all: no move, no message, no reason given.
// (The action buttons never had this problem; panels() hides the whole row when
// it isn't your turn.) A button that looks alive and does nothing has lied to
// you, so the lock lives on the buttons and not only in the variable.
//
// Top up keeps its own rule — it is dead when the wallet cannot cover it — and
// panels() owns that, so this only ever adds a reason to be disabled.
function lock(on) {
[foldBtn, checkBtn, callBtn, raiseBtn, dealBtn, leaveBtn].forEach(function (b) {
if (b) b.disabled = on;
});
if (topupBtn) topupBtn.disabled = on || Number(topupBtn.dataset.amount || 0) <= 0;
}
function send(body, msgEl) {
if (busy) return Promise.resolve();
busy = true;
lock(true);
say(msgEl, "");
// Whatever the last hand said about itself stops being true the moment you do
// something. Only the "hand" beat used to clear this, so a verdict could linger
// over a hand it had nothing to do with.
verdictEl.classList.add("hidden");
return window.PeteGames
.post("/api/games/holdem/move", body)
.then(function (v) {
window.PeteGames.apply(v);
// No table came back: the session ended inside this move — a bust closes a
// solo table. play() animates the last hand (its "bust" beat says the words)
// and clears the felt.
return play(v.holdem_events, v.holdem || null);
})
.catch(function (err) { say(msgEl, err.message, "bad"); })
.then(function () { busy = false; lock(false); flushSync(); });
}
// flushSync applies a table frame that arrived while a hand was animating. The
// felt is now settled, so the held re-render will not clobber a script.
function flushSync() {
if (pendingSync) { pendingSync = false; sync(); }
}
// leave gets you up from the table. It is its own endpoint, not a move: the
// chips cross back and the felt may close behind you, neither of which is a play
// in a hand. The felt clears and the stack you took is reported from what was in
// front of you a moment ago.
function leave() {
if (busy) return;
busy = true;
lock(true);
var stack = view ? view.stack : 0;
verdictEl.classList.add("hidden");
window.PeteGames
.post("/api/games/holdem/leave", {})
.then(function (v) {
window.PeteGames.apply(v);
render0();
say(tableMsg, stack > 0
? money(stack) + " back on your stack. Sit down again when you're ready."
: "");
})
.catch(function (err) { say(betweenMsg, err.message, "bad"); })
.then(function () { busy = false; lock(false); flushSync(); });
}
// render0 is the table with nobody at it.
function render0() {
view = null;
unseated();
seatsEl.innerHTML = "";
youEl.innerHTML = "";
boardEl.innerHTML = "";
potStack.innerHTML = "";
potTotal.textContent = "0";
sideEl.classList.add("hidden");
panels();
syncSit();
loadLobby();
}
if (foldBtn) foldBtn.addEventListener("click", function () { send({ move: "fold" }, gameMsg); });
if (checkBtn) checkBtn.addEventListener("click", function () { send({ move: "check" }, gameMsg); });
if (callBtn) callBtn.addEventListener("click", function () { send({ move: "call" }, gameMsg); });
if (raiseBtn) raiseBtn.addEventListener("click", function () {
var to = Number(slider.value);
// Sliding all the way to the top is shoving, and the table would rather be
// told that than be told to raise to exactly everything you have.
send(to >= view.max_raise_to ? { move: "allin" } : { move: "raise", to: to }, gameMsg);
});
if (slider) slider.addEventListener("input", showRaise);
root.querySelectorAll("[data-raise-preset]").forEach(function (b) {
b.addEventListener("click", function () {
var which = b.dataset.raisePreset;
var to;
// A pot-sized raise is: call what's owed, then bet what the pot would then be.
// So the total is twice what you owe, plus the pot as it stands.
if (which === "max") to = view.max_raise_to;
else to = 2 * view.owed + view.pot * Number(which);
to = Math.max(view.min_raise_to, Math.min(view.max_raise_to, Math.round(to)));
slider.value = to;
showRaise();
});
});
if (dealBtn) dealBtn.addEventListener("click", function () { send({ move: "deal" }, betweenMsg); });
if (leaveBtn) leaveBtn.addEventListener("click", function () { leave(); });
if (topupBtn) topupBtn.addEventListener("click", function () {
send({ move: "topup", amount: Number(topupBtn.dataset.amount || 0) }, betweenMsg);
});
// ---- sitting down ----------------------------------------------------------
function pickTier(btn) {
tier = btn;
tierBtns.forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; });
var min = Number(btn.dataset.min), max = Number(btn.dataset.max), bb = Number(btn.dataset.bb);
buySlider.min = min;
buySlider.max = max;
buySlider.step = bb;
buySlider.value = Math.min(max, Math.max(min, 50 * bb)); // fifty big blinds, the default anybody sensible picks
syncSit();
}
function pickBots(btn) {
bots = Number(btn.dataset.botCount);
botBtns.forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; });
syncSit();
}
function syncSit() {
if (!tier) return;
buyIn = Number(buySlider.value);
var bb = Number(tier.dataset.bb);
buyLabel.textContent = money(buyIn);
buyNote.textContent = Math.round(buyIn / bb) + " big blinds. Short is fewer decisions; deep is more of them.";
botsNote.textContent = bots === 1
? "Heads up. The bots know this game best when there's only one of them."
: bots + " bots. More opponents, and a hand has to be better to be worth playing.";
var chips = window.PeteGames.view();
sitBtn.disabled = !chips || chips.chips < buyIn;
say(tableMsg, sitBtn.disabled ? "You need " + money(buyIn) + " chips to sit at this table." : "");
}
tierBtns.forEach(function (b) { b.addEventListener("click", function () { pickTier(b); }); });
botBtns.forEach(function (b) { b.addEventListener("click", function () { pickBots(b); }); });
if (buySlider) buySlider.addEventListener("input", syncSit);
if (sitBtn) sitBtn.addEventListener("click", function () {
if (busy || !tier) return;
busy = true;
say(tableMsg, "");
window.PeteGames
.post("/api/games/holdem/sit", {
tier: tier.dataset.tier,
bots: bots,
buyin: Number(buySlider.value),
})
.then(function (v) {
window.PeteGames.apply(v);
render(v.holdem);
seated();
// A table with nobody dealt in yet is a table waiting for you to say go.
say(betweenMsg, "You're in. Deal when you're ready.");
})
.catch(function (err) { say(tableMsg, err.message, "bad"); })
.then(function () { busy = false; });
});
// ---- the live table --------------------------------------------------------
//
// Poker is where the room stops being just you and the house: other people are
// at the felt, and what they do has to reach you without your asking. That is
// one EventSource. The server pushes a nudge when the table changes and a line
// when somebody speaks; the nudge is not the state (a hole card must never ride
// a frame that fans to the whole table), so on a nudge we refetch our own
// seat's view, which is authoritative and already redacted.
// seated opens the stream and loads the rail. Called the moment you sit down.
function seated() {
loadChat();
connectLive();
}
// unseated tears it down. Called when the felt clears — you got up, or busted.
function unseated() {
disconnectLive();
if (chatLog) chatLog.innerHTML = "";
chatSeen = {};
}
function connectLive() {
if (stream || !window.EventSource) return;
stream = new EventSource("/api/games/holdem/stream");
stream.onmessage = function (ev) {
var msg;
try { msg = JSON.parse(ev.data); } catch (e) { return; }
if (msg.type === "chat") addChat(msg.line);
else if (msg.type === "table") sync();
};
// The opening nudge (event: sync) just says "come and look".
stream.addEventListener("sync", function () { sync(); });
// EventSource reconnects itself on a dropped connection. The one case we don't
// want it retrying is a table that has closed (the stream 409s) — but by then
// we have called unseated() and closed it ourselves.
}
function disconnectLive() {
if (stream) { stream.close(); stream = null; }
}
// sync re-renders the felt from the authoritative table, but never mid-animation
// — a frame that lands while a hand is playing is held and applied once the
// script finishes, or it would repaint the table out from under it.
function sync() {
if (busy) { pendingSync = true; return; }
window.PeteGames.refresh().then(function (v) {
if (!v) return;
if (v.holdem) render(v.holdem); else { render0(); }
});
}
// ---- the rail --------------------------------------------------------------
function loadChat() {
fetch("/api/games/holdem/chat", { headers: { "Accept": "application/json" } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) {
if (!data || !chatLog) return;
chatLog.innerHTML = "";
chatSeen = {};
(data.chat || []).forEach(addChat);
})
.catch(function () {});
}
function addChat(line) {
if (!chatLog || !line) return;
// Your own line comes back twice — once from the POST that sent it, once
// echoed over your own stream — so drop any id already on the rail.
if (line.id) {
if (chatSeen[line.id]) return;
chatSeen[line.id] = true;
}
var row = document.createElement("div");
row.className = "pete-chat-line" + (line.mine ? " pete-chat-mine" : "");
var who = document.createElement("span");
who.className = "pete-chat-who";
who.textContent = line.name;
var body = document.createElement("span");
body.className = "pete-chat-body";
body.textContent = line.body;
row.appendChild(who);
row.appendChild(body);
chatLog.appendChild(row);
chatLog.scrollTop = chatLog.scrollHeight;
}
if (chatForm) chatForm.addEventListener("submit", function (e) {
e.preventDefault();
var body = (chatInput.value || "").trim();
if (!body) return;
chatInput.value = "";
window.PeteGames
.post("/api/games/holdem/say", { body: body })
.then(function (line) { addChat(line); })
.catch(function () { chatInput.value = body; });
});
// ---- the lobby -------------------------------------------------------------
//
// The tables other people have open, each with a seat going spare. Joining
// takes the buy-in the slider is set to, clamped to what that table allows.
function loadLobby() {
if (!lobbyEl) return;
fetch("/api/games/holdem/tables", { headers: { "Accept": "application/json" } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) {
var tables = (data && data.tables) || [];
renderLobby(tables);
})
.catch(function () {});
}
function renderLobby(tables) {
lobbyEl.innerHTML = "";
lobbyWrap.classList.toggle("hidden", tables.length === 0);
tables.forEach(function (t) {
var stakes = tierBtns.filter(function (b) { return b.dataset.tier === t.tier; })[0];
var btn = document.createElement("button");
btn.type = "button";
btn.className = "flex items-center justify-between gap-3 rounded-2xl border-2 border-[color:var(--ink)]/10 p-3 text-left hover:bg-[color:var(--ink)]/5 transition";
var label = document.createElement("span");
label.className = "font-display font-bold";
label.textContent = stakes ? stakes.querySelector(".font-display").textContent : t.tier;
var seats = document.createElement("span");
seats.className = "text-xs font-semibold text-[color:var(--ink)]/50 tabular-nums";
seats.textContent = t.humans + "/" + t.seats + " seated";
btn.appendChild(label);
btn.appendChild(seats);
btn.addEventListener("click", function () { join(t, stakes); });
lobbyEl.appendChild(btn);
});
}
function join(t, stakes) {
if (busy) return;
// Buy in for what the slider says, but only what this table allows.
var buyIn = buyIn0(stakes);
busy = true;
say(tableMsg, "");
window.PeteGames
.post("/api/games/holdem/sit", { table: t.id, buyin: buyIn })
.then(function (v) {
window.PeteGames.apply(v);
render(v.holdem);
seated();
say(betweenMsg, "You're in. The next hand deals when the table is ready.");
})
.catch(function (err) { say(tableMsg, err.message, "bad"); })
.then(function () { busy = false; });
}
// buyIn0 is a legal buy-in for a table you are joining: fifty big blinds, or as
// close as the table's range allows.
function buyIn0(stakes) {
if (!stakes) return Number(buySlider.value);
var min = Number(stakes.dataset.min), max = Number(stakes.dataset.max), bb = Number(stakes.dataset.bb);
var want = Number(buySlider.value);
if (stakes === tier) return Math.min(max, Math.max(min, want)); // same table: honour the slider
return Math.min(max, Math.max(min, 50 * bb));
}
// ---- boot ------------------------------------------------------------------
window.PeteGames.onUpdate(function () { if (!view) syncSit(); });
pickTier(tierBtns[0]);
pickBots(botBtns[1]);
window.PeteGames.refresh().then(function (v) {
if (v && v.holdem) { render(v.holdem); seated(); }
else { render0(); loadLobby(); }
});
})();

View File

@@ -10,7 +10,7 @@
(function () {
// The localStorage keys we sync. The weather *cache* is deliberately excluded:
// it's transient and per-device.
var SYNCED = ["pete.disabledSources.v1", "pete.weather.loc.v1", "pete-weather-off"];
var SYNCED = ["pete.disabledSources.v1", "pete.weather.loc.v1", "pete-weather-off", "pete.sfx.off"];
var user = window.PETE_USER || null;
var serverPrefs = window.PETE_PREFS || null;

View File

@@ -396,6 +396,36 @@
});
}
// noises is the board's soundtrack, walked off the *same* STEP_MS ladder the
// animation is walked off. That matters more than which sound goes where: a
// card you hear land a step before it lands is worse than a card that lands in
// silence, so this counts its way through the script exactly as planOf and
// flashHome do, and any change to their timing has to be made here too.
function noises(events) {
var at = 0;
(events || []).forEach(function (e) {
switch (e.kind) {
case "draw":
FX.sfx("card", { delay: at / 1000 });
break;
case "recycle":
FX.sfx("shuffle", { delay: at / 1000 });
break;
case "move":
FX.sfx("deal", { delay: (at + MOVE_MS) / 1000 });
at += STEP_MS;
break;
case "home":
// A card reaching a foundation is the only move that pays, so it is the
// only one that gets a note on the end of it.
FX.sfx("deal", { delay: (at + MOVE_MS) / 1000 });
FX.sfx("blip", { delay: (at + MOVE_MS + 60) / 1000 });
at += STEP_MS;
break;
}
});
}
// ---- the money -------------------------------------------------------------
// bank moves the spot to what the server says the board is worth. Up is chips
@@ -548,6 +578,9 @@
// Clearing 52 cards out of a Vegas deal is the rarest thing that happens in
// this room, so it's the one that gets the confetti.
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 40 });
else if (v.net > 0) FX.sfx("win");
else if (v.net < 0) FX.sfx("lose");
else FX.sfx("push");
}
// play walks a server response onto the felt: the board is re-rendered, the
@@ -570,6 +603,7 @@
render(v);
var plan = planOf(events);
flashHome(events);
noises(events);
return animate(before, plan);
})
.then(function () {

View File

@@ -372,10 +372,16 @@
renderQuestion(final);
renderLadder(final);
if (final && final.phase === "playing") startClock(final.left, final.limit);
FX.sfx("blip");
return;
case "right":
reveal(e.choice, e.correct);
// Three notes up, one per rung climbed — the ladder, in sound.
[0, 0.09, 0.18].forEach(function (d, i) {
FX.sfx("tick", { delay: d, v: i });
});
FX.sfx("win", { delay: 0.1 });
if (final) {
// The rung lighting and the multiple climbing are one event,
// because they are one event: this is what the answer was worth.
@@ -387,10 +393,13 @@
case "wrong":
reveal(e.choice, e.correct);
FX.sfx("bad");
return wait(1100);
case "timeout":
// The clock beat you to it, which is a different kind of bad.
reveal(-1, e.correct);
FX.sfx("lose");
return wait(1100);
case "settle":

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,25 @@
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Cash out
</button>
<!-- The room's volume. It lives in the chip bar because the chip bar is the
one thing every table has, and the sound is the room's rather than any
one game's. -->
<button type="button" data-sfx-toggle aria-pressed="false"
class="grid h-10 w-10 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/5 active:translate-y-px transition">
<svg data-sfx-on viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path>
</svg>
<svg data-sfx-off viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="hidden h-5 w-5" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<line x1="23" y1="9" x2="17" y2="15"></line>
<line x1="17" y1="9" x2="23" y2="15"></line>
</svg>
<span data-sfx-label class="sr-only">Mute the room</span>
</button>
</div>
</div>

View File

@@ -51,21 +51,36 @@
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
</div>
<!-- Player: the bet sits in front of the cards it's riding on. -->
<!-- Player: the bet sits in front of the cards it's riding on.
The hands themselves are built by blackjack.js, because how many there
are is a thing that changes mid-hand: you split, and one hand with one
bet on it becomes two hands with a bet each. The template below is the
shape of one of them; the row starts with exactly one, which is also
the spot you build your bet on before any cards exist. -->
<div>
<div class="mb-2 flex items-center gap-2">
<span class="text-xs font-bold uppercase tracking-wider text-white/60">You</span>
<span data-player-total class="hidden rounded-full bg-black/25 px-2.5 py-0.5 text-xs font-bold tabular-nums text-white"></span>
</div>
<div class="flex items-center gap-5">
<div class="bj-hands" data-hands data-count="1" aria-live="polite"></div>
</div>
<template data-hand-template>
<div class="bj-hand" data-hand data-live="1">
<div class="pete-spot" data-spot>
<span class="pete-spot-label">Bet</span>
<div class="pete-stack" data-stack></div>
<span data-spot-total class="pete-spot-total hidden"></span>
</div>
<div data-player class="pete-hand flex-1" aria-live="polite"></div>
<div class="min-w-0">
<div class="mb-1 flex h-5 items-center gap-1.5">
<span data-total class="hidden rounded-full bg-black/25 px-2.5 py-0.5 text-xs font-bold tabular-nums text-white"></span>
<span data-hand-outcome class="hidden rounded-full bg-white/90 px-2 py-0.5 text-[0.65rem] font-bold uppercase tracking-wide text-[#2b2118]"></span>
</div>
<div data-cards class="pete-hand"></div>
</div>
</div>
</div>
</template>
</div>
</section>
@@ -116,9 +131,14 @@
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Double
</button>
<button type="button" data-move="split"
class="rounded-full bg-[color:var(--card)] px-8 py-3 font-display text-lg font-bold shadow-pete border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Split
</button>
</div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
<kbd>h</kbd> hit · <kbd>s</kbd> stand · <kbd>d</kbd> double
<kbd>h</kbd> hit · <kbd>s</kbd> stand · <kbd>d</kbd> double · <kbd>p</kbd> split
</p>
</section>

View File

@@ -25,19 +25,62 @@
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
{{range .Roster}}
<li class="flex items-center gap-4 px-5 py-3">
<li class="flex items-center gap-4 px-5 py-3" data-token="{{.Token}}" data-name="{{.Name}}">
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
<span class="font-semibold">{{.Name}}</span>
<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure hover:underline">{{.Name}}</a>
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
</span>
{{if and $.User .OnRun}}
<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="{{.Token}}" data-name="{{.Name}}">send trouble</button>
{{end}}
</li>
{{else}}
<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody's out at the moment. Quiet week in the realm.</li>
{{end}}
</ul>
</div>
{{if not .User}}
<p class="mt-3 text-xs text-[color:var(--ink)]/50">
<a href="/auth/login?next={{.Path}}" class="underline hover:text-red-500">Sign in</a> to send something unpleasant their way.
</p>
{{end}}
{{if .User}}
<!-- Your open contracts. Rendered from filed facts, never from live game state:
Pete only knows what gogobee told it happened. -->
<div id="mischief-orders" class="mt-4 hidden">
<h3 class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50 mb-2">Trouble you've paid for</h3>
<ul id="mischief-orders-list" class="space-y-2 text-sm"></ul>
</div>
<!-- The buy dialog. Prices are gogobee's, pulled live from /api/mischief/catalog;
nothing here asserts a number of its own. -->
<div id="mischief-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden">
<div class="flex items-center justify-between px-5 py-4 border-b border-[color:var(--ink)]/10">
<h3 class="font-display text-xl font-bold">Send trouble to <span id="mischief-target"></span></h3>
<button type="button" id="mischief-close" class="text-2xl leading-none text-[color:var(--ink)]/50 hover:text-[color:var(--ink)]" aria-label="close">×</button>
</div>
<div class="px-5 py-4 space-y-3">
<p class="text-sm text-[color:var(--ink)]/60">Pick how bad. They won't know who sent it — unless you sign it, or they live to read the receipt.</p>
<div id="mischief-tiers" class="space-y-2"></div>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" id="mischief-signed" class="rounded border-[color:var(--ink)]/30">
<span>Sign my name to it <span class="text-[color:var(--ink)]/45">(+25%, and they'll know)</span></span>
</label>
<p id="mischief-balance" class="text-xs text-[color:var(--ink)]/50"></p>
<p id="mischief-result" class="text-sm hidden"></p>
</div>
<div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-[color:var(--ink)]/10">
<button type="button" id="mischief-cancel" class="rounded-full px-4 py-2 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)]">Never mind</button>
<button type="button" id="mischief-confirm" class="rounded-full bg-red-500 text-white px-5 py-2 text-sm font-semibold shadow-pete hover:-translate-y-0.5 transition disabled:opacity-40 disabled:translate-y-0" disabled>Put the word out</button>
</div>
</div>
</div>
{{end}}
</section>
<script>
@@ -49,6 +92,8 @@
var card = document.getElementById('roster-card');
if (!list) return;
var signedIn = {{if .User}}true{{else}}false{{end}};
function esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
@@ -57,12 +102,15 @@
function row(a) {
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
return '<li class="flex items-center gap-4 px-5 py-3">' +
var button = (signedIn && a.OnRun)
? '<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">send trouble</button>'
: '';
return '<li class="flex items-center gap-4 px-5 py-3" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">' +
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
'<span class="font-semibold">' + esc(a.Name) + '</span>' +
'<a href="/adventure/who/' + esc(a.Token) + '" class="font-semibold hover:text-theme-adventure hover:underline">' + esc(a.Name) + '</a>' +
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
esc(a.Where) + idle + '</span></li>';
esc(a.Where) + idle + '</span>' + button + '</li>';
}
function refresh() {
@@ -84,7 +132,157 @@
document.addEventListener('visibilitychange', function () {
if (!document.hidden) refresh();
});
if (signedIn) initMischief(list, esc);
})();
// The storefront: pick a mark, pick how bad, pay. The only thing this code is
// authoritative about is the buyer's intent — every price, every yes/no, and the
// buyer's actual balance are gogobee's, reached through the API.
function initMischief(list, esc) {
var modal = document.getElementById('mischief-modal');
if (!modal) return;
var targetEl = document.getElementById('mischief-target');
var tiersEl = document.getElementById('mischief-tiers');
var signedEl = document.getElementById('mischief-signed');
var balanceEl = document.getElementById('mischief-balance');
var resultEl = document.getElementById('mischief-result');
var confirmEl = document.getElementById('mischief-confirm');
var ordersWrap = document.getElementById('mischief-orders');
var ordersList = document.getElementById('mischief-orders-list');
var catalog = null; // {tiers, euro, has_balance}
var chosen = null; // selected tier key
var current = null; // {token, name}
function euros(n) { return '€' + Number(n).toLocaleString(); }
function loadCatalog() {
return fetch('/api/mischief/catalog', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (c) { catalog = c; return c; });
}
function drawTiers() {
if (!catalog || !catalog.tiers || !catalog.tiers.length) {
tiersEl.innerHTML = '<p class="text-sm text-[color:var(--ink)]/50">The catalogue is between snapshots. Try again in a moment.</p>';
return;
}
var signed = signedEl.checked;
var known = catalog.has_balance;
var euro = catalog.euro;
tiersEl.innerHTML = catalog.tiers.map(function (t) {
var price = signed ? t.signed_fee : t.fee;
var tooDear = known && euro < price;
return '<button type="button" class="mischief-tier w-full text-left rounded-2xl border-2 px-4 py-2 transition ' +
(chosen === t.key ? 'border-red-500 bg-red-500/10' : 'border-[color:var(--ink)]/10 hover:border-[color:var(--ink)]/25') +
(tooDear ? ' opacity-40 cursor-not-allowed' : '') + '" data-key="' + esc(t.key) + '"' + (tooDear ? ' disabled' : '') + '>' +
'<span class="flex items-baseline justify-between"><span class="font-semibold">' + esc(t.display) + '</span>' +
'<span class="font-semibold">' + euros(price) + '</span></span>' +
(t.blurb ? '<span class="block text-xs text-[color:var(--ink)]/55 mt-0.5">' + esc(t.blurb) + '</span>' : '') +
'</button>';
}).join('');
balanceEl.textContent = known ? 'You have about ' + euros(euro) + ' to spend.' : '';
Array.prototype.forEach.call(tiersEl.querySelectorAll('.mischief-tier'), function (b) {
b.addEventListener('click', function () {
chosen = b.getAttribute('data-key');
confirmEl.disabled = false;
drawTiers();
});
});
}
function open(token, name) {
current = { token: token, name: name };
chosen = null;
confirmEl.disabled = true;
resultEl.classList.add('hidden');
resultEl.textContent = '';
signedEl.checked = false;
targetEl.textContent = name;
modal.classList.remove('hidden');
modal.classList.add('flex');
(catalog ? Promise.resolve() : loadCatalog()).then(drawTiers);
}
function close() {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
function badge(statusText) {
var map = {
pending: ['on its way', 'bg-amber-500/15 text-amber-700'],
placed: ['out for delivery', 'bg-red-500/15 text-red-600'],
bounced_funds: ["couldn't afford it", 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'],
bounced_ineligible: ['no longer a mark', 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60']
};
var m = map[statusText] || [statusText, 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'];
return '<span class="rounded-full px-2 py-0.5 text-xs font-semibold ' + m[1] + '">' + esc(m[0]) + '</span>';
}
function loadOrders() {
fetch('/api/mischief/orders', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (orders) {
if (!orders || !orders.length) { ordersWrap.classList.add('hidden'); return; }
ordersWrap.classList.remove('hidden');
ordersList.innerHTML = orders.map(function (o) {
var detail = o.detail ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(o.detail) + '</span>' : '';
return '<li class="flex items-center gap-3">' +
'<span class="font-semibold">' + esc(o.target_name) + '</span>' +
'<span class="text-[color:var(--ink)]/55">' + esc(o.tier) + (o.signed ? ', signed' : '') + '</span>' +
'<span class="ml-auto">' + badge(o.status) + '</span>' + detail + '</li>';
}).join('');
})
.catch(function () {});
}
list.addEventListener('click', function (e) {
var b = e.target.closest('.mischief-send');
if (!b) return;
open(b.getAttribute('data-token'), b.getAttribute('data-name'));
});
signedEl.addEventListener('change', drawTiers);
document.getElementById('mischief-close').addEventListener('click', close);
document.getElementById('mischief-cancel').addEventListener('click', close);
modal.addEventListener('click', function (e) { if (e.target === modal) close(); });
confirmEl.addEventListener('click', function () {
if (!current || !chosen) return;
confirmEl.disabled = true;
resultEl.classList.add('hidden');
fetch('/api/mischief/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_token: current.token, tier: chosen, signed: signedEl.checked })
})
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
.then(function (res) {
resultEl.classList.remove('hidden');
if (res.ok) {
resultEl.className = 'text-sm text-red-600 font-semibold';
resultEl.textContent = 'The word is out. Watch the board.';
catalog = null; // balance moved; refetch next open
loadOrders();
setTimeout(close, 1200);
} else {
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
resultEl.textContent = (res.body && res.body.error) || 'That didn\'t take. Try again.';
confirmEl.disabled = false;
}
})
.catch(function () {
resultEl.classList.remove('hidden');
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
resultEl.textContent = 'The line dropped. Try again.';
confirmEl.disabled = false;
});
});
loadOrders();
setInterval(loadOrders, 60000);
}
</script>
{{end}}

View File

@@ -101,8 +101,25 @@
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
</div>
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
One to three bots, and the more of them there are the more it pays: up to 3.6× for
beating a full table. Anybody else going out first takes your stake.
One to three bots, and the more of them there are the more it pays: up to 4.1× for
beating a full table. Anybody else going out first takes your stake. Or throw the
No Mercy switch: 168 cards, draws that stack, and twenty-five in your hand puts you out.
</p>
</a>
<a href="/games/holdem"
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">♠️</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold">Texas Hold'em</h3>
<p class="text-sm text-[color:var(--ink)]/60">Buy in. Beat them. Get up.</p>
</div>
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
</div>
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
A real cash game against bots that were trained on it, not scripted. No multiple, no
3:2 — you leave with whatever is in front of you, less the rake on the pots you win.
</p>
</a>

View File

@@ -0,0 +1,70 @@
{{define "title"}}{{.Room.Name}}{{end}}
{{/* The front door: the only page in here a stranger is allowed to see.
It exists because a link has to unfurl into something, and because a casino
whose first move is to shove you at an auth screen is a casino you'd assume
was broken. Nothing on this page can be played — every table below sends you
through sign-in first, and comes back to the table you picked. */}}
{{define "main"}}
<div class="space-y-8">
<section class="rounded-3xl bg-[color:var(--card)] p-6 sm:p-10 shadow-pete border-2 border-[color:var(--ink)]/10">
{{/* No decoration in here on purpose. The chip stacks want the casino's JS,
which the door doesn't load, and an emoji big enough to balance the hero
is an emoji that isn't on every machine. The share card carries the
pictures; the door carries the way in. */}}
<div class="flex flex-wrap items-center justify-between gap-6">
<div class="min-w-0 max-w-2xl">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[color:var(--accent)]">The doors are open</p>
<h1 class="mt-2 font-display text-3xl sm:text-5xl font-bold">{{.Room.Name}}</h1>
<p class="mt-3 text-[color:var(--ink)]/70">
Six tables, played against the house and its bots, for the same euros you
already have. A chip is worth a euro, the house takes {{.RakePct}}% of what you
win and nothing at all when you lose, and whatever you don't spend comes
back when you cash out.
</p>
<div class="mt-6 flex flex-wrap items-center gap-3">
<a href="/auth/login?next=/games"
class="inline-flex items-center gap-2 rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-[#20180c] shadow-pete hover:-translate-y-0.5 hover:shadow-pete-lg transition">
Sign in and sit down
</a>
<span class="text-sm text-[color:var(--ink)]/45">You'll need a parodia.dev account.</span>
</div>
</div>
</div>
</section>
<section>
<h2 class="font-display text-2xl font-bold mb-4">The tables</h2>
<div class="grid gap-4 sm:grid-cols-2">
{{template "_door_table" dict "Href" "/games/blackjack" "Emoji" "🃏" "Name" "Blackjack" "Line" "Six decks, blackjack pays 3:2."}}
{{template "_door_table" dict "Href" "/games/holdem" "Emoji" "♠️" "Name" "Texas hold'em" "Line" "A cash game against bots that were trained on it."}}
{{template "_door_table" dict "Href" "/games/uno" "Emoji" "🎴" "Name" "UNO" "Line" "Three tables, and a No Mercy deck that bites."}}
{{template "_door_table" dict "Href" "/games/trivia" "Emoji" "🧠" "Name" "Trivia" "Line" "A ladder. Climb it or bank it."}}
{{template "_door_table" dict "Href" "/games/hangman" "Emoji" "🔤" "Name" "Hangman" "Line" "Longer words pay more, for the obvious reason."}}
{{template "_door_table" dict "Href" "/games/solitaire" "Emoji" "🂡" "Name" "Solitaire" "Line" "Klondike, and the deal you pick sets the price."}}
</div>
</section>
<p class="pb-2 text-center text-sm text-[color:var(--ink)]/40">
{{if eq .Room.Slug "casinopolis"}}The lights come on at six, and the place changes its name.
{{else}}It goes back to being Casinopolis at six in the morning.{{end}}
</p>
</div>
{{end}}
{{define "_door_table"}}
<a href="{{.Href}}"
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">{{.Emoji}}</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold">{{.Name}}</h3>
<p class="text-sm text-[color:var(--ink)]/60">{{.Line}}</p>
</div>
</div>
</a>
{{end}}

View File

@@ -5,6 +5,23 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}{{.Room.Name}}{{end}}</title>
<meta name="robots" content="noindex">
{{/* What the casino looks like when somebody pastes it into a chat window.
noindex keeps it out of a search engine and does not keep it out of a link
preview: those are two different bots and only one of them is reading this.
The image is drawn by games_og.go and follows the clock, so a link shared
after six unfurls with the neon on. */}}
<meta property="og:type" content="website">
<meta property="og:site_name" content="{{.Room.Name}}">
<meta property="og:title" content="{{block "og_title" .}}{{.Room.Name}}{{end}}">
<meta property="og:description" content="{{block "og_desc" .}}Blackjack, hold'em, UNO, trivia, hangman and solitaire, played for real gogobee euros. Sign in and pull up a chair.{{end}}">
<meta property="og:url" content="{{.URL}}">
<meta property="og:image" content="{{.OGImage}}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{{.Room.Name}}: two cards and a stack of chips on the felt.">
<meta name="twitter:card" content="summary_large_image">
<meta name="description" content="Blackjack, hold'em, UNO, trivia, hangman and solitaire, played for real gogobee euros.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
@@ -51,7 +68,6 @@
{{template "_comb" .}}
<span class="min-w-0">
<span data-room-name class="block font-display text-2xl sm:text-3xl font-bold leading-none tracking-tight">{{.Room.Name}}</span>
<span class="mt-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[color:var(--ink)]/45">Chips are euros</span>
</span>
</a>
@@ -61,6 +77,12 @@
<span class="grid h-6 w-6 place-items-center rounded-full bg-[color:var(--accent)] text-xs font-bold text-[#20180c]">{{.User.Initial}}</span>
<span class="hidden sm:inline max-w-[7rem] truncate">{{.User.Display}}</span>
</a>
{{else}}
{{/* Nobody is signed in, which on any page but the front door can't happen. */}}
<a href="/auth/login?next=/games"
class="inline-flex shrink-0 items-center rounded-full bg-[color:var(--accent)] px-4 py-1.5 text-sm font-bold text-[#20180c] shadow-pete hover:-translate-y-0.5 transition">
Sign in
</a>
{{end}}
</div>
</header>
@@ -73,6 +95,11 @@
Play for what you can lose. Cash out whenever you like.
</footer>
<!-- The room's noise. Loaded here rather than per-table because every table
wants it and none of them owns it, and because it has to be defined before
casino-fx.js — which reaches for it on every chip it throws. `defer` runs
these in document order, so that's guaranteed. -->
<script src="/static/js/casino-sfx.js" defer></script>
{{block "scripts" .}}{{end}}
</body>
</html>{{end}}

View File

@@ -0,0 +1,234 @@
{{define "title"}}Hold'em · {{.Room.Name}}{{end}}
{{define "main"}}
<div class="space-y-6" data-holdem>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
</svg>
<span class="sr-only">Back to the casino</span>
</a>
<h1 class="font-display text-3xl font-bold">Texas Hold'em</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">Buy in, play as long as you like, leave with what's in front of you</p>
</div>
{{template "_chipbar" .}}
<!-- The felt. Seats along the top, the board and the pot in the middle, you at
the bottom. Every seat has its own bet spot, and every chip on this table is
travelling between one of those and the pot — which is the whole difference
between poker and every other game in the room, where the chips only ever
move between you and the house. -->
<section class="pete-felt pete-poker relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
<div class="min-w-0">
<div data-seats class="pete-poker-seats" aria-label="The other players"></div>
<div class="pete-poker-middle">
<div data-board class="pete-poker-board" aria-label="The board"></div>
<div class="pete-poker-pot">
<!-- The chips get a box of their own. .pete-stack is absolutely
positioned over whatever contains it, so a pile sharing a box with
the number under it paints straight over the number. -->
<div class="pete-poker-pot-pile">
<div class="pete-stack" data-pot-stack></div>
</div>
<span class="pete-poker-pot-label">Pot</span>
<span data-pot-total class="pete-poker-pot-total tabular-nums">0</span>
<span data-side class="pete-poker-side hidden"></span>
</div>
<div class="flex min-h-[2.75rem] items-center justify-center">
<p data-verdict class="pete-poker-verdict hidden" aria-live="polite"></p>
</div>
</div>
<div class="pete-poker-you flex flex-col items-center" data-you></div>
</div>
<!-- The rail. This table has no corner free for the house's rack — the seats
run along the top and your hand is under the board — so it takes
solitaire's rail and sits off the felt entirely. -->
<aside class="pete-rail">
<div class="pete-rack" data-at="rail" data-house aria-hidden="true">
<span data-chip="500" style="--stack: 5"></span>
<span data-chip="100" style="--stack: 7"></span>
<span data-chip="25" style="--stack: 4"></span>
<span data-chip="5" style="--stack: 6"></span>
</div>
<div class="text-center">
<div class="pete-meter" data-meter>
<span class="pete-meter-label">Blinds</span>
<span data-blinds class="pete-meter-value"></span>
</div>
<p data-table-name class="mt-1.5 text-xs font-bold uppercase tracking-wider text-white/40"></p>
</div>
</aside>
</div>
</section>
<!-- Acting: shown when the hand is yours to play. -->
<section data-acting class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-center gap-3">
<button type="button" data-move="fold"
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-6 py-3 font-display text-lg font-bold shadow-pete
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Fold
</button>
<button type="button" data-move="check"
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-6 py-3 font-display text-lg font-bold shadow-pete
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Check
</button>
<button type="button" data-move="call"
class="rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Call <span data-call-amount class="tabular-nums"></span>
</button>
</div>
<div data-raise-row class="mt-4 flex flex-wrap items-center gap-3 border-t-2 border-[color:var(--ink)]/5 pt-4">
<div class="pete-raise">
<input type="range" data-raise-slider aria-label="How much to raise to">
<span data-raise-to class="pete-raise-to">0</span>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<button type="button" data-raise-preset="0.5" class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1.5 text-xs font-bold hover:bg-[color:var(--ink)]/5 transition">½ pot</button>
<button type="button" data-raise-preset="1" class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1.5 text-xs font-bold hover:bg-[color:var(--ink)]/5 transition">Pot</button>
<button type="button" data-raise-preset="max" class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1.5 text-xs font-bold hover:bg-[color:var(--ink)]/5 transition">Max</button>
</div>
<button type="button" data-move="raise"
class="ml-auto rounded-full bg-[color:var(--accent)] px-7 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
<span data-raise-verb>Raise to</span> <span data-raise-label class="tabular-nums"></span>
</button>
</div>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- Between hands: deal the next one, put more chips out, or get up. -->
<section data-between class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-center gap-3">
<div class="min-w-0">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">In front of you</div>
<div class="font-display text-3xl font-bold tabular-nums" data-table-stack>0</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/40">
Bought in for <span data-bought-in class="tabular-nums">0</span> · the house has taken <span data-session-rake class="tabular-nums">0</span> in rake
</p>
</div>
<div class="ml-auto flex flex-wrap items-center gap-2">
<button type="button" data-topup
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-5 py-3 font-display text-base font-bold shadow-pete
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Top up
</button>
<button type="button" data-leave
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-5 py-3 font-display text-base font-bold shadow-pete
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Get up
</button>
<button type="button" data-deal
class="rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Next hand
</button>
</div>
</div>
<p data-between-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- The rail talk. Chat lives at the felt and does not leave it for Matrix;
every line is stamped with the hand it was said during. Shown while you're
seated. -->
<section data-chat class="hidden rounded-3xl bg-[color:var(--card)] p-4 shadow-pete border-2 border-[color:var(--ink)]/10">
<div data-chat-log class="flex flex-col gap-1 max-h-40 overflow-y-auto pr-1 text-sm" aria-live="polite" aria-label="Table chat"></div>
<form data-chat-form class="mt-3 flex items-center gap-2">
<input data-chat-input type="text" maxlength="240" autocomplete="off" placeholder="Say something to the table…"
class="flex-1 min-w-0 rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--bg)] px-4 py-2 text-sm focus:outline-none focus:border-[color:var(--accent)]">
<button type="submit"
class="rounded-full bg-[color:var(--accent)] px-5 py-2 font-display text-sm font-bold text-white shadow-pete hover:brightness-105 active:translate-y-px transition">
Say
</button>
</form>
</section>
<!-- Sitting down: shown when you aren't at a table. -->
<section data-sitting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">What are you playing for?</div>
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Stakes}}
<button type="button" data-tier="{{.Slug}}"
data-min="{{.MinBuy}}" data-max="{{.MaxBuy}}" data-bb="{{.BB}}"
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{.SB}}/{{.BB}}</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
Buy in {{.MinBuy}}{{.MaxBuy}}
</p>
</button>
{{end}}
</div>
<div class="mt-5 grid gap-5 sm:grid-cols-2">
<div>
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">How many of them?</div>
<div class="mt-2 flex flex-wrap gap-1.5" data-bots>
<button type="button" data-bot-count="1" class="pete-tier rounded-xl border-2 px-4 py-2 text-sm font-bold transition">1</button>
<button type="button" data-bot-count="2" class="pete-tier rounded-xl border-2 px-4 py-2 text-sm font-bold transition">2</button>
<button type="button" data-bot-count="3" class="pete-tier rounded-xl border-2 px-4 py-2 text-sm font-bold transition">3</button>
<button type="button" data-bot-count="4" class="pete-tier rounded-xl border-2 px-4 py-2 text-sm font-bold transition">4</button>
<button type="button" data-bot-count="5" class="pete-tier rounded-xl border-2 px-4 py-2 text-sm font-bold transition">5</button>
</div>
<p class="mt-1.5 text-xs text-[color:var(--ink)]/40" data-bots-note></p>
</div>
<div>
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Buying in for</div>
<div class="mt-2 flex items-center gap-3">
<input type="range" data-buyin-slider class="flex-1 min-w-0" aria-label="How much to buy in for">
<span data-buyin class="font-display text-2xl font-bold tabular-nums">0</span>
</div>
<p class="mt-1.5 text-xs text-[color:var(--ink)]/40" data-buyin-note></p>
</div>
</div>
<button type="button" data-sit
class="mt-5 w-full rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Sit down
</button>
<!-- Or pull up a chair at a table someone else has already opened. Bots keep
every open table full, so there is always a seat to take. -->
<div data-lobby-wrap class="mt-6 hidden">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Or join a table in progress</div>
<div data-lobby class="mt-2 grid gap-2"></div>
</div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
The bots are trained, not scripted. The house takes {{.RakePct}}% of a pot that sees a flop, capped at three big blinds, and nothing at all from a hand that doesn't.
</p>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
</div>
{{end}}
{{define "scripts"}}
<script src="/static/js/casino-fx.js" defer></script>
<script src="/static/js/casino-cards.js" defer></script>
<script src="/static/js/games.js" defer></script>
<script src="/static/js/holdem.js" defer></script>
{{end}}

View File

@@ -13,23 +13,24 @@
</a>
<h1 class="font-display text-3xl font-bold">UNO</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">Go out first, or it was somebody else's night</p>
<p class="text-sm text-[color:var(--ink)]/50">Sit down, ante up, and go out first — the pot's yours</p>
</div>
{{template "_chipbar" .}}
<!-- The felt. The board takes the room and the money lives in a rail beside it:
there is no corner free on this table for the house rack to sit in. -->
<!-- The felt. Other seats along the top, the deck and the pile in the middle
with the pot beside them, your hand at the bottom. Every ante on this table
is riding in the pot until somebody goes out and takes it. -->
<section class="pete-felt pete-uno relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
<div class="min-w-0 space-y-5">
<!-- The bots. Each one is a name, a fan of backs, and a count — which is
all you are ever told about them, and all a real opponent shows you. -->
<!-- The other seats. Each one is a name, a stack, a fan of backs and a
count — all a real opponent shows you. -->
<div data-seats class="flex flex-wrap items-start justify-center gap-3 sm:gap-5" aria-label="The other players"></div>
<!-- The middle: what you draw from, and what you play onto. -->
<!-- The middle: what you draw from, what you play onto, and the pot. -->
<div class="flex items-center justify-center gap-5 sm:gap-8">
<button type="button" data-deck class="pete-uno-deck" aria-label="Draw a card">
<span class="pete-uno-back" aria-hidden="true"></span>
@@ -39,6 +40,13 @@
<div class="flex flex-col items-center gap-2">
<div data-discard class="pete-uno-discard" aria-label="The card in play"></div>
<p data-colour class="pete-uno-colour" aria-live="polite"></p>
<p data-bill class="pete-uno-pending hidden" aria-live="assertive"></p>
</div>
<!-- The pot. Every seat's ante is in here until the hand is won. -->
<div class="pete-uno-pot text-center">
<span class="block text-xs font-bold uppercase tracking-wider text-white/50">Pot</span>
<span data-pot class="block font-display text-2xl font-bold tabular-nums text-white">0</span>
</div>
</div>
@@ -65,24 +73,17 @@
<span data-chip="5" style="--stack: 6"></span>
</div>
<div class="pete-spot" data-spot>
<span class="pete-spot-label">Bet</span>
<div class="pete-stack" data-stack></div>
<span data-spot-total class="pete-spot-total hidden"></span>
</div>
<div class="text-center">
<div class="pete-meter" data-meter>
<span class="pete-meter-label">Pays</span>
<span data-pays class="pete-meter-value"></span>
<span class="pete-meter-label">In front of you</span>
<span data-stack class="pete-meter-value"></span>
</div>
<p data-table-name class="mt-1.5 text-xs font-bold uppercase tracking-wider text-white/40"></p>
</div>
</aside>
</div>
<!-- Naming a colour for a wild. It sits over the felt because until it is
answered there is no legal move on the table underneath it. -->
<!-- Naming a colour for a wild. -->
<div data-wild class="pete-uno-wild hidden" role="dialog" aria-label="Pick a colour">
<div class="pete-uno-wild-box">
<p class="font-display text-lg font-bold text-white">Pick a colour</p>
@@ -95,12 +96,22 @@
<button type="button" data-colour-cancel class="mt-3 text-xs font-semibold text-white/50 hover:text-white/80 transition">Play something else</button>
</div>
</div>
<!-- Calling UNO. -->
<div data-unogate class="pete-uno-wild hidden" role="dialog" aria-label="Call UNO">
<div class="pete-uno-wild-box">
<p class="font-display text-lg font-bold text-white">Down to one card</p>
<button type="button" data-uno-call class="pete-uno-call">UNO!</button>
<div class="pete-uno-timer" aria-hidden="true"><span data-uno-timer></span></div>
<p class="mt-2 text-xs text-white/50">Say it, or they'll catch you for two.</p>
</div>
</div>
</section>
<!-- Playing: shown while a game is live. -->
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<!-- Acting: shown while it's your turn. -->
<section data-acting class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-center gap-3">
<p class="text-sm text-[color:var(--ink)]/50">
<p data-hint class="text-sm text-[color:var(--ink)]/50">
Click a card that lights up. Nothing lights up? Draw one.
</p>
<div class="ml-auto flex flex-wrap items-center gap-2">
@@ -109,6 +120,11 @@
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Draw
</button>
<button type="button" data-take
class="hidden rounded-full bg-[#cc3d4a] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Take <span data-take-n>0</span>
</button>
<button type="button" data-pass
class="hidden rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
@@ -119,51 +135,118 @@
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- Betting: shown between games. -->
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Who are you playing?</div>
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Tables}}
<button type="button" data-tier="{{.Slug}}"
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · seven cards each
<!-- Between hands: deal the next one, or get up. -->
<section data-between class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-center gap-3">
<div class="min-w-0">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">In front of you</div>
<div class="font-display text-3xl font-bold tabular-nums" data-table-stack>0</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/40">
Bought in for <span data-bought-in class="tabular-nums">0</span> · <span data-ante-note class="tabular-nums">0</span> to ante
</p>
</button>
{{end}}
</div>
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
<div>
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
</div>
<div class="flex flex-wrap items-center gap-2">
{{range .Denominations}}
<button type="button" data-chip="{{.}}" aria-label="Bet {{.}} more"
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
<span>{{.}}</span>
<div class="ml-auto flex flex-wrap items-center gap-2">
<button type="button" data-leave
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-5 py-3 font-display text-base font-bold shadow-pete
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Get up
</button>
<button type="button" data-deal
class="rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Next hand
</button>
</div>
</div>
<p data-between-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- The rail talk, shown while you're seated. -->
<section data-chat class="hidden rounded-3xl bg-[color:var(--card)] p-4 shadow-pete border-2 border-[color:var(--ink)]/10">
<div data-chat-log class="flex flex-col gap-1 max-h-40 overflow-y-auto pr-1 text-sm" aria-live="polite" aria-label="Table chat"></div>
<form data-chat-form class="mt-3 flex items-center gap-2">
<input data-chat-input type="text" maxlength="240" autocomplete="off" placeholder="Say something to the table…"
class="flex-1 min-w-0 rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--bg)] px-4 py-2 text-sm focus:outline-none focus:border-[color:var(--accent)]">
<button type="submit"
class="rounded-full bg-[color:var(--accent)] px-5 py-2 font-display text-sm font-bold text-white shadow-pete hover:brightness-105 active:translate-y-px transition">
Say
</button>
</form>
</section>
<!-- Sitting down: shown when you aren't at a table. -->
<section data-sitting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Which table?</div>
<div class="pete-seg" role="group" aria-label="Which rules">
<button type="button" data-rules="normal" class="pete-seg-btn">UNO</button>
<button type="button" data-rules="nomercy" class="pete-seg-btn">No Mercy</button>
</div>
</div>
<div data-grid="normal">
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Tables}}
<button type="button" data-tier="{{.Slug}}" data-min="{{.MinBuy}}" data-max="{{.MaxBuy}}" data-ante="{{.Ante}}"
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{.Ante}} ante</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · buy in {{.MinBuy}}{{.MaxBuy}}
</p>
</button>
{{end}}
<button type="button" data-bet-clear
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
</div>
<button type="button" data-start
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Deal
</button>
</div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
Normal rules: no stacking a +2 on a +2, and a reverse is a skip when it's just the two of you.
<div data-grid="nomercy" class="hidden">
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .NoMercy}}
<button type="button" data-tier="{{.Slug}}" data-min="{{.MinBuy}}" data-max="{{.MaxBuy}}" data-ante="{{.Ante}}"
class="pete-tier pete-tier-nm rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{.Ante}} ante</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · 168 cards
</p>
</button>
{{end}}
</div>
</div>
<div class="mt-5">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Buying in for</div>
<div class="mt-2 flex items-center gap-3">
<input type="range" data-buyin-slider class="flex-1 min-w-0" aria-label="How much to buy in for">
<span data-buyin class="font-display text-2xl font-bold tabular-nums">0</span>
</div>
<p class="mt-1.5 text-xs text-[color:var(--ink)]/40" data-buyin-note></p>
</div>
<button type="button" data-sit
class="mt-5 w-full rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Sit down
</button>
<div data-lobby-wrap class="mt-6 hidden">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Or join a table in progress</div>
<div data-lobby class="mt-2 grid gap-2"></div>
</div>
<p data-note="normal" class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
Everyone antes, and the first one out takes the pot less {{.RakePct}}% rake. Normal rules: no stacking a +2 on a +2, and a reverse is a skip when it's just the two of you.
</p>
<p data-note="nomercy" class="hidden mt-3 text-center text-xs text-[color:var(--ink)]/40">
168 cards, +6s and +10s. Draw cards stack, you draw until you can play, and {{.MercyLimit}} cards in your hand puts you out of the hand. The pot still goes to whoever goes out first.
</p>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>

View File

@@ -0,0 +1,207 @@
{{define "title"}}{{.Mark.Name}} — {{.SiteTitle}}{{end}}
{{define "main"}}
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="who" data-token="{{.Mark.Token}}">
<nav class="mb-4">
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
<span aria-hidden="true"></span> The board
</a>
</nav>
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{if .Mark.OnRun}}⚔{{else}}🏠{{end}}</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-80">adventurer</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Mark.Name}}</h1>
<p class="mt-2 opacity-90">Level {{.Mark.Level}} {{.Mark.ClassRace}}</p>
<p class="mt-4 text-xs uppercase tracking-wider opacity-75" id="who-where">
{{if .Mark.OnRun}}{{.Mark.Where}}{{else}}In town{{if .Mark.Idle}} · {{.Mark.Idle}}{{end}}{{end}}
</p>
</div>
</header>
{{if .HasDetail}}
<!-- Stats + gear: public, the same anonymity model as the board. -->
<section class="mt-8 grid gap-6 sm:grid-cols-2">
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">Vitals</h2>
<div class="flex items-baseline justify-between">
<span class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50">Hit points</span>
<span class="font-semibold" id="who-hp">{{.Detail.HPCurrent}} / {{.Detail.HPMax}}{{if .Detail.TempHP}} <span class="text-theme-adventure">+{{.Detail.TempHP}}</span>{{end}}</span>
</div>
<div class="mt-2 h-2 rounded-full bg-[color:var(--ink)]/10 overflow-hidden">
<div class="h-full rounded-full bg-theme-adventure transition-all" id="who-hp-bar" style="width:0%"></div>
</div>
<div class="mt-4 flex items-baseline justify-between">
<span class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50">Armor class</span>
<span class="font-semibold" id="who-ac">{{.Detail.ArmorClass}}</span>
</div>
<div class="mt-5 grid grid-cols-3 gap-2">
{{range .Abilities}}
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50">{{.Label}}</div>
<div class="font-display text-lg font-bold leading-none mt-1">{{.Score}}</div>
<div class="text-xs text-[color:var(--ink)]/60">{{.ModStr}}</div>
</div>
{{end}}
</div>
{{if .Mark.OnRun}}
<div class="mt-5 pt-4 border-t border-[color:var(--ink)]/10 space-y-1.5 text-sm" id="who-expedition">
{{if .Detail.Room}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Room</span><span class="font-semibold" id="who-room">{{.Detail.Room}}</span></div>{{end}}
<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Supplies</span><span class="font-semibold" id="who-supplies">{{.Detail.Supplies}}</span></div>
{{if .Detail.ThreatLevel}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Threat</span><span class="font-semibold" id="who-threat">{{.Detail.ThreatLevel}}</span></div>{{end}}
</div>
{{end}}
</div>
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">Gear</h2>
{{if .Detail.Gear}}
<ul class="space-y-2">
{{range .Detail.Gear}}
<li class="flex items-baseline justify-between gap-3">
<span class="text-xs uppercase tracking-wider text-[color:var(--ink)]/45 w-16 shrink-0">{{.Slot}}</span>
<span class="font-semibold flex-1">{{.Name}}{{if .Masterwork}} <span title="masterwork"></span>{{end}}</span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Condition}} · {{.Condition}}%{{end}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="text-sm text-[color:var(--ink)]/50">Traveling light — nothing equipped.</p>
{{end}}
</div>
</section>
{{else}}
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<p class="text-sm text-[color:var(--ink)]/60">No detailed sheet on file for this adventurer yet — check back after the next snapshot.</p>
</section>
{{end}}
{{if .HasSelf}}
<!-- Owner-only: this is you. Inventory, vault, house, pets — private, served
only because your signed-in localpart owns this page's token. -->
<section class="mt-8">
<div class="flex items-center gap-2 mb-3">
<span class="rounded-full bg-theme-adventure text-white text-xs font-semibold px-3 py-1">Your adventurer</span>
<span class="text-xs text-[color:var(--ink)]/45">only you can see the panels below</span>
</div>
<div class="grid gap-6 sm:grid-cols-2">
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">Home</h2>
<div class="space-y-1.5 text-sm">
<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">House tier</span><span class="font-semibold">{{if .Self.House.Tier}}{{.Self.House.Tier}}{{else}}no house{{end}}</span></div>
{{if .Self.House.LoanBalance}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Loan balance</span><span class="font-semibold">{{.Self.House.LoanBalance}}</span></div>{{end}}
{{if .Self.House.Rate}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Rate</span><span class="font-semibold">{{.Self.House.Rate}}</span></div>{{end}}
{{if .Self.House.Autopay}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Autopay</span><span class="font-semibold">on</span></div>{{end}}
</div>
<h3 class="font-display text-lg font-bold mt-6 mb-3">Pets</h3>
{{if .Self.Pets}}
<ul class="space-y-2 text-sm">
{{range .Self.Pets}}
<li class="flex items-baseline justify-between gap-3">
<span class="font-semibold flex-1">{{if .Name}}{{.Name}}{{else}}your {{.Type}}{{end}} <span class="text-[color:var(--ink)]/45 font-normal">{{.Type}}</span></span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">lv {{.Level}}{{if .ArmorTier}} · barding T{{.ArmorTier}}{{end}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="text-sm text-[color:var(--ink)]/50">No pets right now.</p>
{{end}}
</div>
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">Backpack</h2>
{{if .Self.Inventory}}
<ul class="space-y-1.5 text-sm max-h-72 overflow-y-auto pr-1">
{{range .Self.Inventory}}
<li class="flex items-baseline justify-between gap-3">
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="text-sm text-[color:var(--ink)]/50">Backpack's empty.</p>
{{end}}
{{if .Self.Vault}}
<h3 class="font-display text-lg font-bold mt-6 mb-3">Vault</h3>
<ul class="space-y-1.5 text-sm max-h-52 overflow-y-auto pr-1">
{{range .Self.Vault}}
<li class="flex items-baseline justify-between gap-3">
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
</li>
{{end}}
</ul>
{{end}}
</div>
</div>
</section>
{{end}}
</article>
<script>
// The sheet is state, like the board: keep an open tab honest without a reload.
// Public detail only (HP, room, supplies) — the private panels stay as rendered.
(function () {
var root = document.getElementById('who');
if (!root) return;
var token = root.getAttribute('data-token');
function setBar() {
var el = document.getElementById('who-hp-bar');
var hp = document.getElementById('who-hp');
if (!el || !hp) return;
var m = hp.textContent.match(/(-?\d+)\s*\/\s*(\d+)/);
if (!m) return;
var cur = parseInt(m[1], 10), max = parseInt(m[2], 10);
var pct = max > 0 ? Math.max(0, Math.min(100, Math.round(cur / max * 100))) : 0;
el.style.width = pct + '%';
}
setBar();
function txt(id, v) { var el = document.getElementById(id); if (el && v != null) el.textContent = v; }
var timer = null;
function refresh() {
fetch('/api/adventure/who/' + encodeURIComponent(token), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data) return;
if (!data.live) {
// Off the board entirely (expedition ended, opted out): nothing more to
// poll for, so stop the timer rather than hammer a token that's gone.
txt('who-where', 'Back in town');
if (timer) clearInterval(timer);
return;
}
var d = data.detail || {};
var mark = data.mark || {};
if (data.has_detail) {
var temp = d.temp_hp ? ' +' + d.temp_hp : '';
txt('who-hp', d.hp_current + ' / ' + d.hp_max + temp);
txt('who-ac', d.armor_class);
txt('who-room', d.room);
txt('who-supplies', d.supplies);
txt('who-threat', d.threat_level);
setBar();
}
// Keep the location line honest in both directions: a mark that came back
// to town while still on the board must stop showing its old expedition.
if (mark.OnRun) {
txt('who-where', mark.Where);
} else {
txt('who-where', 'In town' + (mark.Idle ? ' · ' + mark.Idle : ''));
}
})
.catch(function () { /* transient — next tick will do */ });
}
timer = setInterval(refresh, 60000);
})();
</script>
{{end}}

167
internal/web/who.go Normal file
View File

@@ -0,0 +1,167 @@
package web
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"pete/internal/storage"
)
// The adventurer detail page.
//
// A click-through from the live board: anyone may see a mark's current stats and
// equipped gear (the same anonymity model as the board — a character name and a
// sheet, never a Matrix handle). The signed-in owner sees the same page enriched
// with their private inventory, vault, house, and pets.
//
// Both halves are still gogobee → Pete: the public detail rides the roster
// snapshot, the private detail rides its own push. Pete renders what it was
// given; it never reaches back into the game box.
// whoDetail is the public sheet as gogobee pushed it (RosterEntry.Detail).
type whoDetail struct {
HPCurrent int `json:"hp_current"`
HPMax int `json:"hp_max"`
TempHP int `json:"temp_hp"`
ArmorClass int `json:"armor_class"`
Abilities [6]int `json:"abilities"`
Modifiers [6]int `json:"modifiers"`
Gear []whoGear `json:"gear"`
Supplies int `json:"supplies"`
ThreatLevel int `json:"threat_level"`
Room string `json:"room"`
}
type whoGear struct {
Slot string `json:"slot"`
Name string `json:"name"`
Tier int `json:"tier"`
Condition int `json:"condition"`
Masterwork bool `json:"masterwork"`
}
// abilityRow is one ability line, pre-formatted for the template.
type abilityRow struct {
Label string
Score int
ModStr string // "+2", "-1"
}
var abilityLabels = [6]string{"STR", "DEX", "CON", "INT", "WIS", "CHA"}
type whoPage struct {
pageData
Mark RosterView
HasDetail bool
Detail whoDetail
Abilities []abilityRow
HasSelf bool
Self storage.PlayerDetail
}
// handleAdventureWho serves one adventurer's detail page. Public; 404s when the
// token isn't on the current board — the same liveness gate the storefront uses,
// so a stale or guessed token never resolves to a page.
func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
token := r.PathValue("token")
entry, ok, err := storage.RosterEntryByToken(token)
if err != nil {
slog.Error("who: roster lookup failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok {
http.NotFound(w, r)
return
}
s.track(r, "adventure")
base := s.base(r)
base.Active = "adventure"
base.NoIndex = true // names a player character; keep out of search indexes
page := whoPage{
pageData: base,
Mark: toRosterView(entry),
}
if d, abil, ok := decodeWhoDetail(entry.Detail); ok {
page.HasDetail = true
page.Detail = d
page.Abilities = abil
}
// Owner enrichment: only when the signed-in user's localpart owns this exact
// page token. The ownership is proven by a row gogobee pushed, never by
// reversing the token, so no visitor can unlock another player's self extras.
if s.auth != nil {
if u := s.auth.userFromRequest(r); u != nil {
if self, ok, err := storage.PlayerDetailByOwner(buyerLocalpart(u), token); err == nil && ok {
page.HasSelf = true
page.Self = self
}
}
}
s.render(w, "who", page)
}
// handleAdventureWhoAPI serves the public detail as JSON for the page's live
// re-poll, so an open tab tracks HP / room / supplies as they move. Public — the
// same exposure as the rendered page. The private self extras are not included:
// inventory changes rarely and stays server-rendered behind the ownership check.
func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
token := r.PathValue("token")
entry, ok, err := storage.RosterEntryByToken(token)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if !ok {
// Gone from the board (expedition ended, opted out): tell the poller so it
// can stop, rather than 404-ing an open tab into an error.
_ = json.NewEncoder(w).Encode(map[string]any{"live": false})
return
}
d, abil, hasDetail := decodeWhoDetail(entry.Detail)
_ = json.NewEncoder(w).Encode(map[string]any{
"live": true,
"mark": toRosterView(entry),
"has_detail": hasDetail,
"detail": d,
"abilities": abil,
})
}
// decodeWhoDetail unpacks the public detail blob and builds the ability rows.
// Returns ok=false when there is no detail (a snapshot from before the detail
// push), so the page can fall back to the summary the board already has.
func decodeWhoDetail(raw json.RawMessage) (whoDetail, []abilityRow, bool) {
if len(raw) == 0 {
return whoDetail{}, nil, false
}
var d whoDetail
if err := json.Unmarshal(raw, &d); err != nil {
return whoDetail{}, nil, false
}
rows := make([]abilityRow, 0, 6)
for i := 0; i < 6; i++ {
rows = append(rows, abilityRow{
Label: abilityLabels[i],
Score: d.Abilities[i],
ModStr: fmt.Sprintf("%+d", d.Modifiers[i]),
})
}
return d, rows, true
}

199
internal/web/who_test.go Normal file
View File

@@ -0,0 +1,199 @@
package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"pete/internal/storage"
)
// The adventurer detail page. Two contracts under test: the public sheet reaches
// any visitor (stats + gear, never a handle), and the private self-view unlocks
// only for the signed-in owner of that exact page — anon and other users see the
// public page and nothing more.
func postDetail(t *testing.T, s *Server, token string, push detailPush) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(push)
req := httptest.NewRequest("POST", "/api/ingest/detail", bytes.NewReader(body))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
s.handleDetailIngest(w, req)
return w
}
// publicDetail is the sheet gogobee hangs on a roster entry, marshalled the way
// the roster push carries it.
func publicDetail(t *testing.T) json.RawMessage {
t.Helper()
raw, err := json.Marshal(map[string]any{
"hp_current": 30,
"hp_max": 42,
"temp_hp": 5,
"armor_class": 17,
"abilities": [6]int{16, 14, 15, 10, 12, 8},
"modifiers": [6]int{3, 2, 2, 0, 1, -1},
"gear": []map[string]any{
{"slot": "weapon", "name": "Rusty Sword", "tier": 0, "condition": 100},
{"slot": "armor", "name": "Padded Vest", "tier": 0, "condition": 100},
},
"supplies": 8,
"threat_level": 2,
"room": "3 / 7",
})
if err != nil {
t.Fatal(err)
}
return raw
}
// seedWho puts one adventurer on the board with a public detail sheet, and
// pushes a private self-detail set owned by `owner` for that same token.
func seedWho(t *testing.T, owner string) *Server {
s, _ := newAdvServer(t, "tok")
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
now := time.Now().Unix()
e := entry("tok-josie", "Josie", "expedition", "holymachina")
e.Level = 14
e.ClassRace = "human cleric"
e.Detail = publicDetail(t)
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
t.Fatalf("seed roster = %d", w.Code)
}
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
Localpart: owner,
Token: "tok-josie",
Inventory: []storage.ItemView{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}},
Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
House: storage.HouseView{Tier: 2, LoanBalance: 1500},
Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}},
}}}); w.Code != 200 {
t.Fatalf("seed detail = %d", w.Code)
}
return s
}
func getWho(t *testing.T, s *Server, token, asUser string) *httptest.ResponseRecorder {
t.Helper()
var req = httptest.NewRequest("GET", "/adventure/who/"+token, nil)
req.SetPathValue("token", token)
if asUser != "" {
payload, _ := json.Marshal(SessionUser{Sub: "sub-1", Username: asUser, Exp: time.Now().Add(time.Hour).Unix()})
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
}
w := httptest.NewRecorder()
s.handleAdventureWho(w, req)
return w
}
// TestWhoPublicSheet: any visitor sees the mark's stats and gear — driven
// through the real template, so a field slip 500s here rather than in prod.
func TestWhoPublicSheet(t *testing.T) {
s := seedWho(t, "josie")
w := getWho(t, s, "tok-josie", "") // anonymous
if w.Code != 200 {
t.Fatalf("GET who = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{"Josie", "human cleric", "Rusty Sword", "Padded Vest"} {
if !strings.Contains(body, want) {
t.Errorf("public page missing %q", want)
}
}
// The private goods must NOT be on an anonymous render.
for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
if strings.Contains(body, leak) {
t.Errorf("anonymous page leaked private detail %q", leak)
}
}
}
// TestWhoSelfUnlock: the owner, signed in on their own page, sees the private
// inventory/vault/house/pets inline.
func TestWhoSelfUnlock(t *testing.T) {
s := seedWho(t, "josie")
w := getWho(t, s, "tok-josie", "josie")
if w.Code != 200 {
t.Fatalf("owner GET who = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
if !strings.Contains(body, want) {
t.Errorf("owner page missing private detail %q", want)
}
}
}
// TestWhoSelfUnlockCaseInsensitive: Authentik may hand back a mixed-case
// username; the localpart it maps to is lowercase. The owner must still unlock.
func TestWhoSelfUnlockCaseInsensitive(t *testing.T) {
s := seedWho(t, "josie")
w := getWho(t, s, "tok-josie", "Josie") // capital-J session
if !strings.Contains(w.Body.String(), "Iron Ore") {
t.Error("a mixed-case owner username failed to unlock their own self-view")
}
}
// TestWhoOtherUserNoUnlock: a different signed-in player sees only the public
// sheet on someone else's page — the ownership join must not leak across users.
func TestWhoOtherUserNoUnlock(t *testing.T) {
s := seedWho(t, "josie")
w := getWho(t, s, "tok-josie", "quack") // signed in, but not the owner
if w.Code != 200 {
t.Fatalf("other-user GET who = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "Josie") {
t.Error("public sheet missing for a signed-in non-owner")
}
for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
if strings.Contains(body, leak) {
t.Errorf("a non-owner unlocked private detail %q", leak)
}
}
}
// TestWhoOffBoardIs404: a token that isn't on the current board resolves to
// nothing — the same liveness gate the storefront uses, so a stale or guessed
// token never renders a page.
func TestWhoOffBoardIs404(t *testing.T) {
s := seedWho(t, "josie")
if w := getWho(t, s, "tok-ghost", ""); w.Code != 404 {
t.Errorf("off-board token = %d, want 404", w.Code)
}
}
// TestDetailIngestReplacesAndAuth: the detail push is bearer-gated and swaps the
// set whole, so a player dropped from a later push loses their self-view.
func TestDetailIngestReplacesAndAuth(t *testing.T) {
s := seedWho(t, "josie")
if w := postDetail(t, s, "wrong", detailPush{SnapshotAt: time.Now().Unix()}); w.Code != 401 {
t.Errorf("bad bearer = %d, want 401", w.Code)
}
// A later push without Josie: her self-view must be gone, but the public
// board (a separate keyspace) is untouched, so her page still renders public.
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: time.Now().Unix() + 60}); w.Code != 200 {
t.Fatalf("empty detail push = %d, want 200", w.Code)
}
body := getWho(t, s, "tok-josie", "josie").Body.String()
if strings.Contains(body, "Iron Ore") {
t.Error("owner still unlocked after her detail was dropped from the push")
}
if !strings.Contains(body, "Josie") {
t.Error("public page vanished when only the private detail was dropped")
}
}

View File

@@ -300,6 +300,7 @@ func main() {
ws.StartPushSender(ctx)
ws.StartAdventureDigest(ctx)
ws.StartTriviaBank(ctx)
ws.StartTableClock(ctx)
}
}

View File

@@ -12,6 +12,280 @@ This plan reuses that seam wholesale and does not invent a second one.
A multi-session build. This section is the handover; read it before anything else.
### Start here (next session)
**The casino is going multiplayer, and the settle path has been rewritten to
survive it.** *(2026-07-14, later.)* The user asked for Blackjack, UNO and
Hold'em to be playable against real people. The design is settled (see "The
multiplayer build" below), and **Phase A — the atomic settle — is done, tested
and driven.** Nothing a player can see has changed. The next thing to build is
Phase B, the table runtime.
**And the deploy note was stale again — for the second time.** §0 said "a deploy
is owed, three commits behind". It wasn't: the box is on `a5b7e41`, its binary was
built three minutes after that commit landed, the front door serves a public 200
and `/og.png` renders. Everything is live. This is the *same note* that was wrong
two sessions ago, with the *same* lesson written directly underneath it, and it
went stale again anyway. Draw the obvious conclusion: **a hand-written record of
what is deployed will rot. Ask the box.** One `git log -1` in `/opt/pete`, one
`stat` on the binary, and one `curl` at a route only the new code serves — that is
thirty seconds and it is never wrong.
**The whole casino is live.** All six games (seven, counting the No Mercy dial)
are deployed and playing on https://games.parodia.dev.
**And the deploy note this plan had been carrying was wrong.** §0 said "only
blackjack is live" for two sessions running. It wasn't: hangman, solitaire, trivia
and UNO had already gone out at some point, and only hold'em was actually missing
(`/games/holdem` was the one route on the live box returning a 404 while the other
five returned a 302 to sign-in). The lesson is cheap and worth keeping: **ask the
running server what it serves — one loop over the routes — rather than trusting a
note about what was deployed.** The note was written by whoever last deployed; the
404 was written by the thing that is actually running.
**A bug the browser found in hold'em, and where it wasn't.** Six-handed, the felt
printed **CO on three seats at once**, and folding the small blind relabelled it
the cutoff. `Position()` walked the table with `nextIn` — which steps *over* folded
seats — while sizing that walk with a seat count that still *included* them, so
every muck slid the anchors round. There is now a second walker, `nextDealt`, which
skips only the seats that are not in the hand at all, because **where you sit is
decided when the button moves and does not change because somebody mucked**. The
pair is easy to confuse and now says so at both definitions. *(`03524ae`)*
Two things about that bug are the useful part. The bots never read `Position()`
they read `InPosition()`, which genuinely does want the last seat still live — so
**the policy and the money were never touched; the only thing it ever broke was the
badge**, which is exactly why nothing caught it. And it was found only by dealing a
six-handed hand in a browser and reading the seats.
**A trap in the dev rig, which cost most of a session.** The felt appeared to show
the button posting a *big* blind heads-up. It was not: the engine is right (the
button posts the small blind and acts first before the flop, and there is a test).
What was on screen was a **half-played hand left behind by a failed drive script**
the rig's table survives across runs of the driver. **A stale live hand will lie to
you.** Read the state from a hand you dealt yourself, and when the felt and your
model of the engine disagree, ask the engine directly (a throwaway `_test.go` in the
package beats an hour of staring at pixels).
**The money finding, and the thing to actually remember:** the *normal* UNO tiers
had been mispriced for a while, and it wasn't No Mercy's fault. They were set
against a naive win rate of 43/32/27%; re-measuring says 40.3/29.2/23.3%. The bots
improved at some point after the multiples were written down and nobody re-ran the
measurement — which §0 already warned about ("the bots and the tiers are a pair").
Table and Full House had quietly been charging an **1819% house edge instead of
8%**. All six tiers are repriced off a fresh measurement, and
**`TestTheMultiplesAreStillPriced`** now fails the build if any of them drift out
of a 214% band. It is the test the normal tiers never had, which is precisely how
they drifted. *Any change to the bots, the deck, or a rule re-opens this.*
And the counterintuitive one, which would have shipped wrong if it had been
guessed: **No Mercy is easier than UNO at every table size** (naive wins 46.7% vs
40.3% heads-up), so it **pays less** — 2.0/3.1/3.8 against the normal deck's new
2.4/3.3/4.1. The mercy rule doesn't 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 is merciless mostly to the table.
### What this session did (2026-07-14, later)
**The casino had no share card, and 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 the word "parodia.dev" — no title, no image. Tags on a
page a stranger cannot fetch are tags nobody reads. The casino now has a **front
door**: `/games` serves a real page to anybody, with the sign-in button and the
six tables on it. The tables still bounce you to sign-in; you cannot play from
the door. The **share card is drawn in Go** (`games_og.go`, `/games/og.png`,
public) and follows the clock like everything else — Casinopolis on green felt by
day, Casino Night Zone in neon after six — except the clock that decides is the
*server's*, because an unfurl bot has no evening of its own. *(`7ca1f7a`)*
Two things from building it worth not re-learning. **`color.RGBA` is
alpha-premultiplied**: the lamp over the table wrote raw channels beside a low
alpha, `image/draw` ran the result past 255 and wrapped the hue, and the first
card came out with a blue dome over a green stripe. If a colour comes out
impossible, look for a missing premultiply. And **an `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, where hostRouter puts
the `/games` back on; `/games/og.png` anywhere else). The test now reads the URL
off the page and goes and fetches it, on both hosts.
**Blackjack has a split.** *(`6f34a89`)* `State.Player` is gone: there is a slice
of `Hands`, each with its own cards, bet, outcome and payout, played left to
right. It is the only move in the game that takes chips *after* the cards are
out, and that is where the bugs live — `handleMove` was staking `st.Bet` for a
double, which was the same number as the hand's bet until today and is now the
whole table's, so doubling the third hand of a split would have charged you for
all three. `DoubleCost`/`SplitCost` are the active hand's. Each hand is **raked
on its own winnings**: netting them first would mean winning one and losing one
costs no rake at all, which is a discount for splitting rather than a rake. The
rules that cost real money if guessed: split aces get one card each and no say;
**21 on a split hand is not a natural** and does not pay 3:2; same rank, not same
value (K+Q is not a pair); four hands max; double after split is allowed; and if
every hand busts the dealer never turns over. `State.UnmarshalJSON` still reads
pre-split blobs, because a live hand outlives a deploy and a player mid-hand at
restart would otherwise be a player whose cards vanished.
### Deployed, as of right now
Everything through **`a5b7e41`** is on the box and running. *Verified by asking
the box, not by trusting this line — which is the only way this line is worth
anything. It has been wrong twice.*
---
## The multiplayer build
Blackjack, UNO and Hold'em, played against real people. This inverts the core
entity from **player** to **table**, and the whole difficulty is that two things
which were safe stop being safe: the settle path was written for one player and
one bet, and a table has two writers (an HTTP move, and a turn clock acting for
whoever walked away) where it used to have one.
### Decisions taken (from the user, 2026-07-14)
- **SSE, not WebSocket.** Latency was never the argument — a turn-based card game
does not care. The argument is that moves keep their existing POST endpoints and
the whole money path with them; a socket would mean rebuilding auth, ordering
and the double-click 409 on a message loop. EventSource also reconnects itself.
Chat is a POST up and an event down.
- The line where a socket *would* start paying for itself is **typing
indicators** — continuous upstream traffic. The user looked at that and said
messages only. If that changes, so does the transport.
- Plain polling was rejected for a specific reason: `MaxOpenConns(1)`. Every
idle player at a felt polling once a second is a serialized query against the
single connection the news app also shares.
- **Bots fill empty seats.** A human sitting down bumps a bot, so solo play is just
"a table nobody else joined yet" and there is no second mode to maintain.
- **UNO becomes a pot**, and the house multiple is deleted. Everyone antes, first
one out takes the pot less rake, bots ante from the house. The payout becomes
arithmetic rather than a measurement — which **kills the tier-drift problem and
`TestTheMultiplesAreStillPriced` with it**.
- The consequence was surfaced and accepted, and it is worth understanding
before anybody "fixes" it: against bots the pot is **harsher** than today.
Naive play loses ~20% a game rather than ~8%, because the bots beat a naive
player 60/40 heads-up and a pot does not compensate the way a 2.4× multiple
did. A pot is only fair between equals. **The casino already ships this exact
deal in hold'em** — you play bots for real chips and the house takes only rake
— so the outcome is one money model across the room instead of two.
- **Chat stays on the felt.** It does not mirror into Matrix.
- Tables are found through a **public lobby**. Room codes later, if ever; bots mean
a table is never empty.
### The four rules the runtime has to obey
Not style preferences. Breaking the first two hangs the process or mints money.
1. **Table lock first, DB second. Never begin a transaction before you hold the
lock; never take a lock while holding one.** SQLite is at `MaxOpenConns(1)`, so
the connection *is* a global mutex. A per-table mutex makes two locks, and two
locks in two orders is a deadlock that eats the only connection — taking the
news app down with the casino.
2. **The primary key decides, not a prior read.** Already this repo's idiom
(`StartLiveHand`'s `ON CONFLICT DO NOTHING`; `game_escrow.guid`). Every new
money path gets the same treatment.
3. **A settle is one transaction.** See Phase A.
4. **A `version` column is the concurrency authority; the mutex is only an
optimisation.** A mutex does not survive a redeploy — during a drain two
processes hold two different mutexes over the same row — and a mutex map you can
`delete()` from will hand two goroutines two different locks for one table.
### Phase A — the atomic settle. **Done.**
Shipped on its own, against the existing single-player games, changing nothing a
player can see. Every multiplayer money bug is downstream of it.
`commit()` was four separate autocommit statements: save → `Award``RecordHand`
`ClearLiveHand`, carefully sequenced so a crash between any two cost the player
as little as possible. That reasoning is sound for a game owned by one player, and
the old comment made it well. **It does not survive a pot.** 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 them into euros.
Worse, the obvious fix is a trap. `storage.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 the news app goes
with it.
So: `storage.CommitHand` does the lot — seat, pay, record, clear, touch — in one
`Begin`/`Commit`, with tx-taking `award`/`recordHand` helpers beside the public
ones. The model already existed: `addChips(tx, …)` has done it this way since the
escrow ledger was written.
Two things fell out that are worth keeping:
- **The refuse-and-refund is now atomic.** A deal landing on a taken seat used to
come back `ErrHandInProgress` and *then* refund in a separate statement — so a
crash in between took a player's stake for a game that existed nowhere: no felt,
no audit row, no way to find it. It is one transaction now.
- **The audit row moved inside the settle**, which means a failure to write it now
rolls the payout back rather than paying quietly and logging. Deliberate: 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 — the game stays live and settles again on
the next request.
**`TestTheSettleDoesNotDeadlockAgainstItsOwnConnection` is a canary, and it has
been made to sing.** Reintroduce the bug (call `Award` instead of `award` inside
`CommitHand`) and it does not fail with a nice message — it *hangs*, and the
timeout is the message, which is exactly the production failure. It was verified by
putting the bug back and watching it catch it. A canary that has never sung is
just a bird.
Driven end to end through the real HTTP path, not just unit tests: eight blackjack
hands conserving to the chip across win/lose/push (a natural is the sharp case —
it is `Fresh` *and* `Done` in one `CommitHand`), a double-deal 409 that refunds and
leaves the live game untouched, hangman, and a hold'em session (sit 200, fold the
blinds, get up with 197).
### Phase B — the table runtime. **Next.**
Full design in `~/.claude/plans/imperative-mixing-emerson.md`. The load-bearing
parts, which are the ones that are easy to get wrong:
- **Occupancy stays in `game_live_hands`** — add a nullable `table_id` rather than
making `game_seats.matrix_user` a second uniqueness domain. A split brain there
silently switches off three guards that already work, and the worst is the
cash-out check (`games_play.go`), which reads `LoadLiveHand`: a seated player
with no live-hand row could **cash out to zero while sitting at a poker table
with chips in the pot.**
- **The turn clock is the first goroutine in Pete that has ever mutated game
state.** Copy `StartTriviaBank`'s shape. It must collect table ids and *close the
rows* before taking any lock (rule 1), and act only if the `version` still
matches the one it saw at scan time. Without that check: Bob's raise lands in the
same second his clock expires, action passes to Cara, and the clock then **folds
Cara**, who had 25 seconds left. A one-second window that recurs on every turn of
every hand.
- **An absent human is not a bot**, and this is a product bug before it is a money
bug. The bot loop stops dead at a disconnected player's seat and waits out the
full clock; a table with three ghosts spends a minute an orbit folding air and
the one real player leaves. Seats go `away` after a timeout and get auto-acted.
- **SSE frames publish under the table lock** (which orders them correctly for
free) but with **non-blocking sends only** — one phone on a train must not block
a send, hold the lock, and stall the clock for the whole casino. And **the SSE
handler must never touch the DB after its first read**: holding rows open for the
life of a stream holds the only connection forever, and one subscriber bricks the
application.
- **`dropUnreadable` must never fire on a multi-seat table.** It discards the game
and keeps the stake — defensible for one player, but at a shared table it
silently incinerates four players' stacks.
- **`ReapIdleSessions` has never actually run.** Zero production callers; its doc
says "safe to run on a timer" and nothing ever put it on one, so chips in
abandoned sessions have been in limbo all along. Wire it while wiring the clock.
Then: **C — Hold'em** (its engine is already multiway: real seats, side pots,
`ToAct`. Mostly letting more than one seat be `Bot: false`. **Write the per-seat
redaction test first** — after SSE, one missed `i == You` fans every hole card to
every subscriber). **D — UNO** (`const You = 0` becomes a seat parameter, ~40
sites; the pot). **E — Blackjack** (seats invented from scratch, but the simplest
turn model. **Shuffle every round**: a persistent shoe broadcast over SSE is a
countable shoe with a free API, and counting is a real +EV attack on real euros).
Two that are easy to miss: **`RecordHand` takes one user, so `HouseTake` will
lie** — four rows each carrying the pot's full rake reports 4× the real take, and
that number is the inflation brake the economy was sized on. And **seeds stop
reproducing a hand**: at a shared table the cards depend on the order the others
acted, so the audit trail quietly stops being one.
---
### Decisions taken (these close §9's open questions)
- **Chips are 1:1 with euros.** No second denomination.
@@ -399,21 +673,164 @@ A multi-session build. This section is the handover; read it before anything els
remembering as a rule: **size a card by its vars, never by the box you put it
in.**
- **No Mercy, on the felt, and it plays.** *(2026-07-14. The engine landed in
`aca523e`; this is the half you can see. Driven in a browser, and that is where
both bugs were.)*
- **It is one switch, not a fourth table.** The betting panel grows a two-way
segmented control (UNO / No Mercy) that swaps the three tier cards for the other
three. The table size stays the tier — it is what you're paid for — and the deck
is the other dial. A settled game leaves the panel on the table you just played,
because the commonest thing anybody wants after a hand of No Mercy is another one.
- **The six faces the normal box doesn't print.** `FACES` in `uno.js` is one table
of {middle mark, corner mark, size}: the long ones ("DISCARD ALL", "SKIP ALL")
wrap across the tilted oval and carry a short mark for the corners, exactly as a
real deck does. Sized by a *font class*, never a box — `--uno-h`/`--uno-w` remain
the only thing that decides how big a card is, which is the rule the phone bug
taught this table.
- **The wild draws glow** (the user asked, and it turned out to be load-bearing).
A rainbow aura behind the card plus a slow shine across it, on any wild that
makes somebody draw. It is the one thing that tells the *wild* +4 apart from No
Mercy's **coloured** +4 sitting next to it in the same hand. It is a sibling
behind the card, not a shadow on it, because a box-shadow there would fight the
one that says which colour a played wild was named as.
- **The stack says what the bill is, in three places**: a red pill on the felt, the
turn line ("+6 — stack it, or take it"), and the button itself ("Take 6"). Under
a stack the hand only lights the cards that answer it, the deck is dead (you
cannot draw your way out of a bill), and the hint line changes to say so.
- **A buried seat is not an empty one.** This is the whole trap of the mercy rule
in the view layer: a seat killed at twenty-five holds zero cards, and so does a
seat that just went out and *won*. The view asks the engine (`State.Live`) rather
than inferring from a count of zero, so the winner is never the corpse.
`TestABuriedSeatIsNotTheWinner` is that, and it also covers the ending only this
deck has — you can win by **outliving** the table, with a hand still in front of
you ("Last one standing!"), and you can lose without anybody going out at all.
- **Two bugs, and a browser found both.**
1. **The bill was writing into the chip bar.** The felt's pending pill was
`[data-pending]` — and so is the chip bar's "chips still in flight from a
buy-in" readout, and the chip bar lives *inside* `[data-uno]` and comes first
in the document. So `root.querySelector` found the wrong one: a stack quietly
overwrote the escrow message and never appeared on the felt at all. It is
`[data-bill]` now. **A table's own attributes are not a private namespace —
the chip bar is in every one of these roots.**
2. **Hold'em let you click a button that did nothing** (found while re-driving
poker, below). `send()` drops a click that arrives while a move is in flight,
which is right — the board on screen during a script is a board the server has
already moved past — but the *between-hands* buttons (Deal, Leave, Top up)
stayed enabled through the whole deal animation. Clicking Leave while the
cards were still flying did nothing: no move, no message, no reason. The lock
lives on the buttons now (`lock()`), not only in the `busy` variable.
- **Driven in a browser, 2026-07-14.** A Full House game stacked a +6 onto us and
the felt said so; taking it worked; a bot went to twenty-five and the seat went
grey and said "Buried"; a Duel was won by outliving the table and paid 1,950 on a
1,000 stake (2.0× is 1,000 of winnings, less the 5% rake — the quote and the
payout agreed, as `Pays()` requires). A 21-card hand at 390px wraps to five rows
with every card painted and no sideways scroll. The normal deck still plays and
still pays (a Table win on 1,200 paid 3,822). Console silent.
- **Hold'em, re-driven on the 20M-hand policy.** *(2026-07-14, and it closes Phase
4.)* The policy is a data file, so the check was whether the bots still play a real
game off it and the money still conserves: sat down for 100 at the 1/2 table, six
hands (won a pot with a straight, lost one at showdown, folded the rest), got up
with 61, and 4,900 + 61 came back as 4,961 — conserved to the chip. The re-drive is
also what turned up the dead-button bug above, which is the argument for the rule:
**re-drive after a policy change even though "only data" moved.**
- **Hold'em, and it is a cash game.** *(2026-07-14. Built, tested, and driven in a
browser. The bots had to be retrained from scratch — see below, it is the whole
story of this phase.)*
- **You buy in, you play, you leave with what's in front of you.** This is the
only table in the casino that is a *session* rather than a game. Everywhere else
stakes once and pays a multiple; poker isn't that shape. So the live row lives
across hands, and chips cross the border exactly twice: once when you sit down
and once when you get up. In between every pot is inside the engine and storage
sees none of it. Three stakes (1/2, 5/10, 25/50), buy in for 20100 big blinds,
top up between hands, bust and the session simply ends.
- **The rake is a real cardroom's rake**: five percent of a pot that *sees a flop*,
capped at three big blinds. No flop, no drop — so folding your blind round after
round costs you the blinds and no fee. Still winnings-only in the sense that
matters: you pay it out of a pot you win, never out of a bet you lose.
- **The bots move inside ApplyMove**, as UNO's do, which is what keeps poker off a
socket. One request plays your action, every bot action behind it, and whatever
streets that finishes — so shoving all-in returns the flop, turn, river, showdown
and payout in a single response, as a script the felt plays back.
- **The CFR policy was a lie, twice over, and this is the part worth reading.**
§5 called it "the single highest-value asset in either repository". It was not
being used *at all*, and could not have been:
1. **The key never matched.** The trainer packed a single "am I last to act" bit
and wrote its keys as `IP`/`OOP`. The runtime looked them up with the labels a
player would recognise — `BTN`, `SB`, `BB`, `UTG`. Not one key ever hit, for
the entire life of the game in gogobee. Nothing looked broken: a policy miss is
not an error, it is a silent fall back to a pot-odds heuristic. The bots played;
they just never once read the five million iterations sitting in policy.gob.
2. **And it was the wrong game anyway.** `TrainCFR` opens with `stack0, stack1 = 20,
20` at 1/2 blinds — a **ten big blind** push-fold stack. 82% of its nodes are in
the "stack smaller than the pot" bucket. A 20100BB cash game lands almost
nowhere near it. Worse, the tree it trained on was not hold'em: a call always
ended the street (so no big-blind option and no check-check), turn order was
history-length parity, and the payoff was ±half the pot regardless of who had
put what in.
- **So the trainer was rewritten to play the real engine.** `internal/games/holdem/
train.go` + `cmd/holdem-train`. External-sampling MCCFR, every move applied through
`Step` — the same reducer the felt calls — so the blinds, the min-raise, street
completion, side pots and the money are the ones a player actually meets. The
stack depth is drawn fresh every hand across the whole 20100BB range, because
poker at twenty big blinds and poker at a hundred are different games and a bot
that only knows one folds into the other.
- **The key is built by one function, `State.spot`, called by both the trainer and
the table.** That is the entire fix for bug (1), and it is structural: they cannot
drift apart because there is only one of them. And because a miss is *still* silent,
`Hits`/`Misses` are counted at the point a bot looks itself up, and
`TestTheBotsAreActuallyTrained` fails if the heads-up hit rate drops under 60%. It
is 95%. Multiway degrades on purpose — the policy is heads-up, six-handed reuses it
as a documented approximation, and the rest falls through to pot odds.
- **Three money bugs, and the tests earned their keep.** `TestChipsAreConserved`
plays a hundred sessions of real hands and counts every chip after every move; it
caught an **uncalled bet that minted chips** (the rule skipped folded players when
working out what had been matched, so a river bet folded to came back *whole*,
including the part called on the flop). A Go var-init ordering trap made `deck52`
build from an empty conversion table, so every card was identical, every showdown
tied and **every bot believed it held exactly 50% equity** — package-level vars are
built before `init()` runs. And the browser found the third: **the rake was
silently zero**, because the tiers declared `RakePct: 5` meaning percent while
`New()` overwrites it with blackjack's `0.05` meaning a fraction, and the integer
arithmetic floored 5% of a hundredth of the pot to nothing. Every rake test built
its own `State` by hand and so never saw the number the table runs on. There is now
one that does.
- **Driven in a browser, 2026-07-14, and it holds up.** Sitting down took 500 off a
5,000 stack and put it on the table; a hand played out; getting up put 1,004 back
(money conserved to the chip). The raise slider, the pot/half-pot/max presets, a
shove that runs the whole board out in one response, and a reload mid-hand that
brings back the hand, the board, the pot, the street and the stack — all clean.
**Side pots and split pots balance**: a three-way all-in paid 758 + 757 + 920 out
of a 2,495 pot with 60 raked, and `paid + rake == pot` on every multi-winner hand
sampled. A bot's cards never cross the wire until a showdown, and a folded seat's
never do at all. Six-handed at 390px: no sideways overflow, nothing colliding.
Console silent.
- Two felt bugs only the browser could show. **`.pete-stack` is `position: absolute;
inset: 0`** — so the pot's chips, sharing a box with the number under them, painted
straight over it: the pot showed a chip and no total. The pile needs a box of its
own. And **a `.pete-spot` is 7rem across** because blackjack has exactly one of
them; six of those is most of a felt, so a seat's bet spot is less than half the
size, with chips scaled to match. The bet total hangs *below* the ring
(`.pete-spot-total`), which is the existing rule for exactly this reason.
### Next, in order
1. **Phase 4hold'em**, as below. It is the last game on the list, and the first
one where a hand has other people in it: the spot is a singleton and the chip
animations are tuned for one seat (see "still open" below).
2. **Deploy.** Hangman, solitaire, trivia and UNO are all played, and all four are
still sitting on main un-deployed — the live casino is blackjack and nothing else.
The server runs `StartTriviaBank`, so trivia's bank fills itself once the binary
is out there, but the first player to try a ladder in the first minute after a
deploy gets the 503.
1. **Phase Bthe table runtime** (see "The multiplayer build" above). Phase A is
done; nothing is half-built.
2. Then C (hold'em), D (UNO), E (blackjack).
3. The open list, none of it blocking, none of it promised: **hold'em has no
multiway policy** (and multiplayer makes that matter more, not less — a
six-handed table of humans is not the heads-up game the policy was trained on);
the trivia bank refills on a 12h tick (`games: trivia bank refill started
target=400`), so a player trying a ladder in the first minute after a fresh
deploy can still meet a 503.
Still open on the table itself, none of it blocking: **split** isn't implemented (the
engine has no move for it), the felt is roomy at desktop widths with only one seat on
it, and the chip animations are tuned for one player — a second seat would need the
spot to be per-seat rather than the singleton it is now.
Still open on hold'em: the policy is **heads-up**, so a six-handed table is an
approximation of it (the hit rate falls from 95% to about 17% at six seats, and
the rest is pot odds) — a multiway policy would want its own training run with
more than two seats in the tree. Blackjack's split is **done**.
### How the browser half fits together
@@ -592,20 +1009,31 @@ into `pete/internal/games/`, let them drift, no shared module.
| Game | Copy | Rewrite | Notes |
|---|---|---|---|
| **Hold'em** | ~2,700 LOC | the shell | The crown jewel. Take it all. |
| **Hold'em** | ~1,400 LOC | the shell + **the whole CFR trainer** | See the warning below. |
| **UNO** | ~1,400 LOC | the turn engine | Great primitives, unshippable engine. |
| **Hangman** | ~250 LOC | loading/persistence | Clean rune-safe state machine. |
| **Blackjack** | ~95 LOC | everything else | 95 lines is the entire core. |
| **Trivia** | ~80 LOC | everything else | **No question bank exists.** |
### Hold'em — take almost all of it
### Hold'em — take the poker, not the brain
> **This section was wrong, and it cost most of Phase 4. Read §0's hold'em entry before
> you believe any of it.** `data/policy.gob` is **not** "the single highest-value asset in
> either repo". It is a 10-big-blind push-fold policy, trained against a model of poker
> that is not poker (a call always ends the street), and *it was never once read* — the
> trainer wrote its keys under `IP`/`OOP` and the runtime looked them up under
> `BTN`/`SB`/`BB`, so every lookup in the history of the game missed and fell through to a
> pot-odds heuristic. Nothing about that is visible from the outside: a policy miss is not
> an error. **Retrain against your own engine.** Pete now does — `internal/games/holdem/
> train.go` plays every move through the real reducer, and both the trainer and the table
> build the info-set key with the same function so they cannot drift apart again.
Already mautrix-free, verified by import check:
- `holdem_cfr.go` (1,285) — full CFR trainer + NPC policy runtime, info-set packing into a
`uint64`, regret pruning, board-texture/SPR/equity bucketing. Plus the trained
`data/policy.gob` (3.4 MB) and `cmd/holdem-train`, `cmd/holdem-seed`. **This is the single
highest-value asset in either repo.**
- `holdem_cfr.go` (1,285) — CFR trainer + NPC policy runtime, info-set packing into a
`uint64`, regret pruning, board-texture/SPR/equity bucketing. The *bucketing* is worth
taking (equity, SPR, board texture). The trainer, the tree and the trained policy are
not: see the warning above.
- `holdem_equity.go` + `holdem_equity_range.go` (548) — Monte-Carlo equity, equity-vs-range,
draw/out detection. 100% pure, well tested.
- `holdem_betting.go` (383) — side pots, min-raise, all-in, street completion. The fiddly