38 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

You couldn't call UNO, and not because the button was missing: going
down to one card *was* the call. discard() fired the uno event by
itself, which made it a thing that happened to you rather than a thing
you did, and a rule nobody can fail is not a rule. So now you say it or
you don't (Move.Uno), and if you don't, every bot still in the game gets
one look at you before any of them plays — because a bot that has moved
on is a bot that has stopped watching your hand. It runs the other way
too, and that half is the fun one: a bot forgets often enough to be
worth watching for, and when it does it says *nothing*. No event, no
badge, no tell on the felt except the count beside its fan reading
"1 card". Catch it and it takes two; call a seat that had nothing to
hide and you take two yourself, which is what stops the catch button
from being a thing you simply mash.

Which cards owe the call is the engine's answer, not a count of your
hand: No Mercy's "discard all" takes every card of its colour with it,
so a six-card hand can land on one, and a browser subtracting one from
six walks you into a catch it never warned you about.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things worth knowing.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two bugs only the browser could show:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Driven in a browser, win and loss: 200 at 2.34x paid 455 and the bar landed on
it; six wrong took the stake and no more; a reload mid-phrase brought back the
board, the limbs, the multiple, the spent keys and the chips on the spot. The
browser found the two bugs a Go test can't — a lives counter under the house
rack, and a word wrapping early because the rack's clearance was on the whole
column instead of the one row beside it.
2026-07-14 01:19:05 -07:00
108 changed files with 29184 additions and 580 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,39 +274,125 @@ 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
if err := s.hit(i+1, &evs); err != nil {
return s, nil, err
}
// Split aces get one card each and no say in it. Without this rule a pair
// of aces is the best hand in the game and everybody would split them
// forever; with it, splitting aces is a gamble like everything else.
if moved.Rank == cards.Ace {
s.Hands[i].Done = true
s.Hands[i+1].Done = true
}
// A hand that has just been dealt a card can still be sitting on 21, and a
// 21 has nothing left to decide.
s.finishIfDone(i)
s.finishIfDone(i + 1)
case Double:
h := &s.Hands[i]
s.Bet += h.Bet
h.Bet *= 2
h.Doubled = true
// Announced before the card, because that is the order it happens in: the
// chips go down, and *then* you find out what you bought with them.
evs = append(evs, Event{Kind: "double", Hand: i})
if err := s.hit(i, &evs); err != nil {
return s, nil, err
}
h.Done = true // one card, and that is the deal you made
case Hit:
if err := s.hit(i, &evs); err != nil {
return s, nil, err
}
s.finishIfDone(i)
case Stand:
s.Hands[i].Done = true
}
s.advance(&evs)
return s, evs, nil
}
if m == Hit {
return s, evs, nil // still the player's turn
// 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
}
}
// Stand, or a double that survived its card: the dealer draws out.
// 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)
return s, evs, nil
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
@@ -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
s.Payout, s.Rake = 0, 0
for i := range s.Hands {
h := &s.Hands[i]
playerVal, _ := h.Value()
// profit is what this hand wins on top of its stake. Negative means the
// stake is gone.
var profit int64
switch {
case playerVal > 21:
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))
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:
s.Outcome = OutcomeLose
profit = -s.Bet
h.Outcome = OutcomeLose
profit = -h.Bet
case dealerVal > 21:
s.Outcome = OutcomeDealerBust
profit = s.Bet
h.Outcome = OutcomeDealerBust
profit = h.Bet
case playerVal > dealerVal:
s.Outcome = OutcomeWin
profit = s.Bet
h.Outcome = OutcomeWin
profit = h.Bet
case playerVal == dealerVal:
s.Outcome = OutcomePush
h.Outcome = OutcomePush
default:
s.Outcome = OutcomeLose
profit = -s.Bet
h.Outcome = OutcomeLose
profit = -h.Bet
}
if profit > 0 {
s.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct))
if s.Rake < 0 {
s.Rake = 0
h.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct))
if h.Rake < 0 {
h.Rake = 0
}
profit -= s.Rake
profit -= h.Rake
}
if profit < 0 {
s.Payout = 0 // stake is lost; nothing comes back
h.Payout = 0 // stake is lost; nothing comes back
} else {
s.Payout = s.Bet + profit
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,400 @@
// Package hangman is a pure hangman engine, played for chips.
//
// Same seam as blackjack: ApplyMove(state, move) (state, events, error), where
// an error means the move was illegal and nothing else. No HTTP, no timers, no
// player names. The state is a plain value, so a game survives a redeploy and
// replays from its seed.
//
// The casino version differs from the one gogobee plays in Matrix in one way
// that matters: there is money on it. That makes the gallows a payout meter as
// well as a death clock — every wrong guess takes a tenth off what a win is
// worth. You can still win from five wrong, you just won't win much.
package hangman
import (
"errors"
"math"
"math/rand/v2"
"strings"
"unicode"
)
// Errors an illegal move can produce.
var (
ErrGameOver = errors.New("hangman: the game is already over")
ErrNotALetter = errors.New("hangman: that is not a letter")
ErrAlreadyTried = errors.New("hangman: that letter has been tried")
ErrUnknownMove = errors.New("hangman: unknown move")
ErrBadBet = errors.New("hangman: bet must be positive")
ErrEmptySolution = errors.New("hangman: guess something")
ErrUnknownTier = errors.New("hangman: no such tier")
)
// MaxWrong is how many wrong guesses the gallows holds: head, body, two arms,
// two legs. It is not a tier setting — the drawing has six parts, so the game
// has six lives, and the tiers vary what a win pays instead.
const MaxWrong = 6
// Decay is what one wrong guess costs, as a fraction of the tier's base
// multiple. Six wrong is death, so the worst a living player can be is 50% off.
const Decay = 0.10
// Tier is a difficulty, chosen before the bet. Short phrases pay the most:
// there is less of them to read, and fewer distinct letters to hit by accident.
// A long phrase mostly reveals itself.
type Tier struct {
Slug string `json:"slug"`
Name string `json:"name"`
Min int `json:"min"` // phrase length, in characters
Max int `json:"max"` // inclusive
Base float64 `json:"base"` // what a win pays, before any wrong guesses
Blurb string `json:"blurb"`
}
// Tiers are the three tables. The bank has 74 short phrases, 67 medium and 64
// long, so none of them runs thin.
var Tiers = []Tier{
{Slug: "short", Name: "Short", Min: 8, Max: 20, Base: 2.6,
Blurb: "A handful of letters and nothing to go on."},
{Slug: "medium", Name: "Medium", Min: 21, Max: 40, Base: 2.0,
Blurb: "Long enough to read once it starts falling open."},
{Slug: "long", Name: "Long", Min: 41, Max: 9999, Base: 1.6,
Blurb: "It gives itself away. It also pays the least."},
}
// TierBySlug finds a tier by the name the browser sent.
func TierBySlug(slug string) (Tier, error) {
for _, t := range Tiers {
if t.Slug == slug {
return t, nil
}
}
return Tier{}, ErrUnknownTier
}
// Phase is where the game is.
type Phase string
const (
PhasePlaying Phase = "playing"
PhaseDone Phase = "done"
)
// Outcome is how it ended.
type Outcome string
const (
OutcomeNone Outcome = ""
OutcomeSolved Outcome = "solved" // guessed the phrase outright
OutcomeFilled Outcome = "filled" // revealed the last letter
OutcomeHung Outcome = "hung" // six wrong
)
// Won reports whether this outcome pays.
func (o Outcome) Won() bool { return o == OutcomeSolved || o == OutcomeFilled }
// State is one game. The phrase is in here, which is exactly why this value
// never leaves the server — the browser gets a Masked() view of it instead.
type State struct {
Tier Tier `json:"tier"`
Phrase string `json:"phrase"`
Runes []rune `json:"runes"` // the phrase, indexable safely
Shown []bool `json:"shown"` // one per rune: is it face up
Tried []rune `json:"tried"` // every letter guessed, in order, right or wrong
Wrong []rune `json:"wrong"` // just the ones that missed
RakePct float64 `json:"rake_pct"`
Bet int64 `json:"bet"`
Phase Phase `json:"phase"`
Outcome Outcome `json:"outcome"`
Payout int64 `json:"payout"`
Rake int64 `json:"rake"`
}
// Event is something the table animates: a letter turning over, a limb being
// drawn. The engine emits them rather than drawing anything itself.
type Event struct {
Kind string `json:"kind"` // "start" | "hit" | "miss" | "solve" | "settle"
Letter string `json:"letter,omitempty"` // the letter guessed
At []int `json:"at,omitempty"` // which rune positions it turned over
Text string `json:"text,omitempty"`
}
// Move is a player action: a single letter, or the whole phrase.
type Move struct {
Letter string `json:"letter"`
Solve string `json:"solve"`
}
// New starts a game on a phrase drawn from the tier's shelf of the bank.
func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
if bet <= 0 {
return State{}, nil, ErrBadBet
}
phrase, err := drawPhrase(t, rng)
if err != nil {
return State{}, nil, err
}
return start(bet, t, phrase, rakePct)
}
// start builds the opening state for a known phrase. Split out from New so a
// test can pin the phrase instead of the seed.
func start(bet int64, t Tier, phrase string, rakePct float64) (State, []Event, error) {
if bet <= 0 {
return State{}, nil, ErrBadBet
}
rs := []rune(phrase)
s := State{
Tier: t, Phrase: phrase, Runes: rs,
Shown: make([]bool, len(rs)),
RakePct: rakePct,
Bet: bet, Phase: PhasePlaying,
}
// Spaces and punctuation are never guessed — they start face up, because a
// row of blanks with the word breaks hidden is a puzzle about typography.
for i, r := range rs {
if !Guessable(r) {
s.Shown[i] = true
}
}
return s, []Event{{Kind: "start"}}, nil
}
// Guessable reports whether a rune is one you'd guess: a letter or a digit.
// Everything else is scaffolding and is shown from the start.
//
// Exported because the renderer needs the same answer — a rune you'd guess is a
// rune that gets a tile to guess it into. Asking the drawing side to decide that
// for itself is how you get a board with no tile for a letter the engine is
// waiting on, and a phrase that cannot be finished.
func Guessable(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
// ApplyMove is the engine. A legal move in, the new state and what happened out.
// An error means the move was illegal and the caller's state is untouched.
func ApplyMove(s State, m Move) (State, []Event, error) {
if s.Phase == PhaseDone {
return s, nil, ErrGameOver
}
s = s.clone()
switch {
case m.Solve != "":
return applySolve(s, m.Solve)
case m.Letter != "":
return applyLetter(s, m.Letter)
default:
return s, nil, ErrUnknownMove
}
}
// applyLetter guesses one letter.
func applyLetter(s State, letter string) (State, []Event, error) {
rs := []rune(strings.ToLower(strings.TrimSpace(letter)))
if len(rs) != 1 || !Guessable(rs[0]) {
return s, nil, ErrNotALetter
}
g := rs[0]
if containsRune(s.Tried, g) {
// Not a miss — a no-op. Charging a life for a letter the board already
// shows you tried is punishing a mis-click, not a bad guess.
return s, nil, ErrAlreadyTried
}
s.Tried = append(s.Tried, g)
var at []int
for i, r := range s.Runes {
if !s.Shown[i] && foldEq(r, g) {
s.Shown[i] = true
at = append(at, i)
}
}
evs := []Event{}
if len(at) > 0 {
evs = append(evs, Event{Kind: "hit", Letter: string(g), At: at})
if s.filled() {
s.settle(OutcomeFilled, &evs)
}
return s, evs, nil
}
s.Wrong = append(s.Wrong, g)
evs = append(evs, Event{Kind: "miss", Letter: string(g)})
if len(s.Wrong) >= MaxWrong {
s.settle(OutcomeHung, &evs)
}
return s, evs, nil
}
// applySolve guesses the whole phrase. Right, and it's over at the multiple you
// still hold. Wrong, and it costs a life like any other bad guess — otherwise
// solving would be a free roll you could spam until it landed.
func applySolve(s State, attempt string) (State, []Event, error) {
if strings.TrimSpace(attempt) == "" {
return s, nil, ErrEmptySolution
}
evs := []Event{{Kind: "solve", Text: attempt}}
if normalize(attempt) == normalize(s.Phrase) {
for i := range s.Shown {
s.Shown[i] = true
}
s.settle(OutcomeSolved, &evs)
return s, evs, nil
}
// A wrong solve is a miss with no letter attached to it.
s.Wrong = append(s.Wrong, '·')
evs = append(evs, Event{Kind: "miss", Text: attempt})
if len(s.Wrong) >= MaxWrong {
s.settle(OutcomeHung, &evs)
}
return s, evs, nil
}
// Multiple is what a win is worth right now: the tier's base, less a tenth of
// it for every wrong guess so far. Floored at 1, so a win never hands back less
// than the stake — a player who fought through five wrong guesses and got there
// has not earned a loss.
func (s State) Multiple() float64 {
m := s.Tier.Base * (1 - Decay*float64(len(s.Wrong)))
if m < 1 {
return 1
}
return m
}
// Pays is what a win *right now* would actually put back on the player's stack:
// the stake, plus the winnings, less the house's cut of the winnings.
//
// It exists because the felt shows this number while the game is still running,
// and settle() is the only other thing that computes it. If the two ever
// disagreed the table would be quoting a payout it doesn't honour — so settle
// calls this rather than doing the sum a second time.
func (s State) Pays() int64 {
total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
if total < s.Bet {
total = s.Bet // a win never hands back less than the stake
}
profit := total - s.Bet
if profit > 0 {
rake := int64(math.Floor(float64(profit) * s.RakePct))
if rake < 0 {
rake = 0
}
profit -= rake
}
return s.Bet + profit
}
// Rake taken on a win right now — the other half of what Pays works out.
func (s State) rakeNow() int64 {
total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
if total < s.Bet {
return 0
}
profit := total - s.Bet
if profit <= 0 {
return 0
}
rake := int64(math.Floor(float64(profit) * s.RakePct))
if rake < 0 {
return 0
}
return rake
}
// Lives is how many wrong guesses are left.
func (s State) Lives() int { return MaxWrong - len(s.Wrong) }
// filled reports whether every guessable rune is face up.
func (s State) filled() bool {
for _, up := range s.Shown {
if !up {
return false
}
}
return true
}
// settle decides the payout. Same rule as blackjack: the rake comes out of
// winnings, never out of the stake. A loss is never charged a fee.
func (s *State) settle(o Outcome, evs *[]Event) {
s.Outcome = o
s.Phase = PhaseDone
if o.Won() {
s.Payout = s.Pays()
s.Rake = s.rakeNow()
} else {
s.Payout = 0
}
// The phrase goes face up when it's over, win or lose. Losing without ever
// being told the answer is the one thing hangman must never do.
for i := range s.Shown {
s.Shown[i] = true
}
*evs = append(*evs, Event{Kind: "settle", Text: string(o)})
}
// Net is what the game did to the player's stack.
func (s State) Net() int64 {
if s.Phase != PhaseDone {
return 0
}
return s.Payout - s.Bet
}
// Masked is the phrase as the player may see it: revealed runes as themselves,
// hidden ones as an underscore. This — not Phrase — is what crosses the wire.
func (s State) Masked() string {
var b strings.Builder
for i, r := range s.Runes {
if s.Shown[i] {
b.WriteRune(r)
} else {
b.WriteRune('_')
}
}
return b.String()
}
// clone deep-copies the slices, so a derived state shares no backing array with
// the one it came from and a state can be replayed freely.
func (s State) clone() State {
s.Runes = append([]rune(nil), s.Runes...)
s.Shown = append([]bool(nil), s.Shown...)
s.Tried = append([]rune(nil), s.Tried...)
s.Wrong = append([]rune(nil), s.Wrong...)
return s
}
// normalize flattens a phrase for comparison: case, spacing and punctuation all
// stop mattering. "How are you gentlemen!!" is solved by "how are you gentlemen".
func normalize(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
if Guessable(r) {
b.WriteRune(r)
}
}
return b.String()
}
// foldEq compares two runes the way a guess should: case-insensitively.
func foldEq(a, b rune) bool {
return unicode.ToLower(a) == unicode.ToLower(b)
}
func containsRune(rs []rune, r rune) bool {
for _, x := range rs {
if foldEq(x, r) {
return true
}
}
return false
}

View File

@@ -0,0 +1,369 @@
package hangman
import (
"encoding/json"
"math/rand/v2"
"strings"
"testing"
)
// tierShort is the tier most tests play on: base 2.6.
func tierShort(t *testing.T) Tier {
t.Helper()
tr, err := TierBySlug("short")
if err != nil {
t.Fatal(err)
}
return tr
}
// play runs a sequence of single-letter guesses against a pinned phrase.
func play(t *testing.T, phrase string, bet int64, rake float64, guesses ...string) State {
t.Helper()
s, _, err := start(bet, tierShort(t), phrase, rake)
if err != nil {
t.Fatal(err)
}
for _, g := range guesses {
next, _, err := ApplyMove(s, Move{Letter: g})
if err != nil {
t.Fatalf("guess %q: %v", g, err)
}
s = next
}
return s
}
func TestStartShowsScaffoldingOnly(t *testing.T) {
s, evs, err := start(100, tierShort(t), "Insert Coin", 0.05)
if err != nil {
t.Fatal(err)
}
if got, want := s.Masked(), "______ ____"; got != want {
t.Errorf("masked = %q, want %q — the space is scaffolding and shows, the letters don't", got, want)
}
if len(evs) != 1 || evs[0].Kind != "start" {
t.Errorf("events = %+v, want one start", evs)
}
if s.Lives() != MaxWrong {
t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong)
}
}
func TestPunctuationAndDigitsAreScaffoldingOrNot(t *testing.T) {
// Punctuation shows from the start; a digit is guessable like a letter.
s, _, err := start(100, tierShort(t), "Level 9!", 0.05)
if err != nil {
t.Fatal(err)
}
if got, want := s.Masked(), "_____ _!"; got != want {
t.Fatalf("masked = %q, want %q", got, want)
}
next, _, err := ApplyMove(s, Move{Letter: "9"})
if err != nil {
t.Fatalf("guessing a digit: %v", err)
}
if got, want := next.Masked(), "_____ 9!"; got != want {
t.Errorf("masked = %q, want %q — a digit is a guess", got, want)
}
}
func TestHitRevealsEveryOccurrence(t *testing.T) {
// "Blue Shell" has an l at 1, 8 and 9. One guess turns over all three.
s := play(t, "Blue Shell", 100, 0.05, "l")
if got, want := s.Masked(), "_l__ ___ll"; got != want {
t.Errorf("masked = %q, want %q — every l turns over, not just the first", got, want)
}
if s.Lives() != MaxWrong {
t.Errorf("a hit cost a life: lives = %d", s.Lives())
}
}
func TestMissCostsALifeAndTheMultiple(t *testing.T) {
tr := tierShort(t)
s := play(t, "Insert Coin", 100, 0.05, "z")
if s.Lives() != MaxWrong-1 {
t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong-1)
}
want := tr.Base * 0.9
if got := s.Multiple(); got != want {
t.Errorf("multiple = %v, want %v — one wrong guess is a tenth of the base", got, want)
}
}
func TestRepeatedLetterIsRefusedNotPunished(t *testing.T) {
s := play(t, "Insert Coin", 100, 0.05, "z")
_, _, err := ApplyMove(s, Move{Letter: "z"})
if err != ErrAlreadyTried {
t.Fatalf("err = %v, want ErrAlreadyTried", err)
}
// And the state the caller holds is untouched: a mis-click is not a life.
if s.Lives() != MaxWrong-1 {
t.Errorf("the refused move moved the state: lives = %d", s.Lives())
}
}
func TestSixWrongHangsYouAndPaysNothing(t *testing.T) {
s := play(t, "Insert Coin", 100, 0.05, "z", "x", "q", "y", "w", "k")
if s.Phase != PhaseDone || s.Outcome != OutcomeHung {
t.Fatalf("phase/outcome = %s/%s, want done/hung", s.Phase, s.Outcome)
}
if s.Payout != 0 {
t.Errorf("payout = %d, want 0", s.Payout)
}
if s.Net() != -100 {
t.Errorf("net = %d, want -100", s.Net())
}
if strings.Contains(s.Masked(), "_") {
t.Error("a lost game must still show the phrase — being hung without being told the answer is the one thing hangman can't do")
}
}
func TestFillingTheLastLetterWinsCleanAtFullMultiple(t *testing.T) {
// "Blue Shell" — every distinct letter, no misses.
s := play(t, "Blue Shell", 100, 0, "b", "l", "u", "e", "s", "h")
if s.Outcome != OutcomeFilled {
t.Fatalf("outcome = %s, want filled", s.Outcome)
}
// Base 2.6, no wrong guesses, no rake: 100 -> 260.
if s.Payout != 260 {
t.Errorf("payout = %d, want 260", s.Payout)
}
if s.Net() != 160 {
t.Errorf("net = %d, want 160", s.Net())
}
}
func TestSolveOutrightWinsAtTheMultipleYouStillHold(t *testing.T) {
s, _, err := start(100, tierShort(t), "Insert Coin", 0)
if err != nil {
t.Fatal(err)
}
// Two wrong first: 2.6 -> 2.08.
for _, g := range []string{"z", "x"} {
s, _, err = ApplyMove(s, Move{Letter: g})
if err != nil {
t.Fatal(err)
}
}
s, _, err = ApplyMove(s, Move{Solve: "insert coin"})
if err != nil {
t.Fatal(err)
}
if s.Outcome != OutcomeSolved {
t.Fatalf("outcome = %s, want solved", s.Outcome)
}
if s.Payout != 208 {
t.Errorf("payout = %d, want 208 — solving pays the multiple you still hold, not the base", s.Payout)
}
}
func TestSolveIgnoresCaseAndPunctuation(t *testing.T) {
s, _, err := start(100, tierShort(t), "How are you gentlemen!!", 0)
if err != nil {
t.Fatal(err)
}
s, _, err = ApplyMove(s, Move{Solve: "HOW ARE YOU GENTLEMEN"})
if err != nil {
t.Fatal(err)
}
if s.Outcome != OutcomeSolved {
t.Errorf("outcome = %s — a solve shouldn't turn on shouting or the exclamation marks", s.Outcome)
}
}
func TestWrongSolveCostsALife(t *testing.T) {
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
if err != nil {
t.Fatal(err)
}
s, _, err = ApplyMove(s, Move{Solve: "insert quarter"})
if err != nil {
t.Fatal(err)
}
if s.Lives() != MaxWrong-1 {
t.Errorf("lives = %d, want %d — a free solve is a solve you spam until it lands", s.Lives(), MaxWrong-1)
}
if s.Phase != PhasePlaying {
t.Errorf("phase = %s, want playing", s.Phase)
}
}
func TestAWinNeverReturnsLessThanTheStake(t *testing.T) {
// Long pays 1.6, and five wrong guesses would take it to 0.8 — under water.
long, err := TierBySlug("long")
if err != nil {
t.Fatal(err)
}
s, _, err := start(100, long, "the quick brown fox jumps over the lazy dog and keeps going", 0.05)
if err != nil {
t.Fatal(err)
}
// Five clean misses. Every letter in the pangram is in the pangram, so the
// only things guaranteed to miss it are digits.
for _, g := range []string{"1", "2", "3", "4", "5"} {
s, _, err = ApplyMove(s, Move{Letter: g})
if err != nil {
t.Fatal(err)
}
}
if got := s.Multiple(); got != 1 {
t.Fatalf("multiple = %v, want 1 — the floor", got)
}
s, _, err = ApplyMove(s, Move{Solve: "the quick brown fox jumps over the lazy dog and keeps going"})
if err != nil {
t.Fatal(err)
}
if s.Payout != 100 {
t.Errorf("payout = %d, want 100 — a win hands back the stake at worst, never less", s.Payout)
}
if s.Rake != 0 {
t.Errorf("rake = %d, want 0 — there was no profit to take a cut of", s.Rake)
}
}
func TestRakeComesOutOfWinningsNeverTheStake(t *testing.T) {
s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h")
// Total 260, profit 160, rake 5% = 8, so 252 comes back.
if s.Rake != 8 {
t.Errorf("rake = %d, want 8 (5%% of the 160 profit)", s.Rake)
}
if s.Payout != 252 {
t.Errorf("payout = %d, want 252", s.Payout)
}
if s.Payout < s.Bet {
t.Error("the rake ate into the stake")
}
}
func TestWhatTheFeltQuotesIsWhatTheHousePays(t *testing.T) {
// The table shows Pays() while the game is still running. If that number and
// the one settle() lands on ever came apart, the felt would be advertising a
// payout the house doesn't honour. Walk a game and check they agree at every
// step — including after a miss, which is where they'd drift.
for _, wrong := range []int{0, 1, 2, 3} {
s, _, err := start(200, tierShort(t), "Blue Shell", 0.05)
if err != nil {
t.Fatal(err)
}
misses := []string{"z", "x", "q", "y"}
for i := 0; i < wrong; i++ {
s, _, err = ApplyMove(s, Move{Letter: misses[i]})
if err != nil {
t.Fatal(err)
}
}
quoted := s.Pays() // what the felt is telling the player right now
s, _, err = ApplyMove(s, Move{Solve: "blue shell"})
if err != nil {
t.Fatal(err)
}
if s.Payout != quoted {
t.Errorf("%d wrong: felt quoted %d, house paid %d", wrong, quoted, s.Payout)
}
if s.Payout != s.Bet+s.Net() {
t.Errorf("%d wrong: payout %d doesn't square with net %d on a %d bet", wrong, s.Payout, s.Net(), s.Bet)
}
}
}
func TestMoveOnAFinishedGameIsRefused(t *testing.T) {
s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h")
if _, _, err := ApplyMove(s, Move{Letter: "z"}); err != ErrGameOver {
t.Errorf("err = %v, want ErrGameOver", err)
}
}
func TestGarbageGuessesAreRefused(t *testing.T) {
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
if err != nil {
t.Fatal(err)
}
for _, m := range []Move{{Letter: "ab"}, {Letter: "!"}, {Letter: " "}, {}} {
if _, _, err := ApplyMove(s, m); err == nil {
t.Errorf("move %+v was accepted", m)
}
}
}
func TestApplyMoveDoesNotTouchTheCallersState(t *testing.T) {
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
if err != nil {
t.Fatal(err)
}
before := s.Masked()
if _, _, err := ApplyMove(s, Move{Letter: "i"}); err != nil {
t.Fatal(err)
}
if s.Masked() != before {
t.Errorf("applying a move mutated the state it was given: %q -> %q", before, s.Masked())
}
if len(s.Tried) != 0 {
t.Errorf("tried = %v, want empty — the caller's state was written through", s.Tried)
}
}
func TestStateSurvivesASerializationRoundTrip(t *testing.T) {
// A redeploy mid-game is a JSON round-trip. It has to come back playable.
s := play(t, "Insert Coin", 100, 0.05, "i", "z")
blob, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
var back State
if err := json.Unmarshal(blob, &back); err != nil {
t.Fatal(err)
}
if back.Masked() != s.Masked() || back.Lives() != s.Lives() || back.Multiple() != s.Multiple() {
t.Fatalf("round trip changed the game: %+v", back)
}
next, _, err := ApplyMove(back, Move{Solve: "insert coin"})
if err != nil {
t.Fatal(err)
}
if next.Outcome != OutcomeSolved {
t.Errorf("a game restored from JSON couldn't be finished: %s", next.Outcome)
}
}
func TestNewIsReproducibleFromItsSeed(t *testing.T) {
// The seed is in the audit log so a disputed game can be replayed. That is
// only true if the phrase comes back the same.
one, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9)))
if err != nil {
t.Fatal(err)
}
two, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9)))
if err != nil {
t.Fatal(err)
}
if one.Phrase != two.Phrase {
t.Errorf("same seed dealt different phrases: %q vs %q", one.Phrase, two.Phrase)
}
}
func TestNewDrawsFromTheRightShelf(t *testing.T) {
rng := rand.New(rand.NewPCG(1, 2))
for _, tr := range Tiers {
for i := 0; i < 50; i++ {
s, _, err := New(100, tr, 0.05, rng)
if err != nil {
t.Fatalf("%s: %v", tr.Slug, err)
}
if n := len([]rune(s.Phrase)); n < tr.Min || n > tr.Max {
t.Fatalf("%s drew %q (%d chars), outside %d-%d", tr.Slug, s.Phrase, n, tr.Min, tr.Max)
}
}
}
}
func TestEveryTierHasAShelfWorthPlaying(t *testing.T) {
// If someone edits phrases.txt and empties a tier, the game 500s at the
// table rather than here. Catch it here.
for _, tr := range Tiers {
if n := Shelf(tr.Slug); n < 20 {
t.Errorf("tier %s has %d phrases — too few to not repeat", tr.Slug, n)
}
}
}

View File

@@ -0,0 +1,68 @@
package hangman
import (
"bufio"
_ "embed"
"errors"
"math/rand/v2"
"strings"
"sync"
)
// The bank. gogobee kept its phrases in a file it read at boot out of a path in
// an env var, which meant the game was one missing file away from not existing.
// Embedding it means the casino cannot start without its phrases, which is the
// correct relationship between a game and the thing it is about.
//
//go:embed phrases.txt
var phrasesTxt string
// ErrNoPhrases means the bank has nothing at this length. It can only happen if
// someone edits phrases.txt down past a tier, and it is a programming error
// rather than anything a player did — but it's an error, not a panic, because a
// casino that won't boot is worse than one game being shut.
var ErrNoPhrases = errors.New("hangman: no phrases in that tier")
var (
shelvesOnce sync.Once
shelves map[string][]string // tier slug -> the phrases that fit it
)
// load sorts the bank onto one shelf per tier, once. Comments and blank lines
// are dropped, and so is anything too short to be a game — the tiers' own Min
// is the floor.
func load() {
shelves = make(map[string][]string, len(Tiers))
sc := bufio.NewScanner(strings.NewReader(phrasesTxt))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
n := len([]rune(line))
for _, t := range Tiers {
if n >= t.Min && n <= t.Max {
shelves[t.Slug] = append(shelves[t.Slug], line)
break
}
}
}
}
// Shelf is how many phrases a tier has. Exists so a test can assert the bank
// hasn't been edited out from under a tier.
func Shelf(slug string) int {
shelvesOnce.Do(load)
return len(shelves[slug])
}
// drawPhrase picks one phrase from a tier's shelf. The rng is threaded, never
// the package global, so a game replays exactly from the seed in its audit row.
func drawPhrase(t Tier, rng *rand.Rand) (string, error) {
shelvesOnce.Do(load)
shelf := shelves[t.Slug]
if len(shelf) == 0 {
return "", ErrNoPhrases
}
return shelf[rng.IntN(len(shelf))], nil
}

View File

@@ -0,0 +1,237 @@
# GogoBee Hangman Seed Phrases -- Video Game Edition
#
# Tiers are assigned automatically by character count at load time.
# Section headers below are for human readability only -- the bot ignores them.
#
# Easy: 8-20 characters
# Medium: 21-40 characters
# Hard: 41-80 characters
#
# Add community phrases via: !hangman submit [phrase]
# All submissions require LLM approval before entering the pool.
# This file can be edited directly. Bot reloads on restart.
# ---------------------------------------------------------------------------
# EASY (8-20 characters)
# ---------------------------------------------------------------------------
Finish Him!
Game Over
Insert Coin
Hadouken!
Fatality!
Big Boss
Konami Code
Warp Zone
Blue Shell
God Mode
BFG 9000
Cacodemon
Quad Damage
Samus Aran
Morph Ball
Mother Brain
Dracula!
Simon Belmont
Ecclesia
Outer Heaven
Spread Gun
Power Pellet
Checkpoint
Rocket Jump
Mushroom Kingdom
Princess Peach
Bowser's Castle
Fire Flower
Varia Suit
Space Jump
Vampire Killer
Holy Water
Trevor Belmont
Soma Cruz
Julius Belmont
Waluigi!
Richter!
Phantoon
Speed Run
High Score
Continue?
Press Start
Ryu Hayabusa
Plasma Gun
What is a man?
Serious Sam
Shoryuken!
Duck Hunt
The cake is a lie
War never changes
Would you kindly?
Do a barrel roll!
Praise the Sun!
A winner is you
You're pretty good
La-Li-Lu-Le-Lo
I am error
Leeroy Jenkins!
For the Horde!
For the Alliance!
Frostmourne hungers
Falcon Punch!
Rip and tear!
Vic Viper
Salamander!
Parodius Da!
We'll bang, okay?
What you say!!
You spoony bard!
One-Winged Angel
Morning Star
Glyph Union
TwinBee, scramble!
# ---------------------------------------------------------------------------
# MEDIUM (21-40 characters)
# ---------------------------------------------------------------------------
Stay a while and listen
It's dangerous to go alone!
Kept you waiting, huh?
A man chooses, a slave obeys
Metal Gear?! Metal Gear!!
We're not tools of the government
The winds of destruction
Who are the Patriots?
This is good, isn't it?
You have died of dysentery
The Triforce of Courage
Ganon has broken the seal!
May the wind guide you home
Dodongo dislikes smoke
The right man in the wrong place
Nothing is true, everything is permitted
I used to be an adventurer like you
You can't hide from the Grim Reaper
All your base are belong to us!
Somebody set up us the bomb!
For great justice, take off every Zig!
How are you gentlemen!!
Kain has betrayed us!
Garland will knock you all down!
You are not prepared!
I am Uther the Lightbringer!
Order of Ecclesia calls
The Dark Lord rises again!
Shanoa, bearer of glyphs
In this world, it's kill or be killed
Despite everything, it's still you
The Underground is your home now
Papyrus demands a battle!
I'm going to make spaghetti!
Toriel will protect you
Estus Flask replenished
The age of fire fades
Prepare to die, undead one
Can't let you do that, Star Fox!
Andross' empire spans the Lylat system!
Captain Falcon, show me your moves!
OBJECTION! That testimony is a lie!
Hold it! I have new evidence!
Phoenix Wright, attorney at law!
Does this unit have a soul?
Shepard, the Reapers are coming!
Tali'Zorah vas Normandy!
Gruntilda shall not be defeated!
K. Rool has stolen the banana hoard!
Kirby, hero of Dream Land!
Meta Knight awaits your challenge!
Congraturation! This story is happy end.
Cecil has become a Paladin
Cloud Strife, SOLDIER First Class
Time compression is inevitable
You require more vespene gas
Nuclear launch detected
You must construct additional pylons
I'm Commander Shepard!
War... War has changed.
Rip and tear until it is done!
Dawn of Sorrow awaits
Do you feel like a hero yet?
You're a monster. You know that, Walker?
Halo... it's not a natural formation.
Whip it good, Belmont!
# ---------------------------------------------------------------------------
# HARD (41-80 characters)
# ---------------------------------------------------------------------------
What is a man? A miserable pile of secrets!
Die monster! You don't belong in this world!
My name is Dracula, and I bid you welcome.
You have no chance to survive make your time!
You've met with a terrible fate, haven't you?
Fear the old blood... and welcome, good hunter.
Welcome to the Liandri Grand Tournament!
I am the very model of a scientist Salarian!
Metroid Prime has escaped into the impact crater!
I want to be the very best, like no one ever was
The world needs only one Big Boss... and one Snake.
Snake, do you think love can bloom on a battlefield?
Thank you Mario, but our princess is in another castle!
You must gather your party before venturing forth
Snake, we're not tools of the government, or anyone else
Did I ever tell you what the definition of insanity is?
They're everywhere! The demons... they won't stop coming!
Dracula! Your time has come! The Vampire Killer strikes!
Link... I'm Navi, your fairy companion! Listen!
Hero of Time, your destiny awaits in the Sacred Realm
Master Chief, finish the fight. Earth is counting on you.
Outer Heaven... a place where warriors can find purpose
We passed the point of no return a long time ago, Snake
Liquid! I was the one who was meant to be the successor!
Revolver Ocelot is a triple agent working for the Patriots
Phazon corruption detected! Seek immediate medical attention!
Dark Samus has absorbed the Phazon and grown more powerful!
I'm the Doom Slayer, and I'm here to kill every last one of you!
Praise the Chosen Undead, for they shall link the fire!
Ganon is the evil king who stole the Triforce of Power!
The legendary soldier who defied his genes... Big Boss
Killing spree! Monster kill! Godlike! Unstoppable!
Humanity restored! The bonfire blazes with newfound strength!
You are the last line of defense against an infinite demonic army
Liquid Snake... your dominant genes... give you the edge in battle!
All we did was give meaning to the nuclear age by using nukes!
I'm a soldier who's been betrayed, abandoned... I fight alone now
Samus Aran, the last of the Chozo warriors, descends into the unknown
You are the Chosen Undead, fated to succeed where so many have failed
The price of living in the past is a slow death in the present
A sword wields no strength unless the hands that hold it have courage
The flow of time is always cruel, its speed seems different for each person
Hey! Listen! There's something important you need to know!
We are born of the blood, made men by the blood, undone by the blood
War has changed. It's no longer about nations, ideologies, or ethnicity
Mental has sent his armies, and I am all that stands between them and Earth!
I'm no hero. Never was. Never will be. I'm just an old killer.
What is a man? A miserable pile of secrets! But enough talk... have at you!
I used to be an adventurer like you, then I took an arrow in the knee
I'm not the only one who's responsible. The humans are just as guilty!
The Skaarj have invaded, and you must fight your way to freedom
# ---------------------------------------------------------------------------
# EXTREME (81+ characters) -- Full quotes. No mercy.
# ---------------------------------------------------------------------------
For you, the day Bison graced your village was the most important day of your life. But for me... it was Tuesday.
What is a man? A miserable pile of secrets! But enough talk... have at you!
Die monster! You don't belong in this world! It was not by my hand that I am once again given flesh!
You can't keep a good man down, and that goes double for a soldier who's been fighting his whole life.
They say the definition of insanity is doing the same thing over and over and expecting different results... did I ever tell you that?
I've been waiting for this... a professional killer. I'm so excited I may be sick!
Kept you waiting, huh? Don't worry though. I'll take you somewhere warm. Back to the battlefield.
It's easy to forget what a sin is in the middle of a battlefield. Especially when you're killing to stay alive.
We are not tools of the government or anyone else. Fighting was the only thing, the only thing I was good at. But at least I always fought for what I believed in.
In the 21st century, the battlefield will once again be a symbol of the glory of nations. I am a weapon. A human weapon.
A man's dreams can be his greatest asset... or his most dangerous enemy. The question is: what are you willing to sacrifice to see them through?
I need scissors! 61!
Outer Heaven, a place where soldiers need not justify their actions -- where warriors can be free of political manipulation.
Nothing happened to me. I happened. I'm the one who knocks, the one who causes all the trouble... that is my purpose.
Sun Tzu said that. I think he meant it as a metaphor, but he also said never leave home without a healthy supply of rations, so what does he know?

View File

@@ -0,0 +1,279 @@
package holdem
import "sort"
// The betting rules. These are the fiddly ones — min-raise, short all-ins that
// don't reopen the action, side pots — and they came over from gogobee, where
// they had no tests at all. They have some now.
// blinds posts the small and the big. Heads-up is the exception every poker
// implementation gets wrong once: with two players the button *is* the small
// blind and acts first before the flop, and last after it.
func (s *State) blinds(evs *[]Event) (bb int) {
var sb int
if s.dealt() == 2 {
sb, bb = s.Button, s.nextIn(s.Button)
} else {
sb = s.nextIn(s.Button)
bb = s.nextIn(sb)
}
s.post(sb, s.Tier.SB, "small", evs)
s.post(bb, s.Tier.BB, "big", evs)
s.Bet = s.Tier.BB
s.MinRaise = s.Tier.BB
s.Aggressor = bb // the big blind has the option to raise their own blind
return bb
}
// post puts a blind up. A player too short to cover it is all-in for what they
// have, which is legal and is why the amount is clamped rather than refused.
func (s *State) post(seat int, amount int64, which string, evs *[]Event) {
p := &s.Seats[seat]
if amount > p.Stack {
amount = p.Stack
}
p.Stack -= amount
p.Bet = amount
p.Total = amount
if p.Stack == 0 {
p.State = AllIn
}
*evs = append(*evs, Event{Kind: "blind", Seat: seat, Amount: amount, Text: which})
}
// firstPreFlop is under the gun: the seat after the big blind, or the button
// itself when the table is heads-up.
//
// The button only gets it if the button can still act. A short stack can be
// all-in on its own blind — post a small blind of 1 with 1 chip left and you are
// in the hand with no chips and no say — and handing the action to a seat that
// cannot act wedges the table.
func (s *State) firstPreFlop(bb int) int {
if s.dealt() != 2 {
return s.nextCanAct(bb)
}
if s.Seats[s.Button].State == Active {
return s.Button
}
return s.nextCanAct(s.Button)
}
// firstPostFlop is the first seat left of the button, on every street after the
// flop. The button acts last from here on, which is the whole point of it.
func (s *State) firstPostFlop() int { return s.nextCanAct(s.Button) }
// ---- the five things a seat can do ----------------------------------------
func (s *State) fold(seat int, evs *[]Event) {
p := &s.Seats[seat]
p.State = Folded
p.Acted = true
s.History += "f"
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "fold"})
}
func (s *State) check(seat int, evs *[]Event) error {
p := &s.Seats[seat]
if p.Bet < s.Bet {
return ErrCantCheck
}
p.Acted = true
s.History += "c"
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "check"})
return nil
}
func (s *State) call(seat int, evs *[]Event) error {
p := &s.Seats[seat]
owed := s.Bet - p.Bet
if owed <= 0 {
return ErrNothingToCall
}
if owed > p.Stack {
owed = p.Stack // a call for less than the bet is a call all-in
}
p.Stack -= owed
p.Bet += owed
p.Total += owed
p.Acted = true
text := "call"
if p.Stack == 0 {
p.State = AllIn
text = "allin"
s.History += "a"
} else {
s.History += "c"
}
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: text, Amount: owed, Total: p.Bet})
return nil
}
// raise raises *to* a total, not *by* an amount. Every poker interface in the
// world means the total, and a browser that means the other thing bets wrong.
func (s *State) raise(seat int, to int64, evs *[]Event) error {
p := &s.Seats[seat]
most := p.Bet + p.Stack
if to > most {
return ErrTooBig
}
if to < s.Bet+s.MinRaise && to < most {
return ErrTooSmall // only a shove may be smaller than a legal raise
}
added := to - p.Bet
over := to - s.Bet
p.Stack -= added
p.Bet = to
p.Total += added
p.Acted = true
if over > 0 {
s.MinRaise = over
}
s.Bet = to
s.Aggressor = seat
text := "raise"
if p.Stack == 0 {
p.State = AllIn
text = "allin"
s.History += "a"
} else {
// The policy was trained against a tree with two raise sizes in it, so the
// history it reads has to say which one this was: R for a pot-sized raise
// or bigger, r for anything smaller.
if pot := s.inPlay(); pot > 0 && float64(over) >= float64(pot)*0.75 {
s.History += "R"
} else {
s.History += "r"
}
}
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: text, Amount: added, Total: to})
return nil
}
// allin pushes the lot.
//
// A short all-in does not reopen the betting. If a player shoves for less than
// a full raise over the current bet, players who have already acted may call it
// but may not raise again — otherwise a tiny stack could be used to reopen the
// action for a partner, which is the oldest collusion trick there is.
func (s *State) allin(seat int, evs *[]Event) error {
p := &s.Seats[seat]
if p.Stack <= 0 {
return ErrNoChips
}
added := p.Stack
to := p.Bet + added
p.Stack = 0
p.Bet = to
p.Total += added
p.State = AllIn
p.Acted = true
if to > s.Bet {
if over := to - s.Bet; over >= s.MinRaise {
s.MinRaise = over
s.Aggressor = seat
}
s.Bet = to
}
s.History += "a"
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "allin", Amount: added, Total: to})
return nil
}
// ---- when is a street over ------------------------------------------------
// streetDone reports whether the betting round is finished, given the seat the
// action would pass to next.
//
// The "has acted" check is the load-bearing half. The big blind has money in
// front of them without having chosen to put it there, so a round where
// everybody merely limps in has all bets matched while the blind has never had
// a say. Without this, they never get their option.
func (s *State) streetDone(next int) bool {
if s.canActCount() == 0 {
return true
}
for i := range s.Seats {
p := &s.Seats[i]
if p.State != Active {
continue
}
if p.Bet != s.Bet || !p.Acted {
return false
}
}
// The last aggressor being all-in means the action can't get back to them:
// everyone left has matched the bet above, so there is nothing more to do.
if s.Seats[s.Aggressor].State == AllIn {
return true
}
return next == s.Aggressor
}
// ---- side pots -------------------------------------------------------------
// sidePots slices the pot into layers, one per distinct all-in level. A player
// can only win the part of the pot they could have lost, so each layer is
// contested by exactly the players who paid into it.
//
// Folded players' chips stay in the pot — they paid for the right to fold — but
// they are eligible for nothing.
func (s *State) sidePots() {
s.collect()
var levels []int64
for i := range s.Seats {
p := &s.Seats[i]
if p.State == Folded || p.State == Out || p.Total == 0 {
continue
}
levels = append(levels, p.Total)
}
if len(levels) == 0 {
return
}
sort.Slice(levels, func(i, j int) bool { return levels[i] < levels[j] })
var pots []Pot
var prev int64
for _, level := range levels {
if level <= prev {
continue
}
var amount int64
var eligible []int
for i := range s.Seats {
p := &s.Seats[i]
paid := p.Total - prev
if paid > level-prev {
paid = level - prev
}
if paid > 0 {
amount += paid // folded money counts toward the pot...
}
if p.State != Folded && p.State != Out && p.Total >= level {
eligible = append(eligible, i) // ...but wins no part of it
}
}
if amount > 0 {
pots = append(pots, Pot{Amount: amount, Eligible: eligible})
}
prev = level
}
if len(pots) > 0 {
s.Side = pots
s.Pot = 0
}
}

View File

@@ -0,0 +1,416 @@
package holdem
import (
_ "embed"
"fmt"
"log/slog"
"math/rand/v2"
"sync"
"sync/atomic"
"pete/internal/games/cards"
)
// The bots' brain.
//
// gogobee ran counterfactual regret minimisation against this game for a very
// long time, and policy.gob is what it converged on: a table from "the situation
// I am in" to "how often I fold, call, raise small, raise big, or shove". It is
// the single highest-value thing in either repository, and none of it is
// re-derived here — this file is the *runtime*, the trainer stayed behind.
//
// A situation is squeezed down to six things, and this is the whole reason the
// table fits in memory: the street, whether the bot is in position, which of
// twelve equity buckets its hand falls in, which of five stack-to-pot buckets,
// whether the board is dry, wet or paired, and the last six actions. Two hands
// that hash to the same key get the same strategy, and that is the approximation
// the whole thing is built on.
//
// The key has to be *exactly* the key the trainer wrote, character for
// character. Change the bucket edges, the position label or the history encoding
// and every lookup misses — silently, because a miss is not an error, it is a
// fall back to the pot-odds rule below. The bots would get quietly, unaccountably
// worse. So: don't touch these numbers.
//go:embed policy.gob
var policyGob []byte
// The five things the trainer let a bot consider.
const (
actFold = iota
actCallCheck
actRaiseHalf
actRaisePot
actAllIn
numActions
)
// policyTable maps an info-set key to how often to take each action.
type policyTable map[string][numActions]float64
var (
policyOnce sync.Once
policy policyTable
)
// loadPolicy decodes the embedded table, once, on the first hand anybody plays.
//
// Not in an init(): it is megabytes of gob, and Pete is a news server that mostly
// never deals a card. Every test in the repo and every cold start would pay for
// it. The first player to sit down pays for it instead, and only they do.
func loadPolicy() policyTable {
policyOnce.Do(func() {
t, err := loadTrained(policyGob)
if err != nil {
// The bots still play — on pot odds — rather than the table 500ing.
slog.Error("holdem: cannot decode CFR policy, bots fall back to pot odds", "err", err)
policy = policyTable{}
return
}
policy = t.Strategy
slog.Info("holdem: CFR policy loaded", "nodes", len(policy),
"iterations", t.Meta.Iterations, "stakes", t.Meta.Stakes, "depths", t.Meta.Depths)
})
return policy
}
// ---- the info-set key ------------------------------------------------------
//
// Every function below is a load-bearing copy of the trainer's. See the note at
// the top of the file before changing a number in any of them.
// equityBucket puts a hand's strength in one of twelve boxes, from trash to
// monster.
func equityBucket(eq float64) int {
switch {
case eq < 0.08:
return 0
case eq < 0.17:
return 1
case eq < 0.25:
return 2
case eq < 0.33:
return 3
case eq < 0.42:
return 4
case eq < 0.50:
return 5
case eq < 0.58:
return 6
case eq < 0.67:
return 7
case eq < 0.75:
return 8
case eq < 0.83:
return 9
case eq < 0.92:
return 10
default:
return 11
}
}
// sprBucket is the stack-to-pot ratio: how much room is left to play. Under 1 is
// a pot that is already committed; over 12 is a pot you can still fold out of.
func sprBucket(spr float64) int {
switch {
case spr < 1:
return 0
case spr < 3:
return 1
case spr < 6:
return 2
case spr < 12:
return 3
default:
return 4
}
}
// Board textures, which is what makes the same hand a bet or a check.
const (
boardDry = 0
boardWet = 1
boardPaired = 2
)
// boardTexture classifies the community cards. A paired board is one somebody
// might have trips on; a wet one has a flush or straight coming.
// It is on the trainer's hot path — millions of calls — so it counts into arrays
// rather than maps. A map here cost more than the poker did.
func boardTexture(board []cards.Card) int {
if len(board) < 3 {
return boardDry
}
var ranks [14]int8
var suits [4]int8
var vals [5]int
for i, c := range board {
ranks[c.Rank]++
suits[c.Suit]++
vals[i] = int(c.Rank)
}
for _, n := range ranks {
if n >= 2 {
return boardPaired
}
}
for _, n := range suits {
if n >= 3 {
return boardWet
}
}
// Three cards inside a five-rank window is a straight waiting to happen.
v := vals[:len(board)]
for i := 1; i < len(v); i++ {
for j := i; j > 0 && v[j] < v[j-1]; j-- {
v[j], v[j-1] = v[j-1], v[j]
}
}
for i := 0; i+2 < len(v); i++ {
if v[i+2]-v[i] <= 4 {
return boardWet
}
}
return boardDry
}
// infoSet builds the string the policy is keyed on. The format is the trainer's.
//
// Position is IP or OOP — in position or out of it — and *nothing else*. This is
// the one thing gogobee got wrong, and it got it wrong invisibly for as long as
// the game has existed: the trainer packed a single "am I last to act" bit and
// wrote its keys as IP/OOP, while the runtime looked them up with the table
// labels a player would recognise (BTN, SB, BB, UTG…). Not one key ever matched.
// Every bot in every hand of hold'em gogobee ever dealt fell through to the
// pot-odds rule, and the five million training iterations sitting in policy.gob
// were never once read.
//
// Nothing about that looks broken from the outside. A missing key is not an
// error, it's a fallback — the bots played, they just played a heuristic. This is
// why the hit rate is now a test.
func infoSet(street Street, inPosition bool, eq, spr, texture int, history string) string {
pos := "OOP"
if inPosition {
pos = "IP"
}
return fmt.Sprintf("%d|%s|%d|%d|%d|%s", street, pos, eq, spr, texture, history)
}
// recent keeps the last six actions, which is all the trainer's key had room for.
func recent(h string) string {
if len(h) > 6 {
return h[len(h)-6:]
}
return h
}
// ---- where a seat stands ---------------------------------------------------
// mcIters is how many runouts a bot samples to judge its hand at the table.
const mcIters = 1000
// spot is everything the policy knows about a seat's situation, and it is the
// one function that builds it.
//
// The trainer calls this too. That is the point of it: the key the policy is
// written under and the key it is read under come out of the same code, so they
// cannot quietly stop matching — which is exactly what went wrong the first time
// and went unnoticed for the life of the game.
func (s State) spot(seat, iters int, rng *rand.Rand) (string, Equity) {
eq := s.equityFor(seat, iters, rng)
return s.spotKey(seat, eq), eq
}
// equityFor measures how good a seat's hand is right now.
//
// It depends only on the cards — the hand, the board, how many opponents — and
// not on a single thing that happened in the betting. Which is why the trainer
// can measure it once per street and reuse it down every branch it explores, and
// why doing that is most of the difference between a run that takes half an hour
// and one that takes four.
func (s State) equityFor(seat, iters int, rng *rand.Rand) Equity {
opponents := s.liveCount() - 1
if opponents < 1 {
opponents = 1
}
// Preflop heads-up is a lookup, not a simulation: there are only 169 hands
// that differ, they have been measured to death, and a sampled answer would
// only add noise to a bucket boundary.
if s.Street == PreFlop && opponents == 1 {
return preflopEquity(s.Seats[seat].Hole)
}
return equityOf(s.Seats[seat].Hole, s.Community, opponents, iters, rng)
}
// spotKey builds the key from an equity already measured.
func (s State) spotKey(seat int, eq Equity) string {
pot := s.inPlay()
spr := 0.0
if pot > 0 {
spr = float64(s.Seats[seat].Stack) / float64(pot)
}
return infoSet(s.Street, s.InPosition(seat), equityBucket(eq.Strength()),
sprBucket(spr), boardTexture(s.Community), recent(s.History))
}
// ---- choosing --------------------------------------------------------------
// hits and misses count how often a bot finds itself in the trained policy.
//
// They exist because the way this can break is silently. A key the policy has
// never seen is not an error — the bot shrugs and plays pot odds — so a policy
// that has stopped matching the game looks exactly like a policy that is working.
// gogobee's never matched once, for the whole life of the game, and nobody could
// have known by watching it play. Now a test reads these and fails.
var hits, misses atomic.Int64
// botActs plays one bot's turn: work out where it stands, look up what it does
// there, throw out anything illegal, and roll for it.
func (s *State) botActs(seat int, evs *[]Event, rng *rand.Rand) {
key, eq := s.spot(seat, mcIters, rng)
probs, ok := loadPolicy()[key]
if ok {
hits.Add(1)
} else {
misses.Add(1)
probs = potOdds(eq, s, seat)
}
probs = legal(probs, s, seat)
move := s.moveFor(pick(probs, rng), seat)
if err := s.act(seat, move, evs); err != nil {
// A bot cannot be allowed to wedge the table by choosing something the rules
// then refuse: it checks if it can and folds if it can't, and the mismatch
// is loud, because it means legal() and the betting rules disagree.
slog.Error("holdem: bot chose an illegal move", "seat", seat, "move", move.Kind, "err", err)
if s.Owed(seat) > 0 {
s.fold(seat, evs)
} else {
_ = s.check(seat, evs)
}
}
}
// potOdds is what a bot does when the trained table has never seen this spot: it
// works out whether the price it is being offered beats its chance of winning,
// and mixes in enough aggression not to be a calling station.
func potOdds(eq Equity, s *State, seat int) [numActions]float64 {
p := &s.Seats[seat]
strength := eq.Strength()
owed := s.Bet - p.Bet
pot := s.inPlay()
price := 0.0
if owed > 0 && pot+owed > 0 {
price = float64(owed) / float64(pot+owed)
}
var probs [numActions]float64
switch {
case strength > 0.8:
probs[actRaisePot], probs[actAllIn], probs[actCallCheck] = 0.6, 0.2, 0.2
case strength > 0.6:
probs[actRaiseHalf], probs[actCallCheck], probs[actFold] = 0.4, 0.5, 0.1
case owed > 0 && strength > price:
probs[actCallCheck], probs[actRaiseHalf], probs[actFold] = 0.7, 0.2, 0.1
case owed > 0:
probs[actFold], probs[actCallCheck] = 0.7, 0.3
default:
probs[actCallCheck], probs[actRaiseHalf], probs[actFold] = 0.6, 0.3, 0.1
}
return probs
}
// mask is what a seat may actually do here. The trainer explores exactly this
// set, so it never learns a strategy the table would turn down.
func (s State) mask(seat int) (m [numActions]bool) {
owed := s.Bet - s.Seats[seat].Bet
// Folding a hand you could see for free is a bug, not a strategy.
m[actFold] = owed > 0
m[actCallCheck] = true
// A raise needs chips behind the call, and somebody left to bet into.
raise := s.Seats[seat].Stack > owed && s.canBet()
m[actRaiseHalf], m[actRaisePot], m[actAllIn] = raise, raise, raise
return m
}
// legal zeroes out what the seat cannot do and renormalises what's left.
func legal(probs [numActions]float64, s *State, seat int) [numActions]float64 {
m := s.mask(seat)
var total float64
for i := range probs {
if !m[i] {
probs[i] = 0
}
total += probs[i]
}
if total <= 0 {
var only [numActions]float64
only[actCallCheck] = 1
return only
}
for i := range probs {
probs[i] /= total
}
return probs
}
// pick rolls against the distribution.
func pick(probs [numActions]float64, rng *rand.Rand) int {
r := rng.Float64()
sum := 0.0
for i, p := range probs {
sum += p
if r < sum {
return i
}
}
return actCallCheck
}
// moveFor turns an abstract action — "raise half the pot" — into a legal move at
// the actual size the table is at. A raise that would cost the bot everything it
// has is a shove, which is the same decision made honestly.
func (s *State) moveFor(action, seat int) Move {
p := &s.Seats[seat]
owed := s.Bet - p.Bet
pot := s.inPlay()
most := p.Bet + p.Stack
sized := func(by int64) Move {
if by < s.MinRaise {
by = s.MinRaise
}
to := s.Bet + by
if to >= most {
return Move{Kind: Shove}
}
return Move{Kind: Raise, To: to}
}
switch action {
case actFold:
if owed <= 0 {
return Move{Kind: Check} // never fold for free
}
return Move{Kind: Fold}
case actCallCheck:
if owed > 0 {
return Move{Kind: Call}
}
return Move{Kind: Check}
case actRaiseHalf:
return sized(pot / 2)
case actRaisePot:
return sized(pot)
case actAllIn:
return Move{Kind: Shove}
}
return Move{Kind: Check}
}

View File

@@ -0,0 +1,134 @@
package holdem
import (
"math/rand/v2"
"github.com/chehsunliu/poker"
"pete/internal/games/cards"
)
// How good is this hand, really?
//
// A bot's decision starts here: deal the cards it cannot see, a thousand times,
// and count how often it wins. That number — plus the board's texture, the
// stack-to-pot ratio and the action so far — is the key it looks its trained
// strategy up under.
//
// Monte Carlo rather than exhaustive because exhaustive is 2.1 million river
// runouts against one opponent and rather more against five, and a thousand
// samples puts the estimate inside a percentage point or so. The bot does not
// need the fourth decimal place; it needs to know whether it is ahead.
// Equity is the fraction of runouts a hand wins, ties and loses against the
// given number of unknown opponents.
type Equity struct {
Win float64
Tie float64
Loss float64
}
// Strength collapses a result into the one number the policy is keyed on: a tie
// is worth half a win, because that is what half a pot is.
func (e Equity) Strength() float64 { return e.Win + e.Tie*0.5 }
// deck52 is the evaluator's whole deck, built once.
var deck52 = func() []poker.Card {
d := make([]poker.Card, 0, 52)
for s := cards.Spades; s <= cards.Clubs; s++ {
for r := cards.Ace; r <= cards.King; r++ {
d = append(d, pokerOf[s][r])
}
}
return d
}()
// equityOf runs the simulation. The RNG is threaded like everything else here,
// so a bot's decision replays from the session's seed along with the deal.
func equityOf(hole [2]cards.Card, board []cards.Card, opponents, iterations int, rng *rand.Rand) Equity {
if opponents < 1 {
opponents = 1
}
h0, h1 := toPoker(hole[0]), toPoker(hole[1])
// Seven cards, checked by hand. A map here would be the most expensive thing
// in the trainer, which calls this function millions of times.
var known [7]poker.Card
n := 2
known[0], known[1] = h0, h1
pb := make([]poker.Card, len(board))
for i, c := range board {
pb[i] = toPoker(c)
known[n] = pb[i]
n++
}
rest := make([]poker.Card, 0, 52)
for _, c := range deck52 {
seen := false
for _, k := range known[:n] {
if k == c {
seen = true
break
}
}
if !seen {
rest = append(rest, c)
}
}
need := opponents*2 + (5 - len(pb))
if need > len(rest) {
return Equity{Tie: 1}
}
hero := make([]poker.Card, 7)
hero[0], hero[1] = h0, h1
villain := make([]poker.Card, 7)
full := make([]poker.Card, 5)
var wins, ties int
for i := 0; i < iterations; i++ {
// A partial Fisher-Yates: only the cards actually needed get shuffled into
// place, which is the difference between this being cheap and being the
// slowest thing in the request.
for j := 0; j < need; j++ {
k := j + rng.IntN(len(rest)-j)
rest[j], rest[k] = rest[k], rest[j]
}
copy(full, pb)
at := opponents * 2
for b := len(pb); b < 5; b++ {
full[b] = rest[at]
at++
}
copy(hero[2:], full)
mine := poker.Evaluate(hero)
best := int32(7463) // one worse than the worst real hand
for o := 0; o < opponents; o++ {
villain[0], villain[1] = rest[o*2], rest[o*2+1]
copy(villain[2:], full)
if r := poker.Evaluate(villain); r < best {
best = r
}
}
switch {
case mine < best:
wins++
case mine == best:
ties++
}
}
total := float64(iterations)
return Equity{
Win: float64(wins) / total,
Tie: float64(ties) / total,
Loss: float64(iterations-wins-ties) / total,
}
}

View File

@@ -0,0 +1,297 @@
package holdem
import (
"math"
"sort"
"strings"
"github.com/chehsunliu/poker"
"pete/internal/games/cards"
)
// The bridge to the evaluator.
//
// The engine deals Pete's own cards.Card — the same one blackjack, solitaire and
// the felt already speak — and converts at the door. Hand strength is the one
// thing in this package that is genuinely hard to get right (7-card best-of-5,
// 7,462 distinct hands), so it is not homegrown: github.com/chehsunliu/poker is
// a lookup table and it is correct.
//
// The conversion is a table built once. Doing it per evaluation would matter:
// a bot's equity estimate is a thousand seven-card evaluations, and it makes
// several of those per hand.
var (
pokerRanks = [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K"}
pokerSuits = [4]string{"s", "h", "d", "c"} // cards.Spades, Hearts, Diamonds, Clubs
// A var initializer, not an init(). Go builds package-level variables before
// it runs init functions, so anything else in this package that is itself a
// var built out of this table — equity.go's deck52 is — would otherwise be
// built out of an empty one. It was, briefly: every card came out identical,
// every showdown tied, and every bot believed it held exactly 50% equity.
pokerOf = func() (t [4][14]poker.Card) {
for s := cards.Spades; s <= cards.Clubs; s++ {
for r := cards.Ace; r <= cards.King; r++ {
t[s][r] = poker.NewCard(pokerRanks[r] + pokerSuits[s])
}
}
return t
}()
)
// toPoker converts one card for the evaluator.
func toPoker(c cards.Card) poker.Card { return pokerOf[c.Suit][c.Rank] }
func toPokerAll(cs []cards.Card) []poker.Card {
out := make([]poker.Card, len(cs))
for i, c := range cs {
out[i] = toPoker(c)
}
return out
}
// rankOf evaluates a seat's best five from its hole cards and the board. Lower
// is better — 1 is a royal flush — which is the evaluator's convention and not
// worth inverting, since nothing outside this file ever sees the number.
func rankOf(hole [2]cards.Card, board []cards.Card) (int32, string) {
seven := make([]poker.Card, 0, 7)
seven = append(seven, toPoker(hole[0]), toPoker(hole[1]))
seven = append(seven, toPokerAll(board)...)
r := poker.Evaluate(seven)
return r, strings.ToLower(poker.RankString(r))
}
// ---- showdown -------------------------------------------------------------
type ranked struct {
seat int
rank int32
desc string
}
// showdown turns the cards over, splits the pots, and pays. Every player still
// in the hand shows, in the order the felt should turn them over: best hand
// first, so the winner is the first card face the player sees.
func (s *State) showdown(evs *[]Event) {
s.collect()
s.Street = Showdown
// Cut the side pots, if nobody has cut them yet.
//
// runout() does this, but runout only happens when the betting stops because
// there is nobody left able to bet. A hand can reach a showdown with an all-in
// player in it and the betting having finished perfectly normally: a short stack
// shoves, and two players who both have chips behind keep betting past them,
// street after street, all the way to the river. Nothing has been cut, and the
// short stack is sitting in a single pot marked eligible for all of it.
//
// Which means they can win every chip the deep players put in *after* they were
// already all-in — money they could never have lost. All-in for 100 against two
// players who each put in 500, and the best hand takes 1,100 instead of the 300
// they were playing for. The chips still balance, so conservation says nothing;
// they just go to the wrong seat.
if len(s.Side) == 0 && s.anyAllIn() {
s.sidePots()
}
// Say so. The last street's bets are still sitting in front of the seats that
// made them, as far as the felt knows, and nothing else in the script is going
// to tell it they have been swept in.
*evs = append(*evs, Event{Kind: "pot", Seat: -1, Amount: s.Total()})
var live []ranked
for i := range s.Seats {
p := &s.Seats[i]
if p.State == Folded || p.State == Out {
continue
}
r, desc := rankOf(p.Hole, s.Community)
live = append(live, ranked{seat: i, rank: r, desc: desc})
}
sort.Slice(live, func(i, j int) bool { return live[i].rank < live[j].rank })
for _, e := range live {
*evs = append(*evs, Event{
Kind: "show", Seat: e.seat,
Cards: []cards.Card{s.Seats[e.seat].Hole[0], s.Seats[e.seat].Hole[1]},
Text: e.desc,
})
}
pots := s.Side
if len(pots) == 0 {
all := make([]int, 0, len(live))
for _, e := range live {
all = append(all, e.seat)
}
pots = []Pot{{Amount: s.Pot, Eligible: all}}
s.Pot = 0
}
s.Side = nil
for _, pot := range pots {
s.payPot(pot, live, evs)
}
s.endHand(evs)
}
// payPot rakes a pot and splits it between the best eligible hands.
//
// The rake comes out of the pot before it is split, which is what a cardroom
// does and is also the only thing consistent with the rest of this casino: a
// player pays it out of a pot they *win*, never out of a bet they lose. A hand
// that dies before the flop is not raked at all — no flop, no drop — so folding
// your blind round after round costs you exactly the blinds and no fee.
func (s *State) payPot(pot Pot, live []ranked, evs *[]Event) {
if pot.Amount <= 0 {
return
}
eligible := make(map[int]bool, len(pot.Eligible))
for _, seat := range pot.Eligible {
eligible[seat] = true
}
var winners []ranked
best := int32(0)
for _, e := range live {
if !eligible[e.seat] {
continue
}
if len(winners) == 0 || e.rank < best {
best, winners = e.rank, []ranked{e}
} else if e.rank == best {
winners = append(winners, e)
}
}
if len(winners) == 0 {
return
}
amount := pot.Amount
if s.Flopped {
rake := int64(math.Floor(float64(amount) * s.Tier.RakePct))
if cap := s.Tier.BB * rakeCapBB; rake > cap {
rake = cap
}
if rake > 0 {
amount -= rake
s.Rake += rake // every chip of it, so the table still balances
// But only the part that came out of a *human's* winnings is money the
// house actually made, and it is the only part worth quoting. The bots'
// chips are not real — the only real money at the table is the players' —
// so raking a pot a bot won costs nobody anything, and a counter that
// climbed while every human folded would be telling them it had.
for _, w := range winners {
if !s.Seats[w.seat].Bot {
// The table total (for the audit's delta) and the winner's own
// running tally (for the ledger line the felt shows them). At a
// table with two humans these are different numbers: each player is
// only ever quoted the rake that came out of a pot they won.
s.Paid += rake / int64(len(winners))
s.Seats[w.seat].Paid += rake / int64(len(winners))
}
}
*evs = append(*evs, Event{Kind: "rake", Seat: -1, Amount: rake})
}
}
share := amount / int64(len(winners))
odd := amount % int64(len(winners)) // the odd chip goes to the first seat left of the button
for i, w := range winners {
won := share
if i == 0 {
won += odd
}
s.Seats[w.seat].Stack += won
s.Seats[w.seat].Won += won
*evs = append(*evs, Event{Kind: "win", Seat: w.seat, Amount: won, Text: w.desc})
}
}
// takeit ends a hand nobody contested: everyone else folded, so the last player
// standing takes the pot without showing. Their own uncalled bet comes back
// first — it was never called, so it was never really in the pot.
func (s *State) takeit(evs *[]Event) {
s.uncalled(evs)
s.collect()
*evs = append(*evs, Event{Kind: "pot", Seat: -1, Amount: s.Total()})
winner := -1
for i := range s.Seats {
if s.Seats[i].State != Folded && s.Seats[i].State != Out {
winner = i
break
}
}
if winner < 0 {
s.endHand(evs)
return
}
// There are never side pots here: they are only cut once the betting is over
// because everybody is all-in, and a table where everybody is all-in is a table
// where nobody is left to fold.
pot := Pot{Amount: s.Pot, Eligible: []int{winner}}
s.Pot = 0
s.payPot(pot, []ranked{{seat: winner, rank: 0}}, evs)
s.endHand(evs)
}
// uncalled returns the unmatched top of a bet. If you shove 500 into a player
// with 200 behind, 300 of that was never contested and comes straight back.
//
// It must run *before* the bets are swept into the pot, and the matched level it
// measures against counts the players who folded. Their chips are in the pot —
// they paid to see the bet and then gave up — so the money they put in is money
// that called. Miss that and a bet folded to on the river comes back whole,
// including the part that was called on the flop, which mints chips out of air.
//
// The rake is the other reason this matters at all. When everybody folds, the
// winner takes the pot back either way and the arithmetic looks the same — but a
// pot with an uncalled bet still in it is a pot the house rakes, and it would be
// raking the player on their own money that nobody ever contested.
func (s *State) uncalled(evs *[]Event) {
top, topSeat := int64(-1), -1
for i := range s.Seats {
p := &s.Seats[i]
if p.State == Folded || p.State == Out {
continue
}
if p.Total > top {
top, topSeat = p.Total, i
}
}
if topSeat < 0 {
return
}
var matched int64 // the most anybody else put in, whether or not they're still in
for i := range s.Seats {
if i == topSeat || s.Seats[i].State == Out {
continue
}
if s.Seats[i].Total > matched {
matched = s.Seats[i].Total
}
}
excess := top - matched
p := &s.Seats[topSeat]
if excess <= 0 || excess > p.Bet {
// An uncalled bet is always part of the street it was made on, so it cannot
// be bigger than what that seat has in front of them right now.
return
}
p.Stack += excess
p.Total -= excess
p.Bet -= excess
if p.State == AllIn && p.Stack > 0 {
p.State = Active // they were never really all-in against anybody
}
*evs = append(*evs, Event{Kind: "uncalled", Seat: topSeat, Amount: excess})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,818 @@
package holdem
import (
"math/rand/v2"
"testing"
"pete/internal/games/cards"
)
// The one that matters: no chip is ever created or destroyed.
//
// Everything else in this package is a rule you could argue about. This is the
// one that would lose somebody money. Every chip at the table is in exactly one
// place — a stack, a bet in front of a seat, a pot, or the house's rake — and
// the only thing that ever adds to the total is a bot reloading. So: play a
// hundred sessions of real hands, with the trained bots making real decisions,
// and count the chips after every single move.
func TestChipsAreConserved(t *testing.T) {
for game := 0; game < 100; game++ {
rng := rand.New(rand.NewPCG(uint64(game), 99))
bots := 1 + game%MaxBots
tier := Tiers[game%len(Tiers)]
s, _, err := New(tier, SoloSeats(tier, bots, tier.MaxBuy), tier.RakePct, uint64(game), 7)
if err != nil {
t.Fatalf("new table: %v", err)
}
want := chipsAt(s) // what the table started with
for hand := 0; hand < 8 && s.Phase != PhaseDone; hand++ {
var evs []Event
s, evs, err = apply(s, Move{Kind: Deal})
if err != nil {
t.Fatalf("game %d hand %d: deal: %v", game, hand, err)
}
want += reloaded(evs) // a bot that rebought brought new chips with it
check(t, s, want, game, hand, "deal")
for step := 0; s.Phase == PhaseBetting; step++ {
if step > 200 {
t.Fatalf("game %d hand %d: the hand will not end", game, hand)
}
s, _, err = apply(s, randomMove(s, rng))
if err != nil {
t.Fatalf("game %d hand %d: %v", game, hand, err)
}
check(t, s, want, game, hand, "move")
}
}
}
}
// chipsAt totals every chip the table can see, plus every one the house has
// already taken out of it.
func chipsAt(s State) int64 {
total := s.Rake + s.Pot
for _, p := range s.Seats {
total += p.Stack + p.Bet
}
for _, pot := range s.Side {
total += pot.Amount
}
return total
}
// reloaded is what the bots brought back to the table on this deal. It is the
// only thing in the game that is allowed to make chips out of nothing, which is
// exactly why the test has to know about it and nothing else does.
func reloaded(evs []Event) int64 {
var n int64
for _, e := range evs {
if e.Kind == "rebuy" {
n += e.Amount
}
}
return n
}
func check(t *testing.T, s State, want int64, game, hand int, when string) {
t.Helper()
if got := chipsAt(s); got != want {
t.Fatalf("game %d hand %d, after %s: %d chips on the table, want %d "+
"(pot %d, rake %d, stacks %v)", game, hand, when, got, want, s.Pot, s.Rake, stacks(s))
}
for i, p := range s.Seats {
if p.Stack < 0 {
t.Fatalf("game %d hand %d: seat %d has a negative stack (%d)", game, hand, i, p.Stack)
}
}
}
func stacks(s State) []int64 {
out := make([]int64, len(s.Seats))
for i, p := range s.Seats {
out[i] = p.Stack
}
return out
}
// You is seat zero — the shape every test in this file is written against. The
// engine no longer has this constant: a table is a list of seats and which are
// human is a per-seat property, not a fixed index. But these tests all seat one
// human at zero (the pre-multiplayer solo shape), so a test-local alias keeps
// them readable as the regression guard they are. The multiway behaviour has its
// own tests, which do not assume it.
const You = 0
// apply plays a move as the seat whose turn it is at a solo table — always the
// human at seat zero. It wraps the seat-parameterized ApplyMove so the solo tests
// read as they did before the reshape.
func apply(s State, m Move) (State, []Event, error) {
return ApplyMove(s, You, m)
}
// randomMove picks something legal for the player, without any thought at all.
// A bad player is exactly what this test wants: it gets into all-ins, folds,
// short stacks and split pots far faster than a good one would.
func randomMove(s State, rng *rand.Rand) Move {
owed := s.Owed(You)
var legal []Move
if owed > 0 {
legal = append(legal, Move{Kind: Fold}, Move{Kind: Call})
} else {
legal = append(legal, Move{Kind: Check})
}
if s.Seats[You].Stack > owed && s.canBet() {
legal = append(legal, Move{Kind: Shove})
if to := s.MinRaiseTo(You); to < s.MaxRaiseTo(You) {
legal = append(legal, Move{Kind: Raise, To: to})
}
}
return legal[rng.IntN(len(legal))]
}
// ---- the rules a poker player would notice were wrong -----------------------
func TestHeadsUpButtonIsTheSmallBlindAndActsFirst(t *testing.T) {
s := table(t, Tiers[0], 1, 200)
s, evs, err := apply(s, Move{Kind: Deal})
if err != nil {
t.Fatal(err)
}
// The button posted the small blind, not the big one.
var small, big int
for _, e := range evs {
if e.Kind == "blind" && e.Text == "small" {
small = e.Seat
}
if e.Kind == "blind" && e.Text == "big" {
big = e.Seat
}
}
if small != s.Button {
t.Errorf("heads-up: seat %d posted the small blind, but the button is seat %d", small, s.Button)
}
if big == s.Button {
t.Error("heads-up: the button posted the big blind too")
}
// And it is the first to act before the flop. (If the button is a bot it has
// already acted, so what we can check is that the player didn't get skipped.)
if s.Phase != PhaseBetting {
t.Fatalf("phase %q: the hand should be waiting on somebody", s.Phase)
}
}
func TestTheBigBlindGetsTheirOption(t *testing.T) {
// A table where everyone just calls: the big blind has the bet matched without
// ever having chosen anything, and the street must not end until they speak.
s := table(t, Tiers[0], 1, 200)
s, _, _ = apply(s, Move{Kind: Deal})
// Find a hand where the player is the big blind. The button alternates, so at
// most a couple of deals.
for i := 0; i < 6 && s.Position(You) != "BB"; i++ {
s = playOut(t, s)
s, _, _ = apply(s, Move{Kind: Deal})
}
if s.Position(You) != "BB" {
t.Skip("never dealt the big blind")
}
if s.Phase != PhaseBetting {
return // the bot folded or raised; either way the option isn't the question
}
if s.ToAct == You && s.Owed(You) == 0 {
// This is the option: nothing to call, but the hand is still ours to act on.
if _, _, err := apply(s, Move{Kind: Check}); err != nil {
t.Errorf("the big blind cannot check their option: %v", err)
}
}
}
func TestAShortAllInDoesNotReopenTheBetting(t *testing.T) {
s := State{
Tier: Tiers[1], // 5/10
Seats: []Seat{{Name: "You", Stack: 1000}, {Name: "Bot", Bot: true, Stack: 1000}},
Bet: 100,
MinRaise: 100, // a full raise would be to 200
Aggressor: You,
Phase: PhaseBetting,
}
s.Seats[You].Bet = 100
s.Seats[1].Bet = 0
s.Seats[1].Stack = 150 // can only get to 150, which is a raise of 50: not a full one
var evs []Event
if err := s.allin(1, &evs); err != nil {
t.Fatal(err)
}
if s.Bet != 150 {
t.Errorf("the bet to call is %d, want 150", s.Bet)
}
if s.MinRaise != 100 {
t.Errorf("min raise moved to %d — a short all-in must not change it", s.MinRaise)
}
if s.Aggressor != You {
t.Errorf("the aggressor moved to seat %d — a short all-in must not reopen the action", s.Aggressor)
}
}
func TestSidePotsPayInLayers(t *testing.T) {
// Three players all-in for different amounts. The short stack can only win
// what everyone could have lost to them.
s := State{
Tier: Tiers[1],
Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}, {Name: "B", Bot: true}},
}
s.Seats[0].Total, s.Seats[0].State = 100, AllIn // short
s.Seats[1].Total, s.Seats[1].State = 500, AllIn // middle
s.Seats[2].Total, s.Seats[2].State = 500, AllIn // covers
s.sidePots()
if len(s.Side) != 2 {
t.Fatalf("got %d pots, want 2: %+v", len(s.Side), s.Side)
}
main, side := s.Side[0], s.Side[1]
if main.Amount != 300 { // 100 from each of the three
t.Errorf("main pot is %d, want 300", main.Amount)
}
if len(main.Eligible) != 3 {
t.Errorf("main pot has %d eligible, want all 3", len(main.Eligible))
}
if side.Amount != 800 { // 400 more from each of the two who had it
t.Errorf("side pot is %d, want 800", side.Amount)
}
if len(side.Eligible) != 2 {
t.Errorf("side pot has %d eligible, want 2 — the short stack cannot win it", len(side.Eligible))
}
if total := main.Amount + side.Amount; total != 1100 {
t.Errorf("the pots hold %d, but %d went in", total, 1100)
}
}
// A covered all-in player can only ever win what they matched, and the hand does
// not have to end in a run-out for that to be true.
//
// This one got through everything. The side pots were only ever cut in runout(),
// which happens when the betting stops because *nobody* can bet — so a short stack
// who shoves and gets called by two players who still have chips behind, and who
// then keep betting past them all the way to the river, reached a showdown with the
// pots never cut. One pot, everybody eligible, and the short stack takes the lot.
//
// Chip conservation never saw it: the chips balance perfectly, they just land in
// the wrong seat. And every browser session went through runout(), because the
// player shoving is what ends the betting. It took reading the code.
func TestACoveredAllInCannotWinTheSidePot(t *testing.T) {
c := func(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} }
s := State{
Tier: Tiers[1],
Phase: PhaseBetting,
Street: River,
Community: []cards.Card{
c(2, cards.Clubs), c(7, cards.Diamonds), c(9, cards.Spades),
c(cards.Jack, cards.Hearts), c(4, cards.Clubs),
},
Seats: []Seat{
{Name: "You", Stack: 500, Hole: [2]cards.Card{c(3, cards.Clubs), c(5, cards.Diamonds)}},
// All-in for 100, and holding the best hand at the table.
{Name: "Short", Bot: true, Hole: [2]cards.Card{c(cards.Ace, cards.Clubs), c(cards.Ace, cards.Diamonds)}},
{Name: "Deep", Bot: true, Stack: 500, Hole: [2]cards.Card{c(cards.King, cards.Clubs), c(cards.Queen, cards.Diamonds)}},
},
}
s.Seats[0].Total, s.Seats[0].State = 500, Active
s.Seats[1].Total, s.Seats[1].State = 100, AllIn
s.Seats[2].Total, s.Seats[2].State = 500, Active
s.Pot = 1100 // 100 + 500 + 500
var evs []Event
s.showdown(&evs)
// The main pot is 100 from each of the three. The other 800 is between the two
// who were still betting, and the short stack cannot touch it.
if s.Seats[1].Won != 300 {
t.Errorf("all-in for 100 against two players, and won %d — the most that can ever "+
"be won is the 300 main pot. The side pot was not cut.", s.Seats[1].Won)
}
if s.Seats[0].Won+s.Seats[2].Won != 800 {
t.Errorf("the 800 side pot paid out %d between the two players who were "+
"actually contesting it", s.Seats[0].Won+s.Seats[2].Won)
}
}
func TestFoldedChipsStayInThePotButWinNothing(t *testing.T) {
s := State{
Tier: Tiers[1],
Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}, {Name: "B", Bot: true}},
}
s.Seats[0].Total, s.Seats[0].State = 200, AllIn
s.Seats[1].Total, s.Seats[1].State = 50, Folded // called 50 and gave up
s.Seats[2].Total, s.Seats[2].State = 200, AllIn
s.sidePots()
var total int64
for _, p := range s.Side {
total += p.Amount
for _, seat := range p.Eligible {
if seat == 1 {
t.Error("a folded seat is eligible to win a pot")
}
}
}
if total != 450 {
t.Errorf("the pots hold %d, want 450 — the folder's 50 has to still be in there", total)
}
}
func TestAnUncalledBetComesBack(t *testing.T) {
s := State{
Tier: Tiers[1],
Seats: []Seat{{Name: "You", Stack: 0}, {Name: "A", Bot: true}},
}
s.Seats[0].Total, s.Seats[0].Bet, s.Seats[0].State = 500, 500, AllIn
s.Seats[1].Total, s.Seats[1].State = 200, Folded
var evs []Event
s.uncalled(&evs)
if s.Seats[You].Stack != 300 {
t.Errorf("got %d back, want the 300 nobody called", s.Seats[You].Stack)
}
if s.Seats[You].Total != 200 {
t.Errorf("still committed for %d, want 200 — the rest was never in the pot", s.Seats[You].Total)
}
if s.Seats[You].State != Active {
t.Error("still marked all-in for chips that came back")
}
}
// ---- the rake --------------------------------------------------------------
func TestNoFlopNoDrop(t *testing.T) {
s := State{Tier: Tiers[1], Flopped: false, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
var evs []Event
s.payPot(Pot{Amount: 1000, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
if s.Rake != 0 {
t.Errorf("raked %d off a pot that never saw a flop", s.Rake)
}
if s.Seats[You].Stack != 1000 {
t.Errorf("paid %d of a 1000 pot", s.Seats[You].Stack)
}
}
func TestTheRakeIsCapped(t *testing.T) {
s := State{Tier: Tiers[1], Flopped: true, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
var evs []Event
// 5% of 10,000 is 500, but the cap is three big blinds — 30 at 5/10.
s.payPot(Pot{Amount: 10000, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
want := s.Tier.BB * rakeCapBB
if s.Rake != want {
t.Errorf("raked %d, want the %d cap", s.Rake, want)
}
if s.Seats[You].Stack != 10000-want {
t.Errorf("paid %d, want %d", s.Seats[You].Stack, 10000-want)
}
}
func TestTheRakeIsFivePercentUnderTheCap(t *testing.T) {
s := State{Tier: Tiers[0], Flopped: true, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
var evs []Event
s.payPot(Pot{Amount: 100, Eligible: []int{You}}, []ranked{{seat: You}}, &evs) // cap is 6 at 1/2
if s.Rake != 5 {
t.Errorf("raked %d off a 100 pot, want 5", s.Rake)
}
if s.Seats[You].Stack != 95 {
t.Errorf("paid %d, want 95", s.Seats[You].Stack)
}
}
// The rake has to survive the wiring, not only the arithmetic.
//
// This is the test that was missing, and a browser found what it would have
// found: New() overwrites the tier's rake with the one the casino hands it, and
// the casino hands it a *fraction* (blackjack's 0.05). The tier declared 5,
// meaning percent. Every other rake test builds a State by hand and sets the tier
// itself, so not one of them ever saw the number a real table runs on — and the
// house quietly took nothing from every pot for an afternoon.
func TestTheRakeSurvivesTheConstructor(t *testing.T) {
tier := Tiers[1] // 5/10, so the cap is 30
s, _, err := New(tier, SoloSeats(tier, 1, tier.MaxBuy), 0.05, 1, 2)
if err != nil {
t.Fatal(err)
}
s.Flopped = true
var evs []Event
s.payPot(Pot{Amount: 400, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
if s.Rake != 20 {
t.Fatalf("the house took %d of a 400 pot, want 20 — five percent of it. "+
"RakePct is a fraction (0.05), not a percentage (5): see the note on Tiers.", s.Rake)
}
if !has(evs, "rake") {
t.Error("the rake was taken with no event to say so, so the felt cannot show it")
}
}
// The house only makes money off you. A pot a bot wins is raked — that is what a
// pot is — but the chips it comes out of are not real, so it has cost you nothing
// and the number the felt quotes you must not move.
func TestYouOnlyPayRakeOnPotsYouWin(t *testing.T) {
s := State{Tier: Tiers[1], Flopped: true,
Seats: []Seat{{Name: "You"}, {Name: "Dice", Bot: true}}}
var evs []Event
// A bot takes it.
s.payPot(Pot{Amount: 400, Eligible: []int{1}}, []ranked{{seat: 1}}, &evs)
if s.Paid != 0 {
t.Errorf("you paid %d in rake on a pot a bot won", s.Paid)
}
if s.Rake != 20 {
t.Errorf("the table lifted %d off that pot, want 20 — the chips have to balance "+
"whoever won it", s.Rake)
}
// Now you take one.
s.payPot(Pot{Amount: 400, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
if s.Paid != 20 {
t.Errorf("you paid %d in rake on a 400 pot you won, want 20", s.Paid)
}
if s.Rake != 40 {
t.Errorf("the table has lifted %d in total, want 40", s.Rake)
}
// And a chop costs you half of it.
s.Paid, s.Rake = 0, 0
s.payPot(Pot{Amount: 400, Eligible: []int{0, 1}},
[]ranked{{seat: 0, rank: 9}, {seat: 1, rank: 9}}, &evs)
if s.Paid != 10 {
t.Errorf("you paid %d in rake on a chopped pot, want 10 — half the rake, "+
"because you won half the pot", s.Paid)
}
}
// At a table with two humans the session-rake line each is shown is their own,
// not the table's: the house took it out of a pot one of them won, and quoting it
// to the other says the house has taken money off them that it has not. So the
// per-seat tally only ever moves on the seat that won the pot.
func TestRakeIsChargedToTheSeatThatWonIt(t *testing.T) {
s := State{Tier: Tiers[1], Flopped: true,
Seats: []Seat{{Name: "Reala"}, {Name: "Bob"}}}
var evs []Event
// Reala wins a 400 pot. The rake on it (5%, 20) is Reala's to have paid.
s.payPot(Pot{Amount: 400, Eligible: []int{0}}, []ranked{{seat: 0}}, &evs)
if s.Seats[0].Paid != 20 {
t.Errorf("the seat that won the pot paid %d in rake, want 20", s.Seats[0].Paid)
}
if s.Seats[1].Paid != 0 {
t.Errorf("the other player was charged %d in rake for a pot they were not in", s.Seats[1].Paid)
}
if s.Paid != 20 {
t.Errorf("the table total is %d, want 20 — it is still the sum for the audit", s.Paid)
}
// Bob wins the next one. His tally moves; Reala's stays where it was.
s.payPot(Pot{Amount: 200, Eligible: []int{1}}, []ranked{{seat: 1}}, &evs)
if s.Seats[0].Paid != 20 {
t.Errorf("Reala's tally moved on a pot Bob won: %d, want 20", s.Seats[0].Paid)
}
if s.Seats[1].Paid != 10 {
t.Errorf("Bob paid %d in rake on a 200 pot he won, want 10", s.Seats[1].Paid)
}
if s.Paid != 30 {
t.Errorf("the table total is %d, want 30", s.Paid)
}
}
func TestASplitPotSplits(t *testing.T) {
s := State{Tier: Tiers[1], Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
var evs []Event
// Same rank: they chop. The odd chip goes to one of them, not into the air.
s.payPot(Pot{Amount: 101, Eligible: []int{0, 1}},
[]ranked{{seat: 0, rank: 500}, {seat: 1, rank: 500}}, &evs)
if got := s.Seats[0].Stack + s.Seats[1].Stack; got != 101 {
t.Errorf("paid out %d of a 101 pot", got)
}
if s.Seats[0].Stack != 51 || s.Seats[1].Stack != 50 {
t.Errorf("split %d/%d, want 51/50", s.Seats[0].Stack, s.Seats[1].Stack)
}
}
// ---- the session -----------------------------------------------------------
func TestYouCannotWalkOutOfALiveHand(t *testing.T) {
s := table(t, Tiers[0], 2, 200)
s, _, _ = apply(s, Move{Kind: Deal})
if s.Phase != PhaseBetting {
t.Skip("the hand ended before the player could act")
}
if _, _, err := apply(s, Move{Kind: Leave}); err != ErrHandLive {
t.Errorf("leaving mid-hand gave %v, want ErrHandLive", err)
}
if _, _, err := apply(s, Move{Kind: TopUp, Amount: 10}); err != ErrHandLive {
t.Errorf("topping up mid-hand gave %v, want ErrHandLive", err)
}
}
func TestLeavingTakesTheStackHome(t *testing.T) {
s := table(t, Tiers[0], 1, 200)
s, _, _ = apply(s, Move{Kind: Deal})
s = playOut(t, s)
stack := s.Seats[You].Stack
s, _, err := apply(s, Move{Kind: Leave})
if err != nil {
t.Fatal(err)
}
if s.Phase != PhaseDone {
t.Errorf("phase %q after leaving, want done", s.Phase)
}
if s.Payout != stack {
t.Errorf("payout %d, want the %d that was in front of us", s.Payout, stack)
}
if _, _, err := apply(s, Move{Kind: Deal}); err != ErrOver {
t.Errorf("dealt a hand at a table we got up from: %v", err)
}
}
func TestBustingEndsTheSession(t *testing.T) {
s := table(t, Tiers[0], 1, 200)
s.Seats[You].Stack = 0
var evs []Event
s.endHand(&evs)
if s.Phase != PhaseDone {
t.Errorf("phase %q with no chips left, want done", s.Phase)
}
if s.Payout != 0 {
t.Errorf("payout %d for a busted player", s.Payout)
}
if !has(evs, "bust") {
t.Error("no bust event")
}
}
func TestATopUpCannotGoOverTheTableMax(t *testing.T) {
s := table(t, Tiers[0], 1, 200) // max buy is 200, and we're at it
if _, _, err := apply(s, Move{Kind: TopUp, Amount: 1}); err != ErrBadBuyIn {
t.Errorf("topped up over the table maximum: %v", err)
}
s = table(t, Tiers[0], 1, 100)
s, _, err := apply(s, Move{Kind: TopUp, Amount: 50})
if err != nil {
t.Fatal(err)
}
if s.Seats[You].Stack != 150 {
t.Errorf("stack is %d, want 150", s.Seats[You].Stack)
}
if s.BoughtIn != 150 {
t.Errorf("bought in for %d, want 150 — the top-up is real money too", s.BoughtIn)
}
}
func TestABuyInHasToBeInRange(t *testing.T) {
tier := Tiers[0]
for _, amount := range []int64{0, tier.MinBuy - 1, tier.MaxBuy + 1} {
if _, _, err := New(tier, SoloSeats(tier, 1, amount), 5, 1, 2); err != ErrBadBuyIn {
t.Errorf("buy-in of %d at a %d%d table: %v", amount, tier.MinBuy, tier.MaxBuy, err)
}
}
}
// ---- what the raw script carries ------------------------------------------
// The engine no longer redacts the event stream, and this pins why: a shared
// table has more than one human, so the engine cannot know who a stream is for.
// It emits every seat's hole cards, and the *view* layer builds each viewer's
// redacted copy — that per-seat redaction is the security boundary now, and it
// has its own test in the web package (TestHoldemViewNeverLeaksAnotherSeatsCards).
//
// So the engine-level contract flipped: the deal must carry a hole event for
// every dealt seat, or a viewer would have no cards of their own to be shown.
func TestTheDealScriptCarriesEverySeatsHole(t *testing.T) {
s := table(t, Tiers[0], 3, 200) // four seats: one human, three bots
s, evs, err := apply(s, Move{Kind: Deal})
if err != nil {
t.Fatal(err)
}
holes := map[int][]cards.Card{}
for _, e := range evs {
if e.Kind == "hole" {
holes[e.Seat] = e.Cards
}
}
for i := range s.Seats {
if s.Seats[i].State == Out {
continue
}
got := holes[i]
if len(got) != 2 {
t.Fatalf("seat %d got no hole event; the view has nothing to redact from", i)
}
if got[0] != s.Seats[i].Hole[0] || got[1] != s.Seats[i].Hole[1] {
t.Errorf("seat %d hole event %v disagrees with state %v", i, got, s.Seats[i].Hole)
}
}
}
// ---- hand strength ---------------------------------------------------------
func TestTheEvaluatorKnowsWhichHandIsBetter(t *testing.T) {
board := []cards.Card{
{Rank: 10, Suit: cards.Hearts}, {Rank: cards.Jack, Suit: cards.Hearts},
{Rank: cards.Queen, Suit: cards.Hearts}, {Rank: 2, Suit: cards.Spades},
{Rank: 7, Suit: cards.Clubs},
}
flush, _ := rankOf([2]cards.Card{{Rank: 3, Suit: cards.Hearts}, {Rank: 5, Suit: cards.Hearts}}, board)
straight, _ := rankOf([2]cards.Card{{Rank: cards.King, Suit: cards.Spades}, {Rank: cards.Ace, Suit: cards.Clubs}}, board)
royal, _ := rankOf([2]cards.Card{{Rank: cards.King, Suit: cards.Hearts}, {Rank: cards.Ace, Suit: cards.Hearts}}, board)
pair, _ := rankOf([2]cards.Card{{Rank: 7, Suit: cards.Spades}, {Rank: 4, Suit: cards.Diamonds}}, board)
if !(royal < flush && flush < straight && straight < pair) {
t.Errorf("hands rank royal=%d flush=%d straight=%d pair=%d — lower must be better, in that order",
royal, flush, straight, pair)
}
}
func TestEquityKnowsAcesAreGood(t *testing.T) {
rng := rand.New(rand.NewPCG(1, 1))
aces := equityOf([2]cards.Card{{Rank: cards.Ace, Suit: cards.Spades}, {Rank: cards.Ace, Suit: cards.Hearts}}, nil, 1, 2000, rng)
rags := equityOf([2]cards.Card{{Rank: 7, Suit: cards.Spades}, {Rank: 2, Suit: cards.Hearts}}, nil, 1, 2000, rng)
if aces.Strength() < 0.8 {
t.Errorf("pocket aces are worth %.2f heads-up, want about 0.85", aces.Strength())
}
if rags.Strength() > 0.4 {
t.Errorf("seven-deuce is worth %.2f heads-up, want about 0.35", rags.Strength())
}
if aces.Strength() <= rags.Strength() {
t.Error("seven-deuce is not better than pocket aces")
}
}
// The policy loads, and every node in it is a probability distribution.
func TestThePolicyLoads(t *testing.T) {
p := loadPolicy()
if len(p) < 1000 {
t.Fatalf("the CFR policy has %d nodes in it — it did not load, or it was never trained", len(p))
}
for key, probs := range p {
var sum float64
for _, v := range probs {
if v < 0 {
t.Fatalf("%s: a negative probability (%v)", key, probs)
}
sum += v
}
if sum < 0.99 || sum > 1.01 {
t.Fatalf("%s: the probabilities sum to %v, not 1", key, sum)
}
}
}
// TestTheBotsAreActuallyTrained is the test this game most needed and did not
// have.
//
// A bot that cannot find itself in the policy does not fail. It shrugs, plays the
// pot-odds rule, and looks exactly like a bot that is working — which is how
// gogobee shipped a trained poker AI whose policy was *never read once* for the
// entire life of the game. The trainer wrote its keys under IP/OOP and the table
// looked them up under BTN/SB/BB, and there was nothing anywhere that would have
// said so.
//
// So: deal real hands, let the bots think, and count how often the thinking lands
// in the table. Heads-up is the number that has to hold — that is what the policy
// was trained on. A six-handed table is a documented approximation of it and
// drops off as seats are added, which is why this only asserts on the duel.
func TestTheBotsAreActuallyTrained(t *testing.T) {
hits.Store(0)
misses.Store(0)
rng := rand.New(rand.NewPCG(11, 12))
for game := 0; game < 40; game++ {
tier := Tiers[1]
s, _, err := New(tier, SoloSeats(tier, 1, tier.MaxBuy), tier.RakePct, uint64(game), 5)
if err != nil {
t.Fatal(err)
}
for hand := 0; hand < 6 && s.Phase != PhaseDone; hand++ {
s, _, err = apply(s, Move{Kind: Deal})
if err != nil {
t.Fatal(err)
}
for s.Phase == PhaseBetting {
s, _, err = apply(s, randomMove(s, rng))
if err != nil {
t.Fatal(err)
}
}
}
}
h, m := hits.Load(), misses.Load()
if h+m < 100 {
t.Fatalf("the bots only made %d decisions — this test isn't measuring anything", h+m)
}
rate := float64(h) / float64(h+m)
if rate < 0.6 {
t.Fatalf("heads-up, the bots found themselves in the trained policy %.0f%% of the time "+
"(%d of %d decisions). They are playing the pot-odds fallback, which means the key the "+
"trainer writes and the key the table reads have drifted apart. See infoSet.",
rate*100, h, h+m)
}
t.Logf("heads-up policy hit rate: %.0f%% (%d of %d decisions)", rate*100, h, h+m)
}
// ---- helpers ---------------------------------------------------------------
func table(t *testing.T, tier Tier, bots int, buyIn int64) State {
t.Helper()
s, _, err := New(tier, SoloSeats(tier, bots, buyIn), tier.RakePct, 1, 2)
if err != nil {
t.Fatalf("new table: %v", err)
}
return s
}
// playOut folds every decision until the hand is over.
func playOut(t *testing.T, s State) State {
t.Helper()
for i := 0; s.Phase == PhaseBetting; i++ {
if i > 100 {
t.Fatal("the hand will not end")
}
move := Move{Kind: Fold}
if s.Owed(You) == 0 {
move = Move{Kind: Check}
}
var err error
s, _, err = apply(s, move)
if err != nil {
t.Fatalf("playing out: %v", err)
}
}
return s
}
func has(evs []Event, kind string) bool {
for _, e := range evs {
if e.Kind == kind {
return true
}
}
return false
}
// Where you sit is decided when the button moves, and a fold does not move it.
// Position walked the table with nextIn, which steps over folded seats while the
// seat count still includes them — so as players mucked, the labels slid round
// and a six-handed felt printed CO on three different seats at once. The badge is
// the only thing that reads this, which is exactly why nothing caught it.
func TestPositionsDoNotMoveWhenSeatsFold(t *testing.T) {
s := table(t, Tiers[0], 5, 200) // six-handed
s, _, _ = apply(s, Move{Kind: Deal})
before := make([]string, len(s.Seats))
for i := range s.Seats {
before[i] = s.Position(i)
}
// Every seat has its own label, and the ones a six-max table prints are these.
seen := map[string]int{}
for _, p := range before {
seen[p]++
}
for _, want := range []string{"BTN", "SB", "BB", "UTG", "MP", "CO"} {
if seen[want] != 1 {
t.Errorf("six-handed: %q appears %d times, want exactly once — got %v",
want, seen[want], before)
}
}
// Now fold seats out of the hand, one at a time. Nobody's position changes by
// mucking — folding is done to the state directly because a fold in the engine
// belongs to whoever is to act, and what is under test is the label, not the turn.
for i := range s.Seats {
if i == You || s.Seats[i].State != Active {
continue
}
s.Seats[i].State = Folded
for j := range s.Seats {
if got := s.Position(j); got != before[j] {
t.Fatalf("seat %d was %q and is now %q after seat %d folded — position is "+
"where you sit, not who is left", j, before[j], got, i)
}
}
}
}

View File

@@ -0,0 +1,112 @@
package holdem
import (
"math/rand/v2"
"testing"
)
// The reshape's own guard: a table with more than one human actually plays, and
// the chips still conserve when the person to act is not always seat zero.
//
// The solo suite proves the engine still behaves as it did; this proves the thing
// that changed. Two humans and two bots sit down, and the driver plays whichever
// human the action stops on — which is the whole point of the multiway advance:
// it runs the bots itself and hands control back at every *human* seat, not just
// at seat zero.
// randomMoveFor picks a legal move for a specific seat, the multiway sibling of
// randomMove. It never folds when it can check, so hands actually develop.
func randomMoveFor(s State, seat int, rng *rand.Rand) Move {
owed := s.Owed(seat)
var legal []Move
if owed > 0 {
legal = append(legal, Move{Kind: Fold}, Move{Kind: Call})
} else {
legal = append(legal, Move{Kind: Check})
}
if s.Seats[seat].Stack > owed && s.canBet() {
if to := s.MinRaiseTo(seat); to < s.MaxRaiseTo(seat) {
legal = append(legal, Move{Kind: Raise, To: to})
}
}
return legal[rng.IntN(len(legal))]
}
func TestMultiwayChipsAreConserved(t *testing.T) {
for game := 0; game < 100; game++ {
rng := rand.New(rand.NewPCG(uint64(game), 71))
tier := Tiers[game%len(Tiers)]
// Two humans, two bots. The humans sit at 0 and 2 so the action genuinely
// lands on a non-zero human seat, which is the case the old engine could not
// have reached.
seats := []SeatConfig{
{Name: "Ana", Stack: tier.MaxBuy},
{Name: "Bot A", Bot: true, Stack: tier.MaxBuy},
{Name: "Bo", Stack: tier.MaxBuy},
{Name: "Bot B", Bot: true, Stack: tier.MaxBuy},
}
s, _, err := New(tier, seats, tier.RakePct, uint64(game), 7)
if err != nil {
t.Fatalf("new table: %v", err)
}
want := chipsAt(s)
for hand := 0; hand < 8 && s.Phase == PhaseHandOver; hand++ {
var evs []Event
s, evs, err = ApplyMove(s, 0, Move{Kind: Deal})
if err != nil {
t.Fatalf("game %d hand %d: deal: %v", game, hand, err)
}
want += reloaded(evs)
check(t, s, want, game, hand, "deal")
for step := 0; s.Phase == PhaseBetting; step++ {
if step > 400 {
t.Fatalf("game %d hand %d: the hand will not end", game, hand)
}
seat := s.ToAct
if s.Seats[seat].Bot {
t.Fatalf("game %d: advance stopped on bot seat %d — it should run bots itself", game, seat)
}
s, _, err = ApplyMove(s, seat, randomMoveFor(s, seat, rng))
if err != nil {
t.Fatalf("game %d hand %d seat %d: %v", game, hand, seat, err)
}
check(t, s, want, game, hand, "move")
}
}
}
}
// TestMultiwayRejectsOutOfTurnMoves pins that a human cannot act when it is
// another human's turn — the betting move is legal only from the seat to act.
func TestMultiwayRejectsOutOfTurnMoves(t *testing.T) {
tier := Tiers[0]
seats := []SeatConfig{
{Name: "Ana", Stack: tier.MaxBuy},
{Name: "Bo", Stack: tier.MaxBuy},
}
s, _, err := New(tier, seats, tier.RakePct, 3, 9)
if err != nil {
t.Fatal(err)
}
s, _, err = ApplyMove(s, 0, Move{Kind: Deal})
if err != nil {
t.Fatal(err)
}
if s.Phase != PhaseBetting {
t.Fatalf("want a live hand, got phase %s", s.Phase)
}
// Whoever is not to act tries to move. It must be refused, and nothing must
// change.
other := 1 - s.ToAct
before := chipsAt(s)
if _, _, err := ApplyMove(s, other, Move{Kind: Call}); err != ErrNotYourTurn {
t.Fatalf("want ErrNotYourTurn from the seat not to act, got %v", err)
}
if got := chipsAt(s); got != before {
t.Errorf("a refused move moved chips: %d -> %d", before, got)
}
}

Binary file not shown.

View File

@@ -0,0 +1,431 @@
package holdem
import (
"bytes"
"encoding/gob"
"fmt"
"io"
"math/rand/v2"
"sync"
"pete/internal/games/cards"
)
// The trainer.
//
// This is counterfactual regret minimisation, and what it produces is policy.gob
// — the table the bots read at the table. It is not on any request path; it runs
// from cmd/holdem-train, for half an hour, and then it is a file.
//
// The one thing worth understanding about it: **it plays the real game.** Every
// move it explores goes through Step, which is the same reducer the felt calls,
// so the blinds, the min-raise, the street completion and the money are the ones
// a player will actually meet. Its info-set key comes out of State.spot, which is
// the same function the bots look themselves up with.
//
// That is not tidiness, it is the whole lesson of the policy this replaces. That
// one was trained against a hand-written model of poker sitting beside the real
// engine — a model where a call always ended the street, the big blind had no
// option, and the payoff was half the pot no matter who had put what in. Then it
// was looked up under a key the trainer never wrote. The result was a 3.4MB file
// that had never once been read, and nobody could tell, because a policy miss is
// not an error. It just quietly isn't there.
//
// So: one engine, one key function, and a test that fails if the bots stop
// finding themselves in the table.
// How much of the game tree to explore. Two raises a street keeps the tree small
// enough to converge; a third barely changes how anybody plays and multiplies the
// nodes.
const (
maxRaisesPerStreet = 2
maxDepth = 40
trainMCIters = 60 // noisy, but it is only picking a bucket
)
// regrets is what CFR accumulates: how much better each action would have been.
type regrets map[string]*[numActions]float64
// Trained is the file the bots read.
type Trained struct {
Strategy map[string][numActions]float64
Meta TrainMeta
}
// TrainMeta is what the policy can say about itself. Worth having: a policy is
// otherwise an opaque three megabytes and there is no way to tell a good one from
// a stale one by looking.
type TrainMeta struct {
Iterations int
Stakes string
Depths string
Nodes int
}
// ---- preflop, measured once ------------------------------------------------
var (
preflopOnce sync.Once
preflopTable [13][13]Equity // [hi][lo] offsuit, [lo][hi] suited, diagonal pairs
)
// preflopEquity is the equity of a starting hand heads-up. There are only 169
// hands that differ from each other, so they are measured properly, once, and
// then it is a lookup — which matters twice: it takes the noise out of a bucket
// boundary, and the trainer visits preflop on every single iteration.
func preflopEquity(hole [2]cards.Card) Equity {
preflopOnce.Do(func() {
rng := cards.NewRNG(20260714, 1)
for a := cards.Ace; a <= cards.King; a++ {
for b := a; b <= cards.King; b++ {
lo, hi := rankIdx(a), rankIdx(b)
// Suited, and the pairs (which can only be offsuit) on the diagonal.
s1 := cards.Card{Rank: a, Suit: cards.Spades}
s2 := cards.Card{Rank: b, Suit: cards.Spades}
if a == b {
s2.Suit = cards.Hearts
}
preflopTable[lo][hi] = equityOf([2]cards.Card{s1, s2}, nil, 1, 10000, rng)
if a != b {
o2 := cards.Card{Rank: b, Suit: cards.Hearts}
preflopTable[hi][lo] = equityOf([2]cards.Card{s1, o2}, nil, 1, 10000, rng)
}
}
}
})
lo, hi := rankIdx(hole[0].Rank), rankIdx(hole[1].Rank)
if lo > hi {
lo, hi = hi, lo
}
if hole[0].Suit == hole[1].Suit {
return preflopTable[lo][hi] // suited, and the pairs sit here too
}
if lo == hi {
return preflopTable[lo][hi]
}
return preflopTable[hi][lo] // offsuit
}
// rankIdx maps a rank to 012, with the ace high — which is what it is, before
// the flop.
func rankIdx(r cards.Rank) int {
if r == cards.Ace {
return 12
}
return int(r) - 2
}
// ---- the traversal ---------------------------------------------------------
// Train runs external-sampling MCCFR for n hands and returns the average
// strategy. Each worker keeps its own tables and they are summed at the end,
// which is what makes this embarrassingly parallel and is the only reason it
// finishes in half an hour.
func Train(n, workers int, t Tier, minBB, maxBB int64, seed uint64, progress func(done int)) *Trained {
if workers < 1 {
workers = 1
}
type table struct {
reg regrets
avg regrets
}
out := make([]table, workers)
var wg sync.WaitGroup
var done sync.Mutex
completed := 0
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
tr := &trainer{
reg: regrets{},
avg: regrets{},
tier: t,
minBB: minBB,
maxBB: maxBB,
rng: cards.NewRNG(seed, uint64(w)+1),
}
share := n / workers
if w < n%workers {
share++
}
for i := 0; i < share; i++ {
tr.iterate(uint64(w)<<40 | uint64(i))
// i+1, not i: the check fired on the very first pass and credited two
// thousand hands before a single one had been walked, which with thirty
// workers made the first ETA sixty thousand hands optimistic.
if progress != nil && (i+1)%2000 == 0 {
done.Lock()
completed += 2000
c := completed
done.Unlock()
progress(c)
}
}
out[w] = table{tr.reg, tr.avg}
}(w)
}
wg.Wait()
// Sum the workers' average-strategy tables, then normalise each node into the
// probabilities a bot will actually play.
total := regrets{}
for _, tab := range out {
for key, v := range tab.avg {
acc, ok := total[key]
if !ok {
acc = &[numActions]float64{}
total[key] = acc
}
for i, x := range v {
acc[i] += x
}
}
}
strategy := make(map[string][numActions]float64, len(total))
for key, v := range total {
var sum float64
for _, x := range v {
sum += x
}
var probs [numActions]float64
if sum > 0 {
for i, x := range v {
probs[i] = x / sum
}
} else {
for i := range probs {
probs[i] = 1.0 / numActions
}
}
strategy[key] = probs
}
return &Trained{
Strategy: strategy,
Meta: TrainMeta{
Iterations: n,
Stakes: fmt.Sprintf("%d/%d", t.SB, t.BB),
Depths: fmt.Sprintf("%d%d BB", minBB, maxBB),
Nodes: len(strategy),
},
}
}
type trainer struct {
reg regrets
avg regrets
tier Tier
minBB int64
maxBB int64
rng *rand.Rand
// A hand's equity on a given street depends on the cards and nothing else —
// not on how the betting went to get there. The deck is fixed for the whole
// iteration, so the flop is the same flop down every branch, and this is
// measured once per seat per street instead of once per node.
eq [2][4]Equity
have [2][4]bool
}
// iterate deals one hand and walks it once for each player.
//
// The stack depth is drawn fresh every hand, across the whole range the table
// allows. This is the fix for the policy that came before: it was trained at ten
// big blinds and nothing else, so four out of five spots in a real cash game fell
// outside anything it had ever seen. A hand of poker is a different game at 20
// big blinds than at 100 — that is most of what makes it a game — and the bots
// have to have played both.
func (tr *trainer) iterate(id uint64) {
depth := tr.minBB
if tr.maxBB > tr.minBB {
depth += tr.rng.Int64N(tr.maxBB - tr.minBB + 1)
}
stack := depth * tr.tier.BB
// No rake while learning. The bots should learn to play poker, not to beat a
// fee, and the fee is the house's business.
t := tr.tier
t.RakePct = 0
s, err := open(t, stack, stack, id, tr.rng.Uint64())
if err != nil {
return
}
start := [2]int64{s.Seats[0].Stack + s.Seats[0].Bet, s.Seats[1].Stack + s.Seats[1].Bet}
tr.have = [2][4]bool{} // one deal, one set of boards, one set of equities
for me := 0; me < 2; me++ {
tr.walk(s.clone(), me, start, 0)
}
}
// equity is the cached measurement for this seat on this street.
func (tr *trainer) equity(s State, seat int) Equity {
st := s.Street
if st > River {
st = River
}
if !tr.have[seat][st] {
tr.eq[seat][st] = s.equityFor(seat, trainMCIters, tr.rng)
tr.have[seat][st] = true
}
return tr.eq[seat][st]
}
// walk returns what the hand is worth to `me`, in chips, from here.
func (tr *trainer) walk(s State, me int, start [2]int64, depth int) float64 {
if s.Phase != PhaseBetting || depth > maxDepth {
// The hand is over (or we have gone far enough to call it over). What it was
// worth is simply what the player has now against what they sat down with —
// the real number, out of the real engine, side pots and all.
return float64(s.Seats[me].Stack - start[me])
}
seat := s.ToAct
key := s.spotKey(seat, tr.equity(s, seat))
mask := s.mask(seat)
if raises(s.History) >= maxRaisesPerStreet {
mask[actRaiseHalf], mask[actRaisePot] = false, false
}
reg := tr.reg[key]
if reg == nil {
reg = &[numActions]float64{}
tr.reg[key] = reg
}
strat := match(*reg, mask)
// The opponent's turn: sample one line and follow it. That is the "external
// sampling" part, and it is what keeps a hand from costing 5^12 traversals.
if seat != me {
avg := tr.avg[key]
if avg == nil {
avg = &[numActions]float64{}
tr.avg[key] = avg
}
for i, p := range strat {
avg[i] += p
}
return tr.walk(tr.play(s, seat, sample(strat, tr.rng)), me, start, depth+1)
}
// Our turn: try everything, and regret what we didn't do.
var values [numActions]float64
var node float64
for a := 0; a < numActions; a++ {
if !mask[a] {
continue
}
values[a] = tr.walk(tr.play(s, seat, a), me, start, depth+1)
node += strat[a] * values[a]
}
for a := 0; a < numActions; a++ {
if mask[a] {
reg[a] += values[a] - node
}
}
return node
}
// play applies one abstract action through the real reducer.
func (tr *trainer) play(s State, seat, action int) State {
next, _, err := step(s.clone(), s.moveFor(action, seat))
if err != nil {
// The mask and the rules disagreed, which is a bug in one of them. Fold and
// carry on rather than poison the whole run.
next, _, err = step(s.clone(), Move{Kind: Fold})
if err != nil {
return s
}
}
return next
}
// match is regret matching: play each action in proportion to how much you wish
// you had played it. An action nobody regrets not taking gets played uniformly.
func match(reg [numActions]float64, mask [numActions]bool) [numActions]float64 {
var strat [numActions]float64
var sum float64
for i, r := range reg {
if mask[i] && r > 0 {
sum += r
}
}
if sum > 0 {
for i, r := range reg {
if mask[i] && r > 0 {
strat[i] = r / sum
}
}
return strat
}
n := 0
for _, ok := range mask {
if ok {
n++
}
}
if n == 0 {
strat[actCallCheck] = 1
return strat
}
for i, ok := range mask {
if ok {
strat[i] = 1 / float64(n)
}
}
return strat
}
func sample(strat [numActions]float64, rng *rand.Rand) int {
r := rng.Float64()
var sum float64
for i, p := range strat {
sum += p
if r < sum {
return i
}
}
return actCallCheck
}
// raises counts the bets and raises on this street, which is what the tree is
// capped on.
func raises(history string) int {
n := 0
for _, c := range history {
if c == 'r' || c == 'R' {
n++
}
}
return n
}
// ---- the file --------------------------------------------------------------
// Save writes a trained policy.
func Save(w io.Writer, t *Trained) error { return gob.NewEncoder(w).Encode(t) }
// Load reads one. It is only used by the tests — the bots read the embedded copy.
func Load(r io.Reader) (*Trained, error) {
var t Trained
if err := gob.NewDecoder(r).Decode(&t); err != nil {
return nil, err
}
return &t, nil
}
// loadTrained decodes the embedded policy in the new format.
func loadTrained(b []byte) (*Trained, error) { return Load(bytes.NewReader(b)) }

View File

@@ -0,0 +1,684 @@
// Package klondike is a pure Klondike solitaire engine, played for chips.
//
// Same seam as blackjack and hangman: ApplyMove(state, move) (state, events,
// error), where an error means the move was illegal and nothing else. The state
// is a plain value, so a game survives a redeploy and replays from its seed.
//
// The casino version is Vegas scoring, which is the only way solitaire has ever
// been a gambling game and the only shape that makes sense with money on it.
// You do not win or lose the deal. You *buy the deck* for your stake, and every
// card you get home to a foundation pays a slice of it back. Fifty-two cards
// home pays the tier's full multiple; nothing home pays nothing. You can stop
// whenever you like and keep what you have banked, which is what makes a game
// that has gone dead a decision rather than a wall.
//
// There is no undo. The stake is spent the moment the deck is bought, so an undo
// would be a way to walk a losing board backwards until it wins.
package klondike
import (
"errors"
"math"
"math/rand/v2"
"strconv"
"strings"
"pete/internal/games/cards"
)
// Errors an illegal move can produce.
var (
ErrGameOver = errors.New("klondike: the game is already over")
ErrUnknownMove = errors.New("klondike: unknown move")
ErrBadBet = errors.New("klondike: bet must be positive")
ErrUnknownTier = errors.New("klondike: no such tier")
ErrBadPile = errors.New("klondike: no such pile")
ErrEmptyPile = errors.New("klondike: there is nothing there to move")
ErrNotASequence = errors.New("klondike: those cards aren't a run you can lift")
ErrWontGo = errors.New("klondike: that card doesn't go there")
ErrNoDraw = errors.New("klondike: there is nothing left to turn over")
ErrNoPasses = errors.New("klondike: you've used your passes through the stock")
ErrNothingHome = errors.New("klondike: nothing can go home right now")
)
// Piles is the number of tableau columns. Foundations is one per suit.
const (
Piles = 7
Foundations = 4
FullDeck = 52
)
// Tier is a difficulty, chosen with the bet. The two dials are how many cards
// the stock turns over at a time and how many times you may go through it —
// which between them are the whole difficulty of Klondike. Turning three at a
// time hides two of every three cards behind a card you may never reach; a
// single pass means the ones you leave behind are gone for good.
//
// The multiple pays for that. Cutthroat is the cruellest deal in the room and
// pays 3.4×, which means you are ahead from sixteen cards home even though most
// of those boards never clear.
type Tier struct {
Slug string `json:"slug"`
Name string `json:"name"`
Draw int `json:"draw"` // cards turned over per pull on the stock
Passes int `json:"passes"` // times through the stock; 0 means unlimited
Base float64 `json:"base"` // what a full 52 cards home pays, as a multiple of the stake
Blurb string `json:"blurb"`
}
// BreakEven is how many cards have to reach the foundations before the player is
// square with the house. It's the number the felt actually quotes, because
// "1.4×" tells a player nothing about a game where the multiple is paid per card.
func (t Tier) BreakEven() int {
if t.Base <= 0 {
return FullDeck
}
n := int(math.Ceil(float64(FullDeck) / t.Base))
if n > FullDeck {
return FullDeck
}
return n
}
// Tiers are the three deals.
var Tiers = []Tier{
{Slug: "patient", Name: "Patient", Draw: 1, Passes: 0, Base: 1.4,
Blurb: "One card at a time, through the stock as often as you like."},
{Slug: "vegas", Name: "Vegas", Draw: 3, Passes: 3, Base: 2.2,
Blurb: "Three at a time, three times round. The house game."},
{Slug: "cutthroat", Name: "Cutthroat", Draw: 3, Passes: 1, Base: 3.4,
Blurb: "Three at a time, one pass. What you leave behind is gone."},
}
// TierBySlug finds a tier by the name the browser sent.
func TierBySlug(slug string) (Tier, error) {
for _, t := range Tiers {
if t.Slug == slug {
return t, nil
}
}
return Tier{}, ErrUnknownTier
}
// Phase is where the game is.
type Phase string
const (
PhasePlaying Phase = "playing"
PhaseDone Phase = "done"
)
// Outcome is how it ended. Note there is no "lost": a board that goes dead is
// cashed, for whatever it made. Solitaire's failure mode is a board you can't
// improve, and the honest thing to do with one is pay out what's on it.
type Outcome string
const (
OutcomeNone Outcome = ""
OutcomeCleared Outcome = "cleared" // all 52 home
OutcomeCashed Outcome = "cashed" // the player stopped and took the board
)
// Pile is one tableau column: a face-down stack with a face-up run on top of it.
// Down is the part the browser never sees.
type Pile struct {
Down []cards.Card `json:"down"`
Up []cards.Card `json:"up"`
}
// State is one game. The stock and every Down card are in here, which is exactly
// why this value never leaves the server.
type State struct {
Tier Tier `json:"tier"`
Stock cards.Deck `json:"stock"`
Waste []cards.Card `json:"waste"`
Table [Piles]Pile `json:"table"`
Found [Foundations][]cards.Card `json:"found"` // indexed by suit
Recycles int `json:"recycles"` // times the waste has gone back under
Moves int `json:"moves"`
RakePct float64 `json:"rake_pct"`
Bet int64 `json:"bet"`
Phase Phase `json:"phase"`
Outcome Outcome `json:"outcome"`
Payout int64 `json:"payout"`
Rake int64 `json:"rake"`
}
// Event is something the table animates. The engine emits them rather than
// leaving the browser to diff two boards and guess what moved — a card that
// slides from a column to a foundation and a card that was simply redrawn there
// are the same diff and very different things to watch.
//
// Home and Pays ride on every event, so the meter on the felt is always quoting
// a number the engine worked out. The browser never does this arithmetic: it did
// once, and the felt advertised a payout the house didn't honour.
type Event struct {
Kind string `json:"kind"` // "deal" | "draw" | "recycle" | "move" | "home" | "flip" | "settle"
Cards []cards.Card `json:"cards,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Text string `json:"text,omitempty"`
Home int `json:"home"`
Pays int64 `json:"pays"`
}
// Move is a player action.
//
// Home is its own kind rather than a Move To a foundation the player picked,
// because there is only ever one foundation a card can go to and asking the
// player to name it would be a quiz about suit ordering. The browser sends
// "this card, home"; the engine finds the pile.
type Move struct {
Kind string `json:"kind"` // "draw" | "move" | "home" | "auto" | "concede"
From string `json:"from"` // "waste" | "t0".."t6" | "f0".."f3"
To string `json:"to"` // "t0".."t6" | "f0".."f3"
Count int `json:"count"` // how many cards off the end of a tableau run; 0 means 1
}
// New deals a game.
func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
if bet <= 0 {
return State{}, nil, ErrBadBet
}
if t.Draw < 1 {
return State{}, nil, ErrUnknownTier
}
d := cards.NewDeck(1)
d.Shuffle(rng)
return deal(bet, t, d, rakePct)
}
// deal lays the board out. Split out from New so a test can pin the deck
// instead of the seed.
func deal(bet int64, t Tier, d cards.Deck, rakePct float64) (State, []Event, error) {
if bet <= 0 {
return State{}, nil, ErrBadBet
}
if len(d) != FullDeck {
return State{}, nil, errors.New("klondike: a solitaire deck is 52 cards")
}
s := State{Tier: t, Bet: bet, RakePct: rakePct, Phase: PhasePlaying}
// The classic lay-out: column i gets i+1 cards, the last of them face up.
for i := 0; i < Piles; i++ {
for j := 0; j <= i; j++ {
c, _ := d.Draw()
if j == i {
s.Table[i].Up = append(s.Table[i].Up, c)
} else {
s.Table[i].Down = append(s.Table[i].Down, c)
}
}
}
s.Stock = d
return s, []Event{s.event("deal", nil, "", "")}, nil
}
// ApplyMove is the engine. A legal move in, the new board and what happened out.
// An error means the move was illegal and the caller's state is untouched.
func ApplyMove(s State, m Move) (State, []Event, error) {
if s.Phase == PhaseDone {
return s, nil, ErrGameOver
}
// The move is played against a copy, and an illegal one hands the original
// back untouched. Nothing below mutates before it has decided the move is
// legal — but "nothing below mutates early" is an invariant seven functions
// have to keep, and this is one line that doesn't need them to.
orig := s
s = s.clone()
var evs []Event
var err error
switch m.Kind {
case "draw":
evs, err = s.draw()
case "move":
evs, err = s.move(m.From, m.To, m.Count)
case "home":
evs, err = s.home(m.From)
case "auto":
evs, err = s.auto()
case "concede":
s.settle(OutcomeCashed, &evs)
return s, evs, nil
default:
return orig, nil, ErrUnknownMove
}
if err != nil {
return orig, nil, err
}
s.Moves++
// A cleared board settles itself. Nothing else does: a board with no move left
// on it is not something the engine gets to decide, because "no move left" in
// Klondike depends on cards nobody has turned over yet.
if s.cleared() {
s.settle(OutcomeCleared, &evs)
}
return s, evs, nil
}
// ---- the moves -------------------------------------------------------------
// draw turns cards off the stock, or puts the waste back under it if the stock
// is spent and the tier still owes a pass.
func (s *State) draw() ([]Event, error) {
if len(s.Stock) == 0 {
if len(s.Waste) == 0 {
return nil, ErrNoDraw
}
// Passes is how many times you may go *through* the stock, so the number of
// times you may turn it back over is one less than that. Zero means unlimited.
if s.Tier.Passes > 0 && s.Recycles >= s.Tier.Passes-1 {
return nil, ErrNoPasses
}
// The waste is turned over as a block, not reshuffled — so the card that
// comes out first on the next pass is the one that came out first on this
// one. Which means no reversal: the waste's *bottom* card is the one your
// hand lands on when you flip the pile, and the bottom card is the one that
// was drawn first. Reversing here would deal a different game on every pass
// and quietly break the seed in the audit log.
s.Stock = cards.Deck(s.Waste)
s.Waste = nil
s.Recycles++
return []Event{s.event("recycle", nil, "waste", "stock")}, nil
}
n := s.Tier.Draw
if n > len(s.Stock) {
n = len(s.Stock)
}
drawn := make([]cards.Card, 0, n)
for i := 0; i < n; i++ {
c, _ := s.Stock.Draw()
drawn = append(drawn, c)
s.Waste = append(s.Waste, c)
}
return []Event{s.event("draw", drawn, "stock", "waste")}, nil
}
// move takes cards from one pile and puts them on another.
func (s *State) move(from, to string, count int) ([]Event, error) {
if count < 1 {
count = 1
}
lifted, err := s.peek(from, count)
if err != nil {
return nil, err
}
if !s.accepts(to, lifted) {
return nil, ErrWontGo
}
if err := s.take(from, count); err != nil {
return nil, err
}
s.put(to, lifted)
kind := "move"
if isFoundation(to) {
kind = "home"
}
evs := []Event{s.event(kind, lifted, from, to)}
return s.withFlip(from, evs), nil
}
// home sends the top card of a pile to the foundation that will take it. There
// is only ever one, so the player doesn't have to say which.
func (s *State) home(from string) ([]Event, error) {
top, err := s.peek(from, 1)
if err != nil {
return nil, err
}
to := "f" + strconv.Itoa(int(top[0].Suit))
if !s.accepts(to, top) {
return nil, ErrWontGo
}
return s.move(from, to, 1)
}
// auto sends everything that can go home, home, and keeps doing it until nothing
// else can. It is the finish button, and it is also the shortcut for the tail of
// a board that is already decided.
//
// It can cost you: a two you needed on the tableau is a two that has gone home.
// That is the player's call to make by pressing it, and it is the same call the
// button makes in every other solitaire ever written.
func (s *State) auto() ([]Event, error) {
var evs []Event
for {
moved := false
for _, from := range sources() {
top, err := s.peek(from, 1)
if err != nil {
continue
}
to := "f" + strconv.Itoa(int(top[0].Suit))
if !s.accepts(to, top) {
continue
}
one, err := s.move(from, to, 1)
if err != nil {
continue
}
evs = append(evs, one...)
moved = true
}
if !moved {
break
}
}
if len(evs) == 0 {
return nil, ErrNothingHome
}
return evs, nil
}
// sources are the piles auto() will lift a card off, in the order it tries them.
func sources() []string {
out := make([]string, 0, Piles+1)
out = append(out, "waste")
for i := 0; i < Piles; i++ {
out = append(out, "t"+strconv.Itoa(i))
}
return out
}
// withFlip turns up the card a tableau column was hiding, if taking from it left
// its face-down stack exposed. This is the only thing in the game that reveals a
// card the player hadn't earned yet, so it is the only place it can happen.
func (s *State) withFlip(from string, evs []Event) []Event {
i, ok := tableauIndex(from)
if !ok {
return evs
}
p := &s.Table[i]
if len(p.Up) > 0 || len(p.Down) == 0 {
return evs
}
c := p.Down[len(p.Down)-1]
p.Down = p.Down[:len(p.Down)-1]
p.Up = append(p.Up, c)
return append(evs, s.event("flip", []cards.Card{c}, from, from))
}
// ---- piles -----------------------------------------------------------------
// peek returns the top `count` cards of a pile without taking them, and refuses
// a run that isn't one you could lift: a tableau run has to descend in rank and
// alternate colour all the way down, exactly as it does on the felt.
func (s *State) peek(name string, count int) ([]cards.Card, error) {
switch {
case name == "waste":
if count != 1 {
return nil, ErrNotASequence // the waste is a pile, not a run: one card, the top one
}
if len(s.Waste) == 0 {
return nil, ErrEmptyPile
}
return []cards.Card{s.Waste[len(s.Waste)-1]}, nil
case isFoundation(name):
i, ok := foundationIndex(name)
if !ok {
return nil, ErrBadPile
}
if count != 1 {
return nil, ErrNotASequence
}
f := s.Found[i]
if len(f) == 0 {
return nil, ErrEmptyPile
}
return []cards.Card{f[len(f)-1]}, nil
default:
i, ok := tableauIndex(name)
if !ok {
return nil, ErrBadPile
}
up := s.Table[i].Up
if len(up) == 0 {
return nil, ErrEmptyPile
}
if count > len(up) {
return nil, ErrNotASequence
}
run := up[len(up)-count:]
if !isRun(run) {
return nil, ErrNotASequence
}
return append([]cards.Card(nil), run...), nil
}
}
// take removes the top `count` cards. peek has already vetted them.
func (s *State) take(name string, count int) error {
switch {
case name == "waste":
s.Waste = s.Waste[:len(s.Waste)-count]
return nil
case isFoundation(name):
i, _ := foundationIndex(name)
s.Found[i] = s.Found[i][:len(s.Found[i])-count]
return nil
default:
i, ok := tableauIndex(name)
if !ok {
return ErrBadPile
}
s.Table[i].Up = s.Table[i].Up[:len(s.Table[i].Up)-count]
return nil
}
}
// put drops cards onto a pile. accepts has already vetted them.
func (s *State) put(name string, cs []cards.Card) {
if isFoundation(name) {
i, _ := foundationIndex(name)
s.Found[i] = append(s.Found[i], cs...)
return
}
i, _ := tableauIndex(name)
s.Table[i].Up = append(s.Table[i].Up, cs...)
}
// accepts is the rule the whole game is made of: what may be put where.
//
// A foundation takes its own suit in order from the ace, one card at a time. A
// tableau column takes a run that descends by one and alternates colour from its
// top card, and an empty column takes a King and nothing else.
func (s *State) accepts(name string, cs []cards.Card) bool {
if len(cs) == 0 {
return false
}
if isFoundation(name) {
i, ok := foundationIndex(name)
if !ok || len(cs) != 1 {
return false
}
c := cs[0]
return int(c.Suit) == i && int(c.Rank) == len(s.Found[i])+1
}
i, ok := tableauIndex(name)
if !ok {
return false
}
if !isRun(cs) {
return false
}
up := s.Table[i].Up
if len(up) == 0 {
// An empty column is the most valuable thing on the board, so it costs a
// King to take one. A column with cards still face-down under it is not
// empty, and Up being empty there can't happen: withFlip turns one over.
return cs[0].Rank == cards.King && len(s.Table[i].Down) == 0
}
top := up[len(up)-1]
return int(cs[0].Rank) == int(top.Rank)-1 && cs[0].Red() != top.Red()
}
// isRun reports whether these cards, in this order, are a tableau sequence:
// descending by one, alternating colour.
func isRun(cs []cards.Card) bool {
for i := 1; i < len(cs); i++ {
if int(cs[i].Rank) != int(cs[i-1].Rank)-1 || cs[i].Red() == cs[i-1].Red() {
return false
}
}
return true
}
func isFoundation(name string) bool { return strings.HasPrefix(name, "f") }
func tableauIndex(name string) (int, bool) { return pileIndex(name, "t", Piles) }
func foundationIndex(name string) (int, bool) { return pileIndex(name, "f", Foundations) }
func pileIndex(name, prefix string, n int) (int, bool) {
if !strings.HasPrefix(name, prefix) {
return 0, false
}
i, err := strconv.Atoi(name[len(prefix):])
if err != nil || i < 0 || i >= n {
return 0, false
}
return i, true
}
// ---- the money -------------------------------------------------------------
// Home is how many cards have reached the foundations. It is the only number in
// this game that the payout depends on.
func (s State) Home() int {
n := 0
for _, f := range s.Found {
n += len(f)
}
return n
}
// PerCard is what one card home is worth, before the rake. The felt quotes this
// because "2.2×" tells a player nothing about a game where the multiple is paid
// out a fifty-second at a time.
func (s State) PerCard() float64 {
return float64(s.Bet) * s.Tier.Base / float64(FullDeck)
}
// Earned is the gross: what the cards home have bought back, before the house
// takes anything. Computed from the total rather than card by card, so 52 cards
// home pays the tier's multiple exactly instead of the multiple less 52 roundings.
func (s State) Earned() int64 {
return int64(math.Floor(float64(s.Bet) * s.Tier.Base * float64(s.Home()) / float64(FullDeck)))
}
// Pays is what stopping *right now* would actually put back on the player's
// stack: the gross, less the house's cut of anything above the stake.
//
// The felt shows this number while the game is still running and settle() lands
// on it, and they are the same function for the reason hangman's are: the moment
// they are two sums, the table is quoting a payout it doesn't honour.
//
// Unlike the other games it can be less than the stake, and can be zero. That is
// the game — you bought the deck, and a deck that gives you nothing owes you
// nothing.
func (s State) Pays() int64 {
total := s.Earned()
profit := total - s.Bet
if profit > 0 {
rake := int64(math.Floor(float64(profit) * s.RakePct))
if rake > 0 {
total -= rake
}
}
return total
}
// rakeNow is the house's cut if the board were cashed right now — the other half
// of what Pays works out.
func (s State) rakeNow() int64 {
profit := s.Earned() - s.Bet
if profit <= 0 {
return 0
}
rake := int64(math.Floor(float64(profit) * s.RakePct))
if rake < 0 {
return 0
}
return rake
}
// Net is what the game did to the player's stack.
func (s State) Net() int64 {
if s.Phase != PhaseDone {
return 0
}
return s.Payout - s.Bet
}
// cleared reports whether every card is home.
func (s State) cleared() bool { return s.Home() == FullDeck }
// CanAuto reports whether anything can go home at all — which is what greys the
// finish button out rather than letting it be pressed at a board that has nothing
// for it.
func (s State) CanAuto() bool {
for _, from := range sources() {
top, err := (&s).peek(from, 1)
if err != nil {
continue
}
if (&s).accepts("f"+strconv.Itoa(int(top[0].Suit)), top) {
return true
}
}
return false
}
// PassesLeft is how many more times the player may go through the stock,
// counting the one they are in. -1 means unlimited.
func (s State) PassesLeft() int {
if s.Tier.Passes <= 0 {
return -1
}
left := s.Tier.Passes - s.Recycles
if left < 0 {
return 0
}
return left
}
// settle closes the game at whatever is on the board. Same rule as everywhere
// else in the room: the rake comes out of winnings, never out of the stake.
func (s *State) settle(o Outcome, evs *[]Event) {
s.Outcome = o
s.Phase = PhaseDone
s.Payout = s.Pays()
s.Rake = s.rakeNow()
*evs = append(*evs, s.event("settle", nil, "", string(o)))
}
// event stamps an event with the two numbers the felt's meter reads off it, so
// the browser never has to work out what the board is worth.
func (s State) event(kind string, cs []cards.Card, from, to string) Event {
return Event{
Kind: kind, Cards: cs, From: from, To: to,
Home: s.Home(), Pays: s.Pays(),
}
}
// clone deep-copies everything with a backing array, so a derived state shares
// none of it with the one it came from and a board can be replayed freely.
func (s State) clone() State {
s.Stock = append(cards.Deck(nil), s.Stock...)
s.Waste = append([]cards.Card(nil), s.Waste...)
for i := range s.Table {
s.Table[i].Down = append([]cards.Card(nil), s.Table[i].Down...)
s.Table[i].Up = append([]cards.Card(nil), s.Table[i].Up...)
}
for i := range s.Found {
s.Found[i] = append([]cards.Card(nil), s.Found[i]...)
}
return s
}

View File

@@ -0,0 +1,730 @@
package klondike
import (
"encoding/json"
"math/rand/v2"
"strconv"
"testing"
"pete/internal/games/cards"
)
const rake = 0.05
func vegas() Tier { t, _ := TierBySlug("vegas"); return t }
func patient() Tier { t, _ := TierBySlug("patient"); return t }
func cut() Tier { t, _ := TierBySlug("cutthroat"); return t }
func card(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} }
// ordered builds the 52 cards in a fixed order — the deck deal() would get if
// the shuffle were the identity. Tests that care about the board build their own.
func ordered() cards.Deck { return cards.NewDeck(1) }
func mustDeal(t *testing.T, bet int64, tier Tier, d cards.Deck) State {
t.Helper()
s, evs, err := deal(bet, tier, d, rake)
if err != nil {
t.Fatalf("deal: %v", err)
}
if len(evs) != 1 || evs[0].Kind != "deal" {
t.Fatalf("deal events = %+v, want one deal", evs)
}
return s
}
func apply(t *testing.T, s State, m Move) (State, []Event) {
t.Helper()
next, evs, err := ApplyMove(s, m)
if err != nil {
t.Fatalf("ApplyMove(%+v): %v", m, err)
}
return next, evs
}
func refuses(t *testing.T, s State, m Move, want error) {
t.Helper()
next, evs, err := ApplyMove(s, m)
if err == nil {
t.Fatalf("ApplyMove(%+v) was allowed, want %v", m, want)
}
if want != nil && err != want {
t.Fatalf("ApplyMove(%+v) = %v, want %v", m, err, want)
}
if evs != nil {
t.Errorf("an illegal move emitted events: %+v", evs)
}
// The board an illegal move hands back must be the one it was given. This is
// the whole contract of the reducer, and it's cheap to check by value.
if !sameBoard(next, s) {
t.Errorf("an illegal move changed the board")
}
}
func sameBoard(a, b State) bool {
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return string(x) == string(y)
}
// ---- the deal --------------------------------------------------------------
func TestDealLaysOutTheBoard(t *testing.T) {
s := mustDeal(t, 520, vegas(), ordered())
seen := 0
for i := 0; i < Piles; i++ {
p := s.Table[i]
if len(p.Up) != 1 {
t.Errorf("column %d has %d face up, want 1", i, len(p.Up))
}
if len(p.Down) != i {
t.Errorf("column %d has %d face down, want %d", i, len(p.Down), i)
}
seen += len(p.Up) + len(p.Down)
}
if seen != 28 {
t.Errorf("tableau holds %d cards, want 28", seen)
}
if len(s.Stock) != 24 {
t.Errorf("stock is %d, want 24", len(s.Stock))
}
if s.Home() != 0 || s.Pays() != 0 {
t.Errorf("a fresh board is worth %d from %d home, want nothing", s.Pays(), s.Home())
}
}
func TestDealRefusesABadStake(t *testing.T) {
if _, _, err := deal(0, vegas(), ordered(), rake); err != ErrBadBet {
t.Fatalf("deal(0) = %v, want ErrBadBet", err)
}
if _, _, err := New(-5, vegas(), rake, cards.NewRNG(1, 2)); err != ErrBadBet {
t.Fatalf("New(-5) = %v, want ErrBadBet", err)
}
}
// ---- the stock -------------------------------------------------------------
func TestDrawTurnsTheTiersCount(t *testing.T) {
for _, tier := range []Tier{patient(), vegas()} {
s := mustDeal(t, 100, tier, ordered())
next, evs := apply(t, s, Move{Kind: "draw"})
if len(next.Waste) != tier.Draw {
t.Errorf("%s: waste is %d after one draw, want %d", tier.Slug, len(next.Waste), tier.Draw)
}
if len(next.Stock) != 24-tier.Draw {
t.Errorf("%s: stock is %d, want %d", tier.Slug, len(next.Stock), 24-tier.Draw)
}
if len(evs) != 1 || evs[0].Kind != "draw" || len(evs[0].Cards) != tier.Draw {
t.Errorf("%s: draw events = %+v", tier.Slug, evs)
}
}
}
// The last pull off a short stock turns over what's left rather than refusing.
func TestDrawTakesWhatIsLeft(t *testing.T) {
s := mustDeal(t, 100, vegas(), ordered()) // 24 in the stock, drawing 3
for i := 0; i < 7; i++ {
s, _ = apply(t, s, Move{Kind: "draw"}) // 21 drawn, 3 left
}
s, _ = apply(t, s, Move{Kind: "draw"})
if len(s.Stock) != 0 || len(s.Waste) != 24 {
t.Fatalf("stock %d waste %d, want 0 and 24", len(s.Stock), len(s.Waste))
}
refuses(t, drained(t, s), Move{Kind: "draw"}, ErrNoDraw)
}
// drained empties the waste too, so there is genuinely nothing to turn over.
func drained(t *testing.T, s State) State {
t.Helper()
s = s.clone()
s.Waste = nil
s.Stock = nil
return s
}
// The waste goes back under the stock in the order it came out — a recycle is a
// pile being turned over, not reshuffled. If this ever reshuffled, the seed in
// the audit log would stop replaying the game.
func TestRecycleTurnsTheWasteOverInOrder(t *testing.T) {
s := mustDeal(t, 100, patient(), ordered())
want := append(cards.Deck(nil), s.Stock...)
for i := 0; i < 24; i++ {
s, _ = apply(t, s, Move{Kind: "draw"})
}
next, evs := apply(t, s, Move{Kind: "draw"})
if len(evs) != 1 || evs[0].Kind != "recycle" {
t.Fatalf("events = %+v, want a recycle", evs)
}
if len(next.Waste) != 0 {
t.Errorf("waste is %d after a recycle, want empty", len(next.Waste))
}
for i := range want {
if next.Stock[i] != want[i] {
t.Fatalf("stock[%d] = %v after recycle, want %v — the pile was reshuffled",
i, next.Stock[i], want[i])
}
}
if next.Recycles != 1 {
t.Errorf("recycles = %d, want 1", next.Recycles)
}
}
// Passes is how many times you may go *through* the stock, so it is one more
// than the number of times you may turn it back over.
func TestPassesRunOut(t *testing.T) {
tests := []struct {
tier Tier
recycles int // how many turn-overs the tier should allow
}{
{cut(), 0}, // one pass: you never get to turn it back over
{vegas(), 2}, // three passes: two turn-overs
{patient(), -1}, // unlimited
}
for _, tc := range tests {
s := mustDeal(t, 100, tc.tier, ordered())
if got := s.PassesLeft(); tc.recycles < 0 && got != -1 {
t.Errorf("%s: PassesLeft = %d, want -1 (unlimited)", tc.tier.Slug, got)
}
allowed := 0
for i := 0; i < 5; i++ {
// Empty the stock, then try to turn it over.
for len(s.Stock) > 0 {
s, _ = apply(t, s, Move{Kind: "draw"})
}
next, _, err := ApplyMove(s, Move{Kind: "draw"})
if err == ErrNoPasses {
break
}
if err != nil {
t.Fatalf("%s: %v", tc.tier.Slug, err)
}
s = next
allowed++
}
if tc.recycles < 0 {
if allowed != 5 {
t.Errorf("%s: only %d recycles allowed, want unlimited", tc.tier.Slug, allowed)
}
continue
}
if allowed != tc.recycles {
t.Errorf("%s: %d recycles allowed, want %d", tc.tier.Slug, allowed, tc.recycles)
}
if s.PassesLeft() != 1 {
t.Errorf("%s: PassesLeft = %d on the last pass, want 1", tc.tier.Slug, s.PassesLeft())
}
}
}
// ---- the rules -------------------------------------------------------------
// board builds a State directly, so a rule can be tested against the position
// that exercises it rather than against whatever a shuffle happened to deal.
func board(tier Tier, bet int64) State {
return State{Tier: tier, Bet: bet, RakePct: rake, Phase: PhasePlaying}
}
func TestTableauTakesDescendingAlternatingColour(t *testing.T) {
s := board(vegas(), 520)
s.Table[0].Up = []cards.Card{card(8, cards.Spades)} // black 8
s.Table[1].Up = []cards.Card{card(7, cards.Hearts)} // red 7 — goes on the 8
s.Table[2].Up = []cards.Card{card(7, cards.Clubs)} // black 7 — does not
s.Table[3].Up = []cards.Card{card(6, cards.Hearts)} // red 6 — wrong rank for the 8
next, evs := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
if len(next.Table[0].Up) != 2 || next.Table[0].Up[1] != card(7, cards.Hearts) {
t.Fatalf("the red seven didn't land on the black eight: %+v", next.Table[0].Up)
}
if len(next.Table[1].Up) != 0 {
t.Errorf("the seven is still in its old column")
}
if len(evs) != 1 || evs[0].Kind != "move" {
t.Errorf("events = %+v, want one move", evs)
}
refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo) // same colour
refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrWontGo) // two below
}
func TestOnlyAKingTakesAnEmptyColumn(t *testing.T) {
s := board(vegas(), 520)
// t0 is empty and has nothing under it.
s.Table[1].Up = []cards.Card{card(cards.King, cards.Hearts)}
s.Table[2].Up = []cards.Card{card(cards.Queen, cards.Spades)}
refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo)
next, _ := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
if len(next.Table[0].Up) != 1 || next.Table[0].Up[0].Rank != cards.King {
t.Fatalf("the king didn't take the empty column: %+v", next.Table[0].Up)
}
}
// A run comes off the tableau as a block, and only if it is a run.
func TestLiftingARun(t *testing.T) {
s := board(vegas(), 520)
s.Table[0].Up = []cards.Card{
card(9, cards.Hearts), // red
card(8, cards.Spades), // black
card(7, cards.Diamonds), // red
}
s.Table[1].Up = []cards.Card{card(10, cards.Clubs)} // black 10 takes the red 9
next, _ := apply(t, s, Move{Kind: "move", From: "t0", To: "t1", Count: 3})
if len(next.Table[1].Up) != 4 || len(next.Table[0].Up) != 0 {
t.Fatalf("the run didn't move as a block: t0=%v t1=%v", next.Table[0].Up, next.Table[1].Up)
}
// Not a run: same colour in the middle of it.
bad := board(vegas(), 520)
bad.Table[0].Up = []cards.Card{
card(9, cards.Hearts),
card(8, cards.Diamonds), // red on red
}
bad.Table[1].Up = []cards.Card{card(10, cards.Clubs)}
refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 2}, ErrNotASequence)
// And you can't lift more cards than the column has.
refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 9}, ErrNotASequence)
}
// Taking the last face-up card off a column turns the next one over. This is the
// only thing in the game that reveals a card, which is the point of the test.
func TestTakingTheLastCardFlipsTheNextOne(t *testing.T) {
s := board(vegas(), 520)
hidden := card(cards.Queen, cards.Clubs)
s.Table[0].Down = []cards.Card{card(2, cards.Spades), hidden}
s.Table[0].Up = []cards.Card{card(7, cards.Hearts)}
s.Table[1].Up = []cards.Card{card(8, cards.Spades)}
next, evs := apply(t, s, Move{Kind: "move", From: "t0", To: "t1"})
if len(next.Table[0].Up) != 1 || next.Table[0].Up[0] != hidden {
t.Fatalf("the hidden card didn't turn over: %+v", next.Table[0].Up)
}
if len(next.Table[0].Down) != 1 {
t.Errorf("face-down stack is %d, want 1", len(next.Table[0].Down))
}
if len(evs) != 2 || evs[1].Kind != "flip" || evs[1].Cards[0] != hidden {
t.Fatalf("events = %+v, want a move then a flip carrying the card", evs)
}
}
func TestFoundationsBuildUpBySuitFromTheAce(t *testing.T) {
s := board(vegas(), 520)
s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
s.Table[2].Up = []cards.Card{card(2, cards.Spades)}
s.Table[3].Up = []cards.Card{card(3, cards.Hearts)}
// A two can't start a foundation.
refuses(t, s, Move{Kind: "home", From: "t1"}, ErrWontGo)
s, evs := apply(t, s, Move{Kind: "home", From: "t0"})
if len(s.Found[cards.Hearts]) != 1 {
t.Fatalf("the ace didn't go home: %+v", s.Found)
}
if evs[0].Kind != "home" || evs[0].To != "f"+strconv.Itoa(int(cards.Hearts)) {
t.Fatalf("event = %+v, want a home to the hearts pile", evs[0])
}
if evs[0].Home != 1 {
t.Errorf("event carries Home=%d, want 1", evs[0].Home)
}
// The three can't jump the two, and the two of spades can't go on hearts.
refuses(t, s, Move{Kind: "home", From: "t3"}, ErrWontGo)
refuses(t, s, Move{Kind: "move", From: "t2", To: "f" + strconv.Itoa(int(cards.Hearts))}, ErrWontGo)
s, _ = apply(t, s, Move{Kind: "home", From: "t1"})
if s.Home() != 2 {
t.Errorf("Home = %d, want 2", s.Home())
}
}
// A card can come back off a foundation — a real rule, and one that matters when
// you need a low card to move a column. The payout follows it back down, because
// the payout reads the board rather than counting events.
func TestACardComesBackOffAFoundation(t *testing.T) {
s := board(vegas(), 5200)
s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
s.Table[0].Up = []cards.Card{card(3, cards.Spades)}
before := s.Pays()
next, _ := apply(t, s, Move{Kind: "move", From: "f" + strconv.Itoa(int(cards.Hearts)), To: "t0"})
if len(next.Found[cards.Hearts]) != 1 || len(next.Table[0].Up) != 2 {
t.Fatalf("the two didn't come back down: found=%v t0=%v", next.Found[cards.Hearts], next.Table[0].Up)
}
if next.Home() != 1 {
t.Errorf("Home = %d after taking a card back, want 1", next.Home())
}
if next.Pays() >= before {
t.Errorf("Pays = %d after taking a card back, want less than %d", next.Pays(), before)
}
}
func TestWasteGivesUpItsTopCardOnly(t *testing.T) {
s := board(vegas(), 520)
s.Waste = []cards.Card{card(5, cards.Spades), card(7, cards.Hearts)}
s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
// The 5 is under the 7 and is not available, however much you'd like it.
refuses(t, s, Move{Kind: "move", From: "waste", To: "t0", Count: 2}, ErrNotASequence)
next, _ := apply(t, s, Move{Kind: "move", From: "waste", To: "t0"})
if len(next.Waste) != 1 || next.Waste[0] != card(5, cards.Spades) {
t.Fatalf("the wrong card left the waste: %+v", next.Waste)
}
}
func TestEmptyPilesAndNonsensePiles(t *testing.T) {
s := board(vegas(), 520)
s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
refuses(t, s, Move{Kind: "move", From: "waste", To: "t0"}, ErrEmptyPile)
refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrEmptyPile)
refuses(t, s, Move{Kind: "move", From: "t9", To: "t0"}, ErrBadPile)
refuses(t, s, Move{Kind: "move", From: "t0", To: "t9"}, ErrWontGo)
refuses(t, s, Move{Kind: "move", From: "banana", To: "t0"}, ErrBadPile)
refuses(t, s, Move{Kind: "sing"}, ErrUnknownMove)
}
// ---- auto ------------------------------------------------------------------
func TestAutoSendsEverythingItCanHome(t *testing.T) {
s := board(vegas(), 5200)
// Two aces and the hearts two, sitting on top of three columns.
s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
s.Table[2].Up = []cards.Card{card(cards.Ace, cards.Spades)}
s.Table[3].Up = []cards.Card{card(9, cards.Clubs)} // goes nowhere
next, evs := apply(t, s, Move{Kind: "auto"})
if next.Home() != 3 {
t.Fatalf("Home = %d after auto, want 3 (two aces and the two)", next.Home())
}
if len(next.Table[3].Up) != 1 {
t.Errorf("the nine went somewhere it couldn't go")
}
homes := 0
for _, e := range evs {
if e.Kind == "home" {
homes++
}
}
if homes != 3 {
t.Errorf("auto emitted %d home events, want 3 — the table has to animate each one", homes)
}
// Nothing left to do: the button says so rather than doing nothing quietly.
if next.CanAuto() {
t.Errorf("CanAuto is true with only a nine on the board")
}
refuses(t, next, Move{Kind: "auto"}, ErrNothingHome)
}
// ---- the money -------------------------------------------------------------
// The number the felt quotes while you play and the number settle() lands on are
// the same function. Hangman had these as two sums once and the table advertised
// a payout the house didn't honour; this asserts they can't drift here.
func TestTheQuoteIsThePayout(t *testing.T) {
s := board(vegas(), 1000)
for home := 0; home <= FullDeck; home++ {
s.Found = [Foundations][]cards.Card{}
left := home
for suit := 0; suit < Foundations && left > 0; suit++ {
n := left
if n > 13 {
n = 13
}
for r := 1; r <= n; r++ {
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
}
left -= n
}
if s.Home() != home {
t.Fatalf("built a board with %d home, wanted %d", s.Home(), home)
}
quoted := s.Pays()
var evs []Event
done := s.clone()
done.settle(OutcomeCashed, &evs)
if done.Payout != quoted {
t.Fatalf("%d home: the felt quoted %d and settle paid %d", home, quoted, done.Payout)
}
if done.Payout+done.Rake != done.Earned() {
t.Fatalf("%d home: payout %d + rake %d != earned %d",
home, done.Payout, done.Rake, done.Earned())
}
}
}
func TestAFullBoardPaysTheTiersMultiple(t *testing.T) {
for _, tier := range Tiers {
s := board(tier, 1000)
for suit := 0; suit < Foundations; suit++ {
for r := 1; r <= 13; r++ {
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
}
}
// Gross is the multiple exactly — computed from the total, not summed 52
// times, so it doesn't bleed a rounding per card.
want := int64(float64(s.Bet) * tier.Base)
if s.Earned() != want {
t.Errorf("%s: a cleared board earns %d, want %d", tier.Slug, s.Earned(), want)
}
// And the rake comes out of the winnings, never the stake.
profit := want - s.Bet
if s.Pays() != want-int64(float64(profit)*rake) {
t.Errorf("%s: pays %d, want %d less %v%% of the profit", tier.Slug, s.Pays(), want, rake*100)
}
}
}
// An empty board owes nothing, and is not charged a fee for owing nothing.
func TestNothingHomePaysNothing(t *testing.T) {
s := board(cut(), 500)
if s.Pays() != 0 || s.rakeNow() != 0 {
t.Fatalf("an empty board pays %d and rakes %d, want nothing either way", s.Pays(), s.rakeNow())
}
var evs []Event
s.settle(OutcomeCashed, &evs)
if s.Payout != 0 || s.Net() != -500 {
t.Errorf("payout %d net %d, want 0 and -500", s.Payout, s.Net())
}
}
// Below break-even the player is down but is not raked: there is no profit to
// take a cut of.
func TestNoRakeBelowTheStake(t *testing.T) {
tier := vegas()
s := board(tier, 5200)
for i := 0; i < tier.BreakEven()-1; i++ {
suit, r := i/13, i%13+1
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
}
if s.Earned() > s.Bet {
t.Fatalf("break-even is meant to be the first card that gets you square, but %d earns %d on a %d stake",
s.Home(), s.Earned(), s.Bet)
}
if s.rakeNow() != 0 {
t.Errorf("raked %d off a losing board", s.rakeNow())
}
if s.Pays() != s.Earned() {
t.Errorf("pays %d, want the full %d — nothing to rake", s.Pays(), s.Earned())
}
}
func TestBreakEvenIsTheCardThatGetsYouSquare(t *testing.T) {
for _, tier := range Tiers {
s := board(tier, 5200)
for i := 0; i < tier.BreakEven(); i++ {
suit, r := i/13, i%13+1
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
}
if s.Earned() < s.Bet {
t.Errorf("%s: %d cards home earns %d on a %d stake — break-even is quoted too low",
tier.Slug, s.Home(), s.Earned(), s.Bet)
}
}
}
// ---- settling --------------------------------------------------------------
func TestConcedeCashesTheBoard(t *testing.T) {
s := board(vegas(), 5200)
s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
want := s.Pays()
next, evs := apply(t, s, Move{Kind: "concede"})
if next.Phase != PhaseDone || next.Outcome != OutcomeCashed {
t.Fatalf("phase %q outcome %q, want done/cashed", next.Phase, next.Outcome)
}
if next.Payout != want {
t.Errorf("cashed for %d, want the %d the board was quoting", next.Payout, want)
}
if evs[len(evs)-1].Kind != "settle" {
t.Errorf("no settle event: %+v", evs)
}
refuses(t, next, Move{Kind: "draw"}, ErrGameOver)
}
// The last card home ends the game on its own — the player doesn't have to tell
// the table they've won.
func TestTheLastCardHomeClearsTheBoard(t *testing.T) {
s := board(vegas(), 1000)
for suit := 0; suit < Foundations; suit++ {
top := 13
if suit == int(cards.Clubs) {
top = 12 // the king of clubs is the one card still out
}
for r := 1; r <= top; r++ {
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
}
}
s.Table[0].Up = []cards.Card{card(cards.King, cards.Clubs)}
next, evs := apply(t, s, Move{Kind: "home", From: "t0"})
if next.Phase != PhaseDone || next.Outcome != OutcomeCleared {
t.Fatalf("phase %q outcome %q, want done/cleared", next.Phase, next.Outcome)
}
if next.Payout != int64(float64(1000)*vegas().Base)-int64(float64(int64(float64(1000)*vegas().Base)-1000)*rake) {
t.Errorf("a cleared board paid %d", next.Payout)
}
if evs[len(evs)-1].Kind != "settle" {
t.Errorf("the winning card didn't settle the game: %+v", evs)
}
}
// ---- the shape of the thing ------------------------------------------------
// A game survives a redeploy: the whole state, shoe and face-down cards and all,
// goes through JSON and comes back the same board.
func TestAGameSurvivesJSON(t *testing.T) {
s, _, err := New(500, cut(), rake, cards.NewRNG(7, 11))
if err != nil {
t.Fatal(err)
}
for i := 0; i < 6; i++ {
s, _, _ = ApplyMove(s, Move{Kind: "draw"})
}
blob, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
var back State
if err := json.Unmarshal(blob, &back); err != nil {
t.Fatal(err)
}
if !sameBoard(s, back) {
t.Fatal("the board didn't come back the same")
}
}
// The same seed deals the same board. This is what lets a disputed game be dealt
// again exactly as it fell, and it is why the RNG is threaded rather than global.
func TestASeedDealsTheSameBoard(t *testing.T) {
a, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
if err != nil {
t.Fatal(err)
}
b, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
if err != nil {
t.Fatal(err)
}
if !sameBoard(a, b) {
t.Fatal("the same seed dealt two different boards")
}
c, _, _ := New(100, vegas(), rake, cards.NewRNG(43, 99))
if sameBoard(a, c) {
t.Fatal("two seeds dealt the same board")
}
}
// Every card is on the board exactly once, whatever you do to it. A move that
// duplicated a card would be a move that printed money.
func TestNoCardIsEverLostOrDuplicated(t *testing.T) {
rng := rand.New(rand.NewPCG(3, 5))
s, _, err := New(1000, patient(), rake, rng)
if err != nil {
t.Fatal(err)
}
countDeck(t, s, "the deal")
// Play a long random game: whatever the fuzzer stumbles into, the deck holds.
for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
m := randomMove(rng)
next, _, err := ApplyMove(s, m)
if err != nil {
continue // an illegal move is a fine thing for a fuzzer to find
}
s = next
countDeck(t, s, "after "+m.Kind)
}
}
func randomMove(rng *rand.Rand) Move {
pile := func() string {
switch rng.IntN(3) {
case 0:
return "waste"
case 1:
return "t" + strconv.Itoa(rng.IntN(Piles))
default:
return "f" + strconv.Itoa(rng.IntN(Foundations))
}
}
switch rng.IntN(10) {
case 0, 1, 2, 3:
return Move{Kind: "draw"}
case 4:
return Move{Kind: "home", From: pile()}
case 5:
return Move{Kind: "auto"}
default:
return Move{Kind: "move", From: pile(), To: pile(), Count: 1 + rng.IntN(4)}
}
}
func countDeck(t *testing.T, s State, when string) {
t.Helper()
seen := map[cards.Card]int{}
add := func(cs []cards.Card) {
for _, c := range cs {
seen[c]++
}
}
add(s.Stock)
add(s.Waste)
for _, p := range s.Table {
add(p.Down)
add(p.Up)
}
for _, f := range s.Found {
add(f)
}
if len(seen) != FullDeck {
t.Fatalf("%s: %d distinct cards on the board, want 52", when, len(seen))
}
for c, n := range seen {
if n != 1 {
t.Fatalf("%s: %v appears %d times", when, c, n)
}
}
}
// The face-up run in every tableau column is always a legal run, and a column
// with cards face-up never has an unturned card left under it. Both are things
// the *rules* keep true, so a fuzzer that breaks them has found a real bug.
func TestTheBoardStaysWellFormed(t *testing.T) {
rng := rand.New(rand.NewPCG(11, 13))
s, _, err := New(1000, vegas(), rake, rng)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
next, _, err := ApplyMove(s, randomMove(rng))
if err != nil {
continue
}
s = next
for j, p := range s.Table {
if !isRun(p.Up) {
t.Fatalf("column %d holds a run that isn't one: %v", j, p.Up)
}
if len(p.Up) == 0 && len(p.Down) > 0 {
t.Fatalf("column %d has %d cards face down and nothing turned over", j, len(p.Down))
}
}
for suit, f := range s.Found {
for r, c := range f {
if int(c.Suit) != suit || int(c.Rank) != r+1 {
t.Fatalf("foundation %d holds %v at position %d", suit, c, r)
}
}
}
}
}

View File

@@ -0,0 +1,375 @@
// Package trivia is a pure trivia-ladder engine, played for chips.
//
// Same seam as blackjack and hangman: ApplyMove(state, move, now) (state,
// events, error), where an error means the move was illegal and nothing else.
// The one difference is that clock: trivia is the only game in the room where
// *when* you move changes what it pays, and a pure reducer cannot own a timer.
// So the time is an argument. The engine stays a value in, value out, and the
// only thing that knows what o'clock it is remains the caller.
//
// The shape is a ladder. You stake once, and then answer a run of questions:
// every right answer multiplies what the stake is worth, a wrong one loses the
// lot, and you may walk with what you've built at any point after the first.
// It is the oldest quiz-show bet there is — the tension is entirely in whether
// you take the money.
//
// The reason for the clock is less pretty: trivia answers are googlable, and a
// game that paid the same for a slow right answer as a fast one would be a game
// about typing into another tab. So the multiple a question is worth decays
// from Fast to Buzzer across the tier's time limit, and running out of time
// loses exactly as much as being wrong. The countdown in the browser is
// decoration; this is the clock that counts.
package trivia
import (
"errors"
"math"
"math/rand/v2"
"time"
)
// Errors an illegal move can produce.
var (
ErrGameOver = errors.New("trivia: the game is already over")
ErrUnknownMove = errors.New("trivia: unknown move")
ErrBadBet = errors.New("trivia: bet must be positive")
ErrUnknownTier = errors.New("trivia: no such tier")
ErrShortLadder = errors.New("trivia: not enough questions to build a ladder")
ErrNothingBanked = errors.New("trivia: answer one before you walk")
)
// Rungs is how long the ladder is. Clearing it is a win in itself: the run ends
// and banks, because a ladder with no top is just a slot machine you can't stop
// playing, and eventually every player loses everything to one bad question.
const Rungs = 12
// Tier is a difficulty, chosen before the bet. It sets three things that move
// together: how hard the questions are, how long you get, and what a right
// answer is worth. Hard questions pay more and give you less time to look them
// up, which is the whole bargain.
type Tier struct {
Slug string `json:"slug"`
Name string `json:"name"`
Difficulty string `json:"difficulty"` // what OpenTDB calls it: easy | medium | hard
Fast float64 `json:"fast"` // what a right answer multiplies by, answered instantly
Buzzer float64 `json:"buzzer"` // ...and what it's worth answered on the last tick
Limit int `json:"limit"` // seconds on the clock, per question
Blurb string `json:"blurb"`
}
// Tiers are the three tables.
var Tiers = []Tier{
{Slug: "easy", Name: "Easy", Difficulty: "easy", Fast: 1.30, Buzzer: 1.10, Limit: 20,
Blurb: "Things you know. The clock is the only thing in your way."},
{Slug: "medium", Name: "Medium", Difficulty: "medium", Fast: 1.55, Buzzer: 1.20, Limit: 18,
Blurb: "Things you nearly know."},
{Slug: "hard", Name: "Hard", Difficulty: "hard", Fast: 1.90, Buzzer: 1.30, Limit: 15,
Blurb: "Things you don't. Fifteen seconds is not enough to find out."},
}
// TierBySlug finds a tier by the name the browser sent.
func TierBySlug(slug string) (Tier, error) {
for _, t := range Tiers {
if t.Slug == slug {
return t, nil
}
}
return Tier{}, ErrUnknownTier
}
// Step is what a right answer multiplies the running total by, given how long
// it took. Fast at nought seconds, Buzzer at the limit, straight line between.
//
// Answering at the buzzer still pays *something* — the decay is a reason to be
// quick, not a punishment for thinking. The punishment for thinking too long is
// the timeout, and that one takes everything.
func (t Tier) Step(elapsed time.Duration) float64 {
limit := t.Clock()
switch {
case elapsed <= 0:
return t.Fast
case elapsed >= limit:
return t.Buzzer
}
speed := 1 - float64(elapsed)/float64(limit) // 1 answering instantly, 0 at the buzzer
return t.Buzzer + (t.Fast-t.Buzzer)*speed
}
// Clock is the tier's time limit as a duration.
func (t Tier) Clock() time.Duration { return time.Duration(t.Limit) * time.Second }
// Question is one rung. It carries its own correct index, which is exactly why
// a State never crosses the wire — the browser is sent the answers and not
// which of them is right.
type Question struct {
Category string `json:"category"`
Text string `json:"text"`
Answers []string `json:"answers"` // already shuffled: the right one is not always first
Correct int `json:"correct"` // index into Answers
}
// Phase is where the game is.
type Phase string
const (
PhasePlaying Phase = "playing"
PhaseDone Phase = "done"
)
// Outcome is how it ended.
type Outcome string
const (
OutcomeNone Outcome = ""
OutcomeWalked Outcome = "walked" // took the money
OutcomeCleared Outcome = "cleared" // answered all twelve
OutcomeWrong Outcome = "wrong" // picked the wrong one
OutcomeTimeout Outcome = "timeout" // ran out of clock
)
// Won reports whether this outcome pays.
func (o Outcome) Won() bool { return o == OutcomeWalked || o == OutcomeCleared }
// State is one game. The ladder — every question, and every right answer — is
// in here, which is why this value stays on the server. The browser gets a view
// of the current rung and nothing about the ones ahead of it.
type State struct {
Tier Tier `json:"tier"`
Ladder []Question `json:"ladder"` // the whole run, drawn up front
Rung int `json:"rung"` // how many answered right; also the index of the live question
// AskedAt is when the current question was *put to the player*, by the
// server's clock. It is the only clock in the game. A reload does not reset
// it: you cannot stop time by refreshing.
AskedAt time.Time `json:"asked_at"`
Multiple float64 `json:"multiple"` // 1.0 at the start; the product of every step earned
RakePct float64 `json:"rake_pct"`
Bet int64 `json:"bet"`
Phase Phase `json:"phase"`
Outcome Outcome `json:"outcome"`
Payout int64 `json:"payout"`
Rake int64 `json:"rake"`
}
// Event is something the table animates.
type Event struct {
Kind string `json:"kind"` // "ask" | "right" | "wrong" | "timeout" | "settle"
Choice int `json:"choice"` // what the player picked (-1 when they didn't)
Correct int `json:"correct"` // which one was right — sent only once it's decided
Step float64 `json:"step,omitempty"` // what this answer multiplied by
Multiple float64 `json:"multiple,omitempty"` // the running total, after
Text string `json:"text,omitempty"`
}
// Move is a player action: pick an answer, or take the money.
type Move struct {
Choice int `json:"choice"` // index into the live question's answers
Walk bool `json:"walk"`
}
// New starts a game on a ladder of questions the caller has already drawn. The
// engine does not reach for a database any more than blackjack reaches for a
// deck: the questions arrive as a value, so a game is reproducible and a test
// can pin every rung.
//
// The answers are shuffled here, with the caller's seeded rng, because a bank
// that always stores the right answer first would otherwise be a game about
// clicking first.
func New(bet int64, t Tier, rakePct float64, qs []Question, now time.Time, rng *rand.Rand) (State, []Event, error) {
if bet <= 0 {
return State{}, nil, ErrBadBet
}
if len(qs) < Rungs {
return State{}, nil, ErrShortLadder
}
ladder := make([]Question, Rungs)
for i := range ladder {
ladder[i] = shuffleAnswers(qs[i], rng)
}
s := State{
Tier: t, Ladder: ladder, RakePct: rakePct,
Multiple: 1,
AskedAt: now,
Bet: bet, Phase: PhasePlaying,
}
return s, []Event{{Kind: "ask", Choice: -1, Correct: -1}}, nil
}
// shuffleAnswers moves the right answer somewhere the player can't guess from
// position, and keeps track of where it went.
func shuffleAnswers(q Question, rng *rand.Rand) Question {
answers := append([]string(nil), q.Answers...)
correct := q.Answers[q.Correct]
rng.Shuffle(len(answers), func(i, j int) { answers[i], answers[j] = answers[j], answers[i] })
out := q
out.Answers = answers
for i, a := range answers {
if a == correct {
out.Correct = i
break
}
}
return out
}
// Live is the question the player is looking at.
func (s State) Live() Question {
if s.Rung < 0 || s.Rung >= len(s.Ladder) {
return Question{}
}
return s.Ladder[s.Rung]
}
// Left is how much clock the live question has, at the given moment. It goes to
// the browser so its countdown starts where the server's does — but the browser
// is never asked what it says.
func (s State) Left(now time.Time) time.Duration {
d := s.Tier.Clock() - now.Sub(s.AskedAt)
if d < 0 {
return 0
}
return d
}
// ApplyMove is the engine. now is the server's clock, and the only one that
// counts toward the answer.
func ApplyMove(s State, m Move, now time.Time) (State, []Event, error) {
if s.Phase == PhaseDone {
return s, nil, ErrGameOver
}
s = s.clone()
q := s.Live()
if len(q.Answers) == 0 {
return s, nil, ErrUnknownMove
}
elapsed := now.Sub(s.AskedAt)
// Out of time. This is a loss, and it has to be — a timeout that merely cost
// you the speed bonus would make "leave it open in another tab and go and
// look it up" the strongest way to play.
//
// It is checked before *everything*, walking included. A dead clock that you
// could still walk away from would be no clock at all: you would sit on every
// question for as long as you liked, answer the ones you found and walk off
// the ones you didn't, and never once lose the ladder. The timeout has to be
// the first thing that happens to a move, or it is not a deadline.
if elapsed > s.Tier.Clock() {
evs := []Event{{Kind: "timeout", Choice: -1, Correct: q.Correct}}
s.settle(OutcomeTimeout, &evs)
return s, evs, nil
}
if m.Walk {
// You cannot walk off a rung you haven't climbed. If you could, seeing the
// first question and walking away would be a free look: stake, peek, walk,
// stake again, and keep reshuffling until the question is one you know.
// The first question is therefore the price of sitting down.
if s.Rung == 0 {
return s, nil, ErrNothingBanked
}
evs := []Event{}
s.settle(OutcomeWalked, &evs)
return s, evs, nil
}
if m.Choice < 0 || m.Choice >= len(q.Answers) {
return s, nil, ErrUnknownMove
}
if m.Choice != q.Correct {
evs := []Event{{Kind: "wrong", Choice: m.Choice, Correct: q.Correct}}
s.settle(OutcomeWrong, &evs)
return s, evs, nil
}
// Right, and quick enough to be worth something.
step := s.Tier.Step(elapsed)
s.Multiple *= step
s.Rung++
evs := []Event{{
Kind: "right", Choice: m.Choice, Correct: q.Correct,
Step: step, Multiple: s.Multiple,
}}
if s.Rung >= Rungs {
s.settle(OutcomeCleared, &evs)
return s, evs, nil
}
// The next question goes up, and its clock starts now.
s.AskedAt = now
evs = append(evs, Event{Kind: "ask", Choice: -1, Correct: -1})
return s, evs, nil
}
// Pays is what banking *right now* would put back on the player's stack: the
// stake, plus the winnings, less the house's cut of the winnings.
//
// It exists for the same reason hangman's does. The felt quotes this number
// while the game is still running — it is the "take the money" button's label —
// and settle() calls it rather than doing the sum a second time, so the table
// can never advertise a payout the house doesn't hand over.
func (s State) Pays() int64 {
total := int64(math.Floor(float64(s.Bet) * s.Multiple))
if total < s.Bet {
total = s.Bet // banking never hands back less than the stake
}
profit := total - s.Bet
if profit > 0 {
rake := int64(math.Floor(float64(profit) * s.RakePct))
if rake > 0 {
profit -= rake
}
}
return s.Bet + profit
}
// rakeNow is the other half of what Pays works out: the house's cut of a win
// banked at this moment. Never taken from the stake, so a player who walks
// having answered nothing — which they can't — and one who loses, pay nothing.
func (s State) rakeNow() int64 {
total := int64(math.Floor(float64(s.Bet) * s.Multiple))
if total <= s.Bet {
return 0
}
rake := int64(math.Floor(float64(total-s.Bet) * s.RakePct))
if rake < 0 {
return 0
}
return rake
}
// settle decides the payout. Same rule as every other table in the room: the
// rake comes out of winnings, never out of the stake, and a loss is never
// charged a fee.
func (s *State) settle(o Outcome, evs *[]Event) {
s.Outcome = o
s.Phase = PhaseDone
if o.Won() {
s.Payout = s.Pays()
s.Rake = s.rakeNow()
} else {
s.Payout = 0
}
*evs = append(*evs, Event{Kind: "settle", Choice: -1, Correct: -1, Text: string(o)})
}
// Net is what the game did to the player's stack.
func (s State) Net() int64 {
if s.Phase != PhaseDone {
return 0
}
return s.Payout - s.Bet
}
// clone deep-copies the ladder, so a derived state shares no backing array with
// the one it came from and a game can be replayed freely.
func (s State) clone() State {
s.Ladder = append([]Question(nil), s.Ladder...)
return s
}

View File

@@ -0,0 +1,352 @@
package trivia
import (
"math/rand/v2"
"testing"
"time"
)
func rng() *rand.Rand { return rand.New(rand.NewPCG(1, 2)) }
var epoch = time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
// bank builds n questions whose right answer is always "right", so a test can
// find it after the shuffle without caring where it landed.
func bank(n int) []Question {
qs := make([]Question, n)
for i := range qs {
qs[i] = Question{
Category: "General",
Text: "question?",
Answers: []string{"right", "wrong1", "wrong2", "wrong3"},
Correct: 0,
}
}
return qs
}
func tier(slug string) Tier {
t, err := TierBySlug(slug)
if err != nil {
panic(err)
}
return t
}
func newGame(t *testing.T, bet int64, slug string) State {
t.Helper()
s, evs, err := New(bet, tier(slug), 0.05, bank(Rungs), epoch, rng())
if err != nil {
t.Fatalf("New: %v", err)
}
if len(evs) != 1 || evs[0].Kind != "ask" {
t.Fatalf("New should open with one ask, got %+v", evs)
}
if s.Multiple != 1 {
t.Fatalf("a fresh ladder is worth the stake, got multiple %v", s.Multiple)
}
return s
}
// answerRight plays the live question correctly, after `took` on the clock.
func answerRight(t *testing.T, s State, took time.Duration) (State, []Event) {
t.Helper()
q := s.Live()
next, evs, err := ApplyMove(s, Move{Choice: q.Correct}, s.AskedAt.Add(took))
if err != nil {
t.Fatalf("right answer refused: %v", err)
}
return next, evs
}
func TestNewShufflesButKeepsTheAnswer(t *testing.T) {
s := newGame(t, 100, "medium")
moved := 0
for _, q := range s.Ladder {
if q.Answers[q.Correct] != "right" {
t.Fatalf("Correct points at %q, not the right answer", q.Answers[q.Correct])
}
if q.Correct != 0 {
moved++
}
}
// All twelve landing on index 0 would mean the shuffle isn't running, and the
// game would be "always click the first one".
if moved == 0 {
t.Fatal("the right answer is first in every question — the shuffle did nothing")
}
}
func TestShortBankIsRefused(t *testing.T) {
if _, _, err := New(100, tier("easy"), 0.05, bank(Rungs-1), epoch, rng()); err != ErrShortLadder {
t.Fatalf("a ladder with a missing rung should be refused, got %v", err)
}
}
// The one that matters most: the number the felt quotes is the number the
// player is actually paid, at every rung, exactly as in hangman.
func TestTheQuoteIsThePayout(t *testing.T) {
s := newGame(t, 200, "hard")
for rung := 1; rung < Rungs; rung++ {
s, _ = answerRight(t, s, 3*time.Second)
quoted := s.Pays() // what the "take the money" button says it's worth
banked, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt)
if err != nil {
t.Fatalf("rung %d: walk refused: %v", rung, err)
}
if banked.Payout != quoted {
t.Fatalf("rung %d: the felt quoted %d and the house paid %d", rung, quoted, banked.Payout)
}
if banked.Phase != PhaseDone || banked.Outcome != OutcomeWalked {
t.Fatalf("rung %d: walking should end the game, got %s/%s", rung, banked.Phase, banked.Outcome)
}
}
}
// Walking before answering anything would be a free look at the first question:
// stake, peek, walk, restake until the question is one you happen to know.
func TestYouCannotWalkOffTheFirstRung(t *testing.T) {
s := newGame(t, 100, "easy")
if _, _, err := ApplyMove(s, Move{Walk: true}, epoch); err != ErrNothingBanked {
t.Fatalf("walking on rung 0 should be refused, got %v", err)
}
// One right answer, and now you may.
s, _ = answerRight(t, s, time.Second)
if _, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt); err != nil {
t.Fatalf("walking after a right answer should be allowed, got %v", err)
}
}
func TestAWrongAnswerLosesTheLot(t *testing.T) {
s := newGame(t, 300, "medium")
// Build a decent ladder first, so there is something real to lose.
for i := 0; i < 4; i++ {
s, _ = answerRight(t, s, time.Second)
}
if s.Pays() <= 300 {
t.Fatalf("four right answers should be worth more than the stake, got %d", s.Pays())
}
q := s.Live()
wrong := (q.Correct + 1) % len(q.Answers)
out, evs, err := ApplyMove(s, Move{Choice: wrong}, s.AskedAt.Add(time.Second))
if err != nil {
t.Fatalf("a wrong answer is a legal move: %v", err)
}
if out.Outcome != OutcomeWrong || out.Payout != 0 {
t.Fatalf("a wrong answer should pay nothing, got %s/%d", out.Outcome, out.Payout)
}
if out.Rake != 0 {
t.Fatalf("a loss must never be charged a rake, got %d", out.Rake)
}
if out.Net() != -300 {
t.Fatalf("a wrong answer costs the stake and nothing more, got %d", out.Net())
}
// The player is told which one it was.
if evs[0].Kind != "wrong" || evs[0].Correct != q.Correct {
t.Fatalf("a wrong answer should reveal the right one, got %+v", evs[0])
}
}
// The clock is the whole anti-google mechanism: running out of it has to cost
// as much as being wrong, or leaving the tab open and looking it up wins.
func TestTheClockTakesEverything(t *testing.T) {
s := newGame(t, 250, "hard")
for i := 0; i < 3; i++ {
s, _ = answerRight(t, s, time.Second)
}
banked := s.Pays()
q := s.Live()
late := s.AskedAt.Add(s.Tier.Clock() + time.Millisecond)
out, evs, err := ApplyMove(s, Move{Choice: q.Correct}, late) // the *right* answer, too late
if err != nil {
t.Fatalf("a late answer is a legal move: %v", err)
}
if out.Outcome != OutcomeTimeout {
t.Fatalf("answering past the limit should time out, got %s", out.Outcome)
}
if out.Payout != 0 {
t.Fatalf("a timeout pays nothing — it was worth %d a moment ago, and paid %d", banked, out.Payout)
}
if evs[0].Kind != "timeout" {
t.Fatalf("expected a timeout event, got %+v", evs[0])
}
// And answering on the final tick still counts.
onTime := s.AskedAt.Add(s.Tier.Clock())
if out, _, err = ApplyMove(s, Move{Choice: q.Correct}, onTime); err != nil {
t.Fatalf("an answer on the buzzer is legal: %v", err)
}
if out.Rung != s.Rung+1 {
t.Fatal("an answer on the final tick should still count")
}
}
// Speed is the only thing separating a slow right answer from a fast one.
func TestFasterPaysMore(t *testing.T) {
base := newGame(t, 1000, "hard")
quick, _ := answerRight(t, base, time.Second)
slow, _ := answerRight(t, base, 14*time.Second)
if quick.Multiple <= slow.Multiple {
t.Fatalf("a quick answer should be worth more: quick %v, slow %v", quick.Multiple, slow.Multiple)
}
if quick.Pays() <= slow.Pays() {
t.Fatalf("a quick answer should pay more: quick %d, slow %d", quick.Pays(), slow.Pays())
}
// The ends of the scale are the tier's own numbers, and nothing is outside them.
instant, _ := answerRight(t, base, 0)
buzzer, _ := answerRight(t, base, base.Tier.Clock())
if instant.Multiple != base.Tier.Fast {
t.Fatalf("an instant answer is worth Fast (%v), got %v", base.Tier.Fast, instant.Multiple)
}
if buzzer.Multiple != base.Tier.Buzzer {
t.Fatalf("an answer on the buzzer is worth Buzzer (%v), got %v", base.Tier.Buzzer, buzzer.Multiple)
}
if quick.Multiple > base.Tier.Fast || slow.Multiple < base.Tier.Buzzer {
t.Fatal("a step escaped the tier's range")
}
}
// Clearing the ladder ends the run and banks it, rather than leaving the player
// on a rung that doesn't exist.
func TestClearingTheLadderBanks(t *testing.T) {
s := newGame(t, 100, "easy")
for i := 0; i < Rungs; i++ {
if s.Phase != PhasePlaying {
t.Fatalf("the game ended early, on rung %d", i)
}
s, _ = answerRight(t, s, time.Second)
}
if s.Outcome != OutcomeCleared {
t.Fatalf("twelve right answers should clear the ladder, got %s", s.Outcome)
}
if s.Rung != Rungs {
t.Fatalf("expected to be on rung %d, got %d", Rungs, s.Rung)
}
if s.Payout != s.Pays() || s.Payout <= s.Bet {
t.Fatalf("clearing should bank a win, got payout %d on a %d stake", s.Payout, s.Bet)
}
if _, _, err := ApplyMove(s, Move{Choice: 0}, s.AskedAt); err != ErrGameOver {
t.Fatalf("a cleared ladder takes no more moves, got %v", err)
}
}
// The rake comes out of winnings, never out of the stake.
func TestRakeOnlyBitesWinnings(t *testing.T) {
s := newGame(t, 1000, "medium")
s, _ = answerRight(t, s, 0) // instant: multiple is exactly Fast, so the sum is checkable by hand
banked, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt)
if err != nil {
t.Fatalf("walk: %v", err)
}
total := int64(float64(1000) * s.Tier.Fast) // 1550
profit := total - 1000 // 550
rake := int64(float64(profit) * 0.05) // 27
want := 1000 + profit - rake // 1523
if banked.Payout != want {
t.Fatalf("payout should be stake + winnings - 5%% of winnings = %d, got %d", want, banked.Payout)
}
if banked.Rake != rake {
t.Fatalf("rake should be %d, got %d", rake, banked.Rake)
}
if banked.Payout < banked.Bet {
t.Fatal("a win handed back less than the stake")
}
}
// A move must not scribble on the state it came from — a game has to replay.
func TestApplyMoveDoesNotMutateItsInput(t *testing.T) {
s := newGame(t, 100, "easy")
before := s.Live()
next, _, err := ApplyMove(s, Move{Choice: before.Correct}, s.AskedAt.Add(time.Second))
if err != nil {
t.Fatalf("move: %v", err)
}
if s.Rung != 0 || s.Multiple != 1 || s.Phase != PhasePlaying {
t.Fatalf("the original state moved underneath us: rung %d multiple %v", s.Rung, s.Multiple)
}
if next.Rung != 1 {
t.Fatalf("the derived state should have climbed a rung, got %d", next.Rung)
}
// The same move replays to the same place.
again, _, err := ApplyMove(s, Move{Choice: before.Correct}, s.AskedAt.Add(time.Second))
if err != nil {
t.Fatalf("replay: %v", err)
}
if again.Multiple != next.Multiple || again.Rung != next.Rung {
t.Fatal("the same move from the same state landed somewhere else")
}
}
func TestLeftCountsDown(t *testing.T) {
s := newGame(t, 100, "hard") // 15s
if got := s.Left(epoch); got != 15*time.Second {
t.Fatalf("a fresh question has the whole clock, got %v", got)
}
if got := s.Left(epoch.Add(10 * time.Second)); got != 5*time.Second {
t.Fatalf("expected 5s left, got %v", got)
}
// It floors at nought rather than going negative, so a browser can render it.
if got := s.Left(epoch.Add(time.Hour)); got != 0 {
t.Fatalf("the clock should stop at zero, got %v", got)
}
}
func TestGarbageMovesAreRefused(t *testing.T) {
s := newGame(t, 100, "easy")
for _, choice := range []int{-1, 4, 99} {
if _, _, err := ApplyMove(s, Move{Choice: choice}, s.AskedAt); err != ErrUnknownMove {
t.Fatalf("choice %d should be refused, got %v", choice, err)
}
}
if s.Phase != PhasePlaying {
t.Fatal("a refused move should leave the game alone")
}
}
// The clock has to beat the walk button, or it is not a deadline.
//
// If a dead clock could still be walked away from, the ladder would carry no
// risk at all: sit on every question for as long as you like, answer the ones
// you can look up, and walk off the ones you can't. The timeout has to be the
// first thing that happens to a move.
func TestWalkingOffADeadClockIsATimeout(t *testing.T) {
s := newGame(t, 500, "hard")
s, _ = answerRight(t, s, time.Second) // one rung banked, so a walk is otherwise legal
late := s.AskedAt.Add(s.Tier.Clock() + time.Second)
out, evs, err := ApplyMove(s, Move{Walk: true}, late)
if err != nil {
t.Fatalf("walking after the clock died should resolve, not error: %v", err)
}
if out.Outcome != OutcomeTimeout {
t.Fatalf("a walk after the clock ran out is a timeout, got %q", out.Outcome)
}
if out.Payout != 0 {
t.Fatalf("a timeout pays nothing, got %d", out.Payout)
}
if len(evs) == 0 || evs[0].Kind != "timeout" {
t.Fatalf("expected the timeout event first, got %+v", evs)
}
// And the same walk, one tick inside the limit, still banks.
intime := s.AskedAt.Add(s.Tier.Clock() - time.Millisecond)
banked, _, err := ApplyMove(s, Move{Walk: true}, intime)
if err != nil {
t.Fatalf("walk with the clock still running: %v", err)
}
if banked.Outcome != OutcomeWalked || banked.Payout <= 0 {
t.Fatalf("a walk inside the clock banks, got %q paying %d", banked.Outcome, banked.Payout)
}
}

182
internal/games/uno/bot.go Normal file
View File

@@ -0,0 +1,182 @@
package uno
import "math/rand/v2"
// The bots.
//
// Lifted from the ones gogobee's UNO plays in Matrix, which are genuinely decent
// company — they hold their wild draw fours back until you're close to going
// out, they follow the colour in play when they can, and they get out of the way
// of their own hand. Two things changed on the way over:
//
// The RNG is threaded. gogobee's bots reach for the package global, which is why
// its own tests can only assert that a bot played *something* legal. These take
// the game's generator, so a bot's choice is part of what a seed replays.
//
// They are not the same bot at every table. A single deterministic policy is a
// puzzle: play round it once and it never surprises you again. So the bot takes
// the best card it sees most of the time, and now and then takes the second best
// — enough that you cannot count what it is holding by what it plays.
// botSlip is how often a bot takes its second choice instead of its first. Low
// enough that it still plays well, high enough that it isn't a lookup table.
const botSlip = 6 // one turn in six
// botPick chooses a card to play, or reports -1 when the bot has nothing legal.
func botPick(hand []Card, top Card, topColor Color, minOpponent int, rng *rand.Rand) (Card, int) {
var playable []int
for i, c := range hand {
if c.CanPlayOn(top, topColor) {
playable = append(playable, i)
}
}
if len(playable) == 0 {
return Card{}, -1
}
order := botRank(hand, topColor, playable, minOpponent)
pick := order[0]
if len(order) > 1 && rng.IntN(botSlip) == 0 {
pick = order[1]
}
return hand[pick], pick
}
// botRank sorts the playable cards best-first, by the bot's own priorities.
//
// The shape of it: hurt the leader if there is one, otherwise get rid of
// something useful and keep the wilds back. A wild draw four spent early is a
// wild draw four you don't have when somebody is sitting on one card.
func botRank(hand []Card, topColor Color, playable []int, minOpponent int) []int {
var wd4, wilds, actions, numbers []int
for _, i := range playable {
switch c := hand[i]; {
case c.Value == WildDrawFour:
wd4 = append(wd4, i)
case c.Value == WildCard:
wilds = append(wilds, i)
case c.Value.Action():
actions = append(actions, i)
default:
numbers = append(numbers, i)
}
}
// Following the colour in play is worth more than not, because it keeps the
// bot's hand flexible — so within each group, the cards already in colour go
// first.
inColorFirst := func(idx []int) []int {
var same, other []int
for _, i := range idx {
if hand[i].Color == topColor {
same = append(same, i)
} else {
other = append(other, i)
}
}
return append(same, other...)
}
var out []int
if minOpponent >= 0 && minOpponent <= 2 {
// Somebody is about to go out. This is what the +4 was being saved for.
out = append(out, wd4...)
out = append(out, inColorFirst(actions)...)
out = append(out, wilds...)
out = append(out, inColorFirst(numbers)...)
return out
}
out = append(out, inColorFirst(actions)...)
out = append(out, inColorFirst(numbers)...)
out = append(out, wilds...)
out = append(out, wd4...)
return out
}
// botStack answers a stack, or reports -1 when the bot has nothing to answer it
// with and has to eat the lot.
//
// It plays the *smallest* draw card it holds. The bill is passed on either way —
// what it is passing on is the stack plus whatever it added — so the cheap card
// does the same job as the expensive one, and keeps the +10 in hand for a turn
// when the bot is the one choosing to hurt somebody rather than the one dodging.
//
// The slip is here too: one time in six it reaches for the second-smallest, so a
// player can't read the stack it just passed as a complete inventory of what the
// bot doesn't have.
func botStack(hand []Card, topColor Color, rng *rand.Rand) (Card, int) {
var can []int
for i, c := range hand {
if c.CanStackOn(topColor) {
can = append(can, i)
}
}
if len(can) == 0 {
return Card{}, -1
}
// Smallest draw first. A stable insertion sort: there are never many.
for i := 1; i < len(can); i++ {
for j := i; j > 0 && hand[can[j]].Value.Draw() < hand[can[j-1]].Value.Draw(); j-- {
can[j], can[j-1] = can[j-1], can[j]
}
}
pick := can[0]
if len(can) > 1 && rng.IntN(botSlip) == 0 {
pick = can[1]
}
return hand[pick], pick
}
// botRouletteColor names the colour for a roulette: whichever the bot holds
// *least* of. The victim flips until that colour turns up, so the rarer the
// colour, the longer they flip and the more they keep. Naming the colour you're
// long in is naming the one that ends the flipping soonest, which is mercy — and
// this is not that game.
func botRouletteColor(hand []Card, rng *rand.Rand) Color {
counts := [5]int{}
for _, c := range hand {
if c.Color.Playable() {
counts[c.Color]++
}
}
best, bestN := Wild, 1<<30
for col := Red; col <= Green; col++ {
if counts[col] < bestN {
best, bestN = col, counts[col]
}
}
if best == Wild {
return Red + Color(rng.IntN(4))
}
return best
}
// botColor names a colour for a wild: whichever the bot holds most of, so the
// card it plays next is one it already has. A hand of nothing but wilds picks
// at random rather than always saying red, which would be a tell.
func botColor(hand []Card, rng *rand.Rand) Color {
counts := [5]int{}
for _, c := range hand {
if c.Color.Playable() {
counts[c.Color]++
}
}
best, bestN := Wild, 0
for col := Red; col <= Green; col++ {
if counts[col] > bestN {
best, bestN = col, counts[col]
}
}
if bestN == 0 {
return Red + Color(rng.IntN(4))
}
return best
}
// botPool are the regulars a bot seat is named from. Flavour, and load-bearing
// flavour: "Kiwi played a +4" is a table, "Bot 2 played a +4" is a test fixture.
// More names than a full table, so no two chairs ever share one.
var botPool = []string{
"Kiwi", "Mochi", "Bramble", "Pixel", "Gus", "Nori", "Waffle", "Marzipan",
"Tuck", "Bebop", "Olive", "Rascal", "Peaches", "Dot", "Sable", "Clementine",
}

203
internal/games/uno/call.go Normal file
View File

@@ -0,0 +1,203 @@
package uno
import "math/rand/v2"
// Calling UNO, and catching the seat that didn't.
//
// This was the one rule on the box the table wasn't playing. A hand going down to
// one card used to emit the "uno" event by itself, which made the call a thing
// that *happened to you* rather than a thing you did — and a rule nobody can fail
// is not a rule, it's an announcement.
//
// So now: play your second-to-last card and you owe the table a word. Say it
// (Move.Uno) and you're safe. Stay quiet and the bots get one look at you, right
// then, before any of them plays — because a bot that has moved on is a bot that
// has stopped watching your hand. Miss it and you take two.
//
// It runs the other way too, and that half is the fun one. A bot forgets often
// enough to be worth watching for, and when it does it says *nothing* — there is
// no event, no badge, no tell on the felt except the count beside its fan reading
// "1 card" with no UNO on it. Catch it (MoveCatch) and it takes two. Call a seat
// that had nothing to hide and you take two yourself, which is what stops the
// catch button from being a thing you simply mash every turn.
//
// The whole rule turns on one asymmetry, and it's deliberate: the bots get a
// *roll* to notice you, and you get to actually look. Attention is the edge here,
// and it's the player's to take.
const (
// CatchPenalty is what silence costs, both ways. Two, as printed on the box.
CatchPenalty = 2
// botForget is how often a bot goes down to one card without saying so. High
// enough that watching the counts pays — at a full table you'll get a catch to
// make every few games — and low enough that a quiet seat is still a thing you
// have to notice rather than assume.
botForget = 0.30
// botAlert is the chance a *single* bot notices that you didn't call. They each
// get a look, so forgetting is punished about 75% of the time heads-up and 98%
// at a full table: the more seats there are watching you, the less you get away
// with, which is the right shape for it.
botAlert = 0.75
)
// botForgets rolls for whether a bot muffs its call.
func botForgets(rng *rand.Rand) bool { return rng.Float64() < botForget }
// declare records whether a seat that is now on one card said so, and — if it did
// — announces it. A seat on any other number of cards owes nothing and this does
// nothing, which is what makes it safe to call after every play.
func (s *State) declare(seat int, called bool, evs *[]Event) {
if !s.playing() || len(s.Hands[seat]) != 1 {
return
}
s.ensureCalled()
s.Called[seat] = called
if called {
*evs = append(*evs, Event{Kind: EvUno, Seat: seat, Left: 1})
}
}
// botsCatch is the window. The seat that just played is holding one card and
// didn't say the word — so every bot still in the hand gets one look, in seat
// order, and the first to see it takes them for two.
//
// It runs before runBots on purpose. The catch has to land while the table is
// still looking at the card that was played, not three turns later. Only the bots
// catch on a roll here; a human polices the table with the catch button, which is
// the asymmetry the whole rule turns on — attention is the player's edge.
func (s *State) botsCatch(actor int, evs *[]Event, rng *rand.Rand) {
if !s.playing() || len(s.Hands[actor]) != 1 || s.called(actor) {
return
}
for _, seat := range s.alive() {
if seat == actor || !s.Seats[seat].Bot {
continue // a human catches with the button, not on a roll
}
if rng.Float64() >= botAlert {
continue // this one wasn't looking
}
s.penalise(actor, seat, EvCaught, evs, rng)
return // caught once is caught. They don't queue up to take turns at you
}
}
// seatCatches calls out a seat the caller thinks is holding one card in silence.
//
// It is not a turn: right or wrong, the turn stays where it was. What it costs is
// the risk — a seat that did call, or isn't on one card at all, is a seat the
// caller has accused of nothing, and that is two cards to them.
func (s *State) seatCatches(caller int, m Move, rng *rand.Rand) ([]Event, error) {
seat := m.Seat
if seat == caller || seat < 0 || seat >= len(s.Hands) || !s.live(seat) {
return nil, ErrNoCatch
}
var evs []Event
if len(s.Hands[seat]) == 1 && !s.called(seat) {
s.penalise(seat, caller, EvCaught, &evs, rng) // got them
} else {
s.penalise(caller, seat, EvMiscall, &evs, rng) // they were clean, and you weren't looking
}
return evs, nil
}
// penalise makes a seat take the price of the call: cards off the deck, quietly —
// no draw event, because what the table is being told about is the catch, and a
// draw event alongside it would animate the same two cards twice.
//
// In No Mercy those two cards can be the two that bury them, which is a fine way
// to go: caught on one card, dead on twenty-five.
func (s *State) penalise(victim, by int, kind string, evs *[]Event, rng *rand.Rand) {
got := s.drawCards(victim, CatchPenalty, evs, rng)
if len(got) == 0 {
return // the table has nothing left to punish anybody with
}
s.ensureCalled()
s.Called[victim] = false
*evs = append(*evs, s.mine(Event{
Kind: kind, Seat: victim, By: by, N: len(got), Left: len(s.Hands[victim]),
}))
s.mercy(victim, evs, rng)
}
// UnoAt is which cards in your hand would leave you holding exactly one, if you
// played them. It is the table's cue to ask you for the call, and it comes from
// here rather than from the browser counting your cards because No Mercy's
// "discard all" doesn't take one card out of your hand — it takes every card of
// its colour, and the browser guessing at that is the browser getting it wrong.
//
// It answers for every card, legal or not. The table only ever asks about a card
// you were allowed to play anyway.
func (s State) UnoAt(seat int) []int {
if !s.playing() || s.Turn != seat {
return nil
}
hand := s.Hands[seat]
var out []int
for i, c := range hand {
left := len(hand) - 1
if c.Value == DiscardAll {
for j, other := range hand {
if j != i && other.Color == c.Color && !other.IsWild() {
left--
}
}
}
if left == 1 {
out = append(out, i)
}
}
return out
}
// Catchable is which seats are, right now, sitting on one card they never
// announced. It is what the browser puts a button on.
//
// This leaks nothing. The card counts are already on the felt and the UNO badge
// already isn't — this is the same two facts, subtracted, and doing that
// subtraction on the server is only so that the rule for what counts as catchable
// lives in one place instead of two.
func (s State) Catchable(viewer int) []int {
if !s.playing() || s.Turn != viewer {
return nil // you can only catch a seat on your own turn
}
var out []int
for _, seat := range s.alive() {
if seat != viewer && len(s.Hands[seat]) == 1 && !s.called(seat) {
out = append(out, seat)
}
}
return out
}
// called reports whether a seat holding one card announced it. Callers outside
// the package read the Called slice, which is on the state they already hold.
func (s State) called(seat int) bool {
return seat < len(s.Called) && s.Called[seat]
}
// ensureCalled grows the slice to fit the table. A game dealt before this rule
// existed has no Called at all, and the seats in it are all — correctly —
// uncalled: nobody in that game ever said the word, because there was no way to.
func (s *State) ensureCalled() {
for len(s.Called) < len(s.Hands) {
s.Called = append(s.Called, false)
}
}
// tidyCalls forgets the calls that no longer mean anything. A seat's call is only
// ever about the one card it is holding — draw into two and the word you said is
// spent, and saying it again is a new thing you have to do.
//
// Without this, a seat that called on one card, was made to draw, and worked its
// way back down to one would still be wearing the old call, and could never be
// caught again for the rest of the game.
func (s *State) tidyCalls() {
s.ensureCalled()
for seat := range s.Called {
if len(s.Hands[seat]) != 1 {
s.Called[seat] = false
}
}
}

View File

@@ -0,0 +1,323 @@
package uno
import "testing"
// The UNO call, and the catch on the other side of it. See call.go.
// oneCardAway sets you up holding two cards, both of which go on the pile, so
// playing either one takes you to UNO.
//
// The bot is given a hand that can't touch a red pile and a deck that can't help
// it, so whatever it does on its turn, it does not make you draw. That matters:
// a hand that grows spends the call (see tidyCalls), which is correct and would
// otherwise make these tests flap on the seeds where the bot happens to turn up
// a +2.
func oneCardAway(t *testing.T, seed uint64) State {
t.Helper()
s := deal(t, duel(), seed)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
s.Hands[1] = []Card{{Blue, Three}, {Green, Four}, {Yellow, Six}}
s.Deck = make([]Card, 24)
for i := range s.Deck {
s.Deck[i] = Card{Blue, Nine} // nothing here plays on a red one, and nothing bites
}
s.Turn = You
s.Phase = PhasePlay
return s
}
// TestCallingUnoKeepsYouSafe — say the word and the table has nothing on you.
// Across a spread of seeds, not one bot ever gets a catch.
func TestCallingUnoKeepsYouSafe(t *testing.T) {
for seed := uint64(0); seed < 200; seed++ {
s := oneCardAway(t, seed)
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Uno: true})
if err != nil {
t.Fatalf("seed %d: play: %v", seed, err)
}
if hasKind(evs, EvCaught) {
t.Fatalf("seed %d: caught after calling UNO", seed)
}
if !hasKind(evs, EvUno) {
t.Fatalf("seed %d: called UNO and the table never said so", seed)
}
if !next.Called[You] {
t.Fatalf("seed %d: the call wasn't recorded", seed)
}
}
}
// TestForgettingUnoGetsYouCaught — stay quiet on one card and the bot takes you
// for two. It gets one look, so it misses sometimes; over 400 games it should
// land near botAlert, and the two cards should actually arrive.
func TestForgettingUnoGetsYouCaught(t *testing.T) {
caught, games := 0, 400
for seed := uint64(0); seed < uint64(games); seed++ {
s := oneCardAway(t, seed)
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("seed %d: play: %v", seed, err)
}
if !hasKind(evs, EvCaught) {
continue
}
caught++
// One card left after the play, plus the two the catch cost.
if n := len(next.Hands[You]); n != 3 {
t.Fatalf("seed %d: caught and holding %d, want 3", seed, n)
}
if hasKind(evs, EvUno) {
t.Fatalf("seed %d: an UNO was announced by a seat that never called", seed)
}
}
rate := float64(caught) / float64(games)
if rate < botAlert-0.08 || rate > botAlert+0.08 {
t.Errorf("one bot caught you %.0f%% of the time, want about %.0f%% (botAlert)",
rate*100, botAlert*100)
}
}
// TestMoreBotsMeansLessGettingAwayWithIt — every seat gets its own look, so
// forgetting at a full table is very nearly always punished.
func TestMoreBotsMeansLessGettingAwayWithIt(t *testing.T) {
away := func(tier Tier, seed uint64) bool {
s := deal(t, tier, seed)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
s.Turn = You
s.Phase = PhasePlay
_, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play: %v", err)
}
return !hasKind(evs, EvCaught)
}
var got [2]float64
for i, tier := range []Tier{duel(), table()} {
escapes := 0
for seed := uint64(0); seed < 400; seed++ {
if away(tier, seed) {
escapes++
}
}
got[i] = float64(escapes) / 400
}
if got[1] >= got[0] {
t.Errorf("you got away with it %.0f%% of the time against one bot and %.0f%% against two; "+
"two pairs of eyes should catch you more often, not less", got[0]*100, got[1]*100)
}
}
// quietBot puts a bot on one card it never called, with the turn back on you.
func quietBot(t *testing.T, called bool) State {
t.Helper()
s := deal(t, duel(), 21)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}, {Blue, Two}, {Green, Three}}
s.Hands[1] = []Card{{Yellow, Nine}}
s.Called = []bool{false, called}
s.Turn = You
s.Phase = PhasePlay
return s
}
// TestCatchingAQuietBot — it's on one card and it never said so. Two cards to it,
// and the turn is still yours: catching is not a move you spend a turn on.
func TestCatchingAQuietBot(t *testing.T) {
s := quietBot(t, false)
before := total(census(s))
next, evs, err := ApplyMove(s, You, Move{Kind: MoveCatch, Seat: 1})
if err != nil {
t.Fatalf("catch: %v", err)
}
if !hasKind(evs, EvCaught) {
t.Fatal("no catch event")
}
if n := len(next.Hands[1]); n != 3 {
t.Errorf("the bot holds %d, want 3: one card, plus the two it just took", n)
}
if n := len(next.Hands[You]); n != 3 {
t.Errorf("your hand is %d, want 3: a catch costs you nothing", n)
}
if next.Turn != You {
t.Errorf("the turn went to seat %d: a catch is not a turn", next.Turn)
}
if total(census(next)) != before {
t.Error("the catch lost a card")
}
}
// TestCatchingACleanBotCostsYou — it called, or it isn't on one card at all.
// Either way you've accused it of nothing, and that is two cards to you.
func TestCatchingACleanBotCostsYou(t *testing.T) {
for _, tc := range []struct {
name string
state State
}{
{"it called", quietBot(t, true)},
{"it isn't even close", func() State {
s := quietBot(t, false)
s.Hands[1] = []Card{{Yellow, Nine}, {Yellow, Eight}}
return s
}()},
} {
t.Run(tc.name, func(t *testing.T) {
next, evs, err := ApplyMove(tc.state, You, Move{Kind: MoveCatch, Seat: 1})
if err != nil {
t.Fatalf("catch: %v", err)
}
if !hasKind(evs, EvMiscall) {
t.Fatal("no miscall event")
}
if n := len(next.Hands[You]); n != 5 {
t.Errorf("your hand is %d, want 5: three, plus the two a bad call cost", n)
}
if next.Turn != You {
t.Errorf("the turn went to seat %d: even a bad catch isn't a turn", next.Turn)
}
})
}
}
// TestYouCannotCatchYourself, or a seat that isn't at the table.
func TestYouCannotCatchYourself(t *testing.T) {
s := quietBot(t, false)
for _, seat := range []int{You, -1, 9} {
if _, _, err := ApplyMove(s, You, Move{Kind: MoveCatch, Seat: seat}); err != ErrNoCatch {
t.Errorf("catching seat %d: got %v, want ErrNoCatch", seat, err)
}
}
}
// TestACallIsSpentWhenTheHandGrows. Call on one card, get made to draw, and work
// your way back down to one: that is a new call you owe, not the old one still
// standing. Without this a seat could be caught out once and never again.
func TestACallIsSpentWhenTheHandGrows(t *testing.T) {
s := deal(t, duel(), 5)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}}
s.Called = []bool{true, false}
s.Turn = You
s.Phase = PhasePlay
// Draw, and the hand is two: the word you said was about a card you no longer
// hold on its own.
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.Called[You] {
t.Error("the call survived the hand growing; it should be spent")
}
}
// TestCatchableIsWhatTheTableCanSee — a quiet bot on one card, and nobody else.
func TestCatchable(t *testing.T) {
s := quietBot(t, false)
if got := s.Catchable(You); len(got) != 1 || got[0] != 1 {
t.Errorf("Catchable() = %v, want [1]", got)
}
clean := quietBot(t, true)
if got := clean.Catchable(You); len(got) != 0 {
t.Errorf("Catchable() = %v on a bot that called, want none", got)
}
// And not on somebody else's turn: you can only call it out when it's on you.
off := quietBot(t, false)
off.Turn = 1
if got := off.Catchable(You); len(got) != 0 {
t.Errorf("Catchable() = %v off-turn, want none", got)
}
}
// TestUnoAtSeesThroughDiscardAll — the whole reason the table asks the engine
// which cards take you to one, rather than counting your hand itself. "Discard
// all" takes every card of its colour with it, so a six-card hand can land on
// one, and a browser subtracting one from six gets a player caught.
func TestUnoAtSeesThroughDiscardAll(t *testing.T) {
s := deal(t, nmDuel(), 3)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Red, Seven}, {Blue, Two}}
s.Turn = You
s.Phase = PhasePlay
// Index 0 dumps itself and the three other reds: five cards become one.
// Index 4 is an ordinary play: five become four.
got := s.UnoAt(You)
if len(got) != 1 || got[0] != 0 {
t.Errorf("UnoAt() = %v, want [0]: only the discard-all lands you on one card", got)
}
}
// TestUnoAtIsTheOrdinaryCaseToo — two cards in hand, and either of them is a call.
func TestUnoAtIsTheOrdinaryCaseToo(t *testing.T) {
s := oneCardAway(t, 1)
got := s.UnoAt(You)
if len(got) != 2 {
t.Errorf("UnoAt() = %v, want both cards: either one leaves you holding one", got)
}
}
// TestGoingOutNeedsNoCall — your last card is not one card, it's none. Nobody
// owes the table a word for winning.
func TestGoingOutNeedsNoCall(t *testing.T) {
s := deal(t, duel(), 9)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}}
s.Turn = You
s.Phase = PhasePlay
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play the last card: %v", err)
}
if next.Winner != You {
t.Fatalf("winner %d outcome %q, want a win for you", next.Winner, next.Outcome)
}
if hasKind(evs, EvCaught) {
t.Error("caught for not calling UNO on the card that won the game")
}
}
// TestABotThatForgetsSaysNothing — the tell is the absence of the badge, and the
// count beside the fan. If a quiet bot emitted anything at all there'd be nothing
// to spot.
func TestABotThatForgetsSaysNothing(t *testing.T) {
quiet := 0
for seed := uint64(0); seed < 300 && quiet < 1; seed++ {
s := deal(t, duel(), seed)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Blue, Two}, {Blue, Three}, {Blue, Four}}
s.Hands[1] = []Card{{Red, One}, {Red, Nine}}
s.Turn = 1
s.Phase = PhasePlay
s.Turn = You // the bot plays on the back of your move
// Draw, handing the turn over: the bot plays a red and lands on one card.
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if len(next.Hands[1]) != 1 || next.Called[1] {
continue // it either didn't get down to one, or it remembered
}
quiet++
if hasKind(evs, EvUno) {
t.Error("a bot that forgot to call still announced it")
}
if len(next.Catchable(You)) != 1 {
t.Error("a quiet bot on one card isn't catchable")
}
}
if quiet == 0 {
t.Skip("no bot forgot in 300 games; botForget may have been turned down")
}
}

View File

@@ -0,0 +1,237 @@
package uno
import "math/rand/v2"
// No Mercy.
//
// A rules dial, not a fourth table. The table size is still the tier — a duel is
// a duel — and No Mercy is a switch you throw across all three of them. What it
// changes is the game they play:
//
// - A 168-card deck, with faces the normal one doesn't print: a coloured +4, a
// +6, a +10, a skip-everyone, a discard-all, a reverse-and-draw-four, and a
// colour roulette.
// - Draw cards stack. A +2 pointed at you can be answered with any draw card
// you hold, and the bill goes to the next seat with the two added on. Whoever
// runs out of draw cards eats the lot.
// - You draw until you can play. There is no drawing one card and shrugging.
// - And twenty-five cards in your hand kills you. That is the whole point of
// the deck: it is built to bury somebody, and the mercy rule is what happens
// when it does.
//
// Everything here is reached from uno.go behind `s.Tier.NoMercy`. A normal game
// never runs a line of it.
// MercyLimit is the hand that ends you. Reach it and you are out of the game —
// your cards go back in the deck and the table plays on without you.
const MercyLimit = 25
// NewNoMercyDeck builds the 168.
//
// Per colour: two of each number, three skips, two skip-everyones, four
// reverses, two +2s, two coloured +4s and three discard-alls — thirty-six cards,
// times four colours. Then the wilds: eight reverse-draw-fours, four +6s, four
// +10s and eight roulettes. Unshuffled, same as NewDeck, because New shuffles and
// a test wants the order it was built in.
func NewNoMercyDeck() []Card {
d := make([]Card, 0, 168)
for _, col := range []Color{Red, Blue, Yellow, Green} {
for v := Zero; v <= Nine; v++ {
d = append(d, Card{col, v}, Card{col, v})
}
for i := 0; i < 3; i++ {
d = append(d, Card{col, Skip})
}
for i := 0; i < 2; i++ {
d = append(d, Card{col, SkipAll})
}
for i := 0; i < 4; i++ {
d = append(d, Card{col, Reverse})
}
for i := 0; i < 2; i++ {
d = append(d, Card{col, DrawTwo})
}
for i := 0; i < 2; i++ {
d = append(d, Card{col, DrawFour})
}
for i := 0; i < 3; i++ {
d = append(d, Card{col, DiscardAll})
}
}
for i := 0; i < 8; i++ {
d = append(d, Card{Wild, WildRevFour})
}
for i := 0; i < 4; i++ {
d = append(d, Card{Wild, WildDrawSix})
}
for i := 0; i < 4; i++ {
d = append(d, Card{Wild, WildDrawTen})
}
for i := 0; i < 8; i++ {
d = append(d, Card{Wild, WildRoulette})
}
return d
}
// CanStackOn reports whether a card can be thrown onto a stack that is already
// building. Any draw card answers any other — there is no escalation rule, so a
// +2 is a legal reply to a +10 — but a *coloured* draw card still has to follow
// the colour in play. The wild draws always go.
//
// This is why the pending count is not a cap: what you are matching is the fact
// of a draw card, not its size.
func (c Card) CanStackOn(topColor Color) bool {
if c.Value.Draw() == 0 {
return false
}
if c.IsWild() {
return true
}
return c.Color == topColor
}
// canStack reports whether a seat holds anything at all it could answer with.
func (s State) canStack(seat int) bool {
for _, c := range s.Hands[seat] {
if c.CanStackOn(s.Color) {
return true
}
}
return false
}
// absorb is what happens when the stack stops with you: you take every card in
// it, and you lose your turn. The pending count is cleared *before* the cards
// land, because a mercy kill inside the draw ends the seat and there must be no
// bill left standing against a seat that is no longer at the table.
func (s *State) absorb(seat int, evs *[]Event, rng *rand.Rand) {
n := s.Pending
s.Pending = 0
s.deal(seat, n, true, evs, rng)
// The seat can die paying the bill, and a mercy kill can end the hand — the last
// seat but one dying leaves somebody alone to take the pot. So the phase is only
// reset if there is still a hand to have a phase.
if s.mercy(seat, evs, rng) && !s.playing() {
return
}
if !s.live(seat) {
s.Phase = PhasePlay
s.advance(1)
return // it died, but the table plays on. Don't skip a seat that isn't there.
}
*evs = append(*evs, Event{Kind: EvSkip, Seat: seat, Left: len(s.Hands[seat])})
s.Phase = PhasePlay
s.advance(1) // the turn is on the seat that just paid, so it moves one on
}
// roulette is the colour roulette: the next seat turns cards over until the
// named colour comes up, and keeps every card it turned. Then it loses its turn.
//
// The deck can run out mid-flip (the discard is reshuffled back under as usual,
// and even that can be dry), so this is bounded by what there is to draw, not by
// the colour ever actually appearing. A wild is not a colour and never ends it.
func (s *State) roulette(victim int, color Color, evs *[]Event, rng *rand.Rand) {
got := 0
for {
if len(s.Deck) == 0 && !s.reshuffle(evs, rng) {
break
}
c, ok := s.pop()
if !ok {
break
}
s.Hands[victim] = append(s.Hands[victim], c)
got++
if c.Color == color {
break
}
if len(s.Hands[victim]) >= MercyLimit {
break // they are dead already; stop dealing cards to a corpse
}
}
if got > 0 {
e := Event{Kind: EvRoulette, Seat: victim, N: got, Color: color, Left: len(s.Hands[victim])}
*evs = append(*evs, s.mine(e))
}
if s.mercy(victim, evs, rng) {
return
}
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim, Left: len(s.Hands[victim])})
s.advance(2)
}
// discardAll dumps every remaining card of a colour out of a hand and buries it
// under the card that was just played. The pile keeps its top: the played card
// stays the card in play, and the rest go beneath it, where they are still in the
// game (a reshuffle brings them back) and still count in a census.
func (s *State) discardAll(seat int, color Color, evs *[]Event) int {
hand := s.Hands[seat]
kept := make([]Card, 0, len(hand))
var dumped []Card
for _, c := range hand {
if c.Color == color && !c.IsWild() {
dumped = append(dumped, c)
} else {
kept = append(kept, c)
}
}
s.Hands[seat] = kept
if len(dumped) > 0 {
top := s.Discard[len(s.Discard)-1]
s.Discard = append(s.Discard[:len(s.Discard)-1], dumped...)
s.Discard = append(s.Discard, top)
*evs = append(*evs, s.mine(Event{Kind: EvDiscardAll, Seat: seat, N: len(dumped),
Color: color, Left: len(kept)}))
}
return len(dumped)
}
// mercy checks a seat against the limit and, if it has crossed it, takes it out
// of the hand: its cards go back into the deck and it plays no more this hand. It
// reports whether the seat died.
//
// A mercy kill is no longer game over for anyone — it is one seat out of the
// current hand, human or bot alike, and the hand plays on among the living. When
// it leaves exactly one seat alive, that seat has outlived the table and takes the
// pot; whoever it is, they win it the same way going out first would.
func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool {
if !s.Tier.NoMercy || !s.live(seat) || len(s.Hands[seat]) < MercyLimit {
return false
}
n := len(s.Hands[seat])
s.Deck = append(s.Deck, s.Hands[seat]...)
rng.Shuffle(len(s.Deck), func(i, j int) { s.Deck[i], s.Deck[j] = s.Deck[j], s.Deck[i] })
s.Hands[seat] = nil
s.Out[seat] = true
s.Pending = 0 // a dead seat pays no bill, and leaves none behind
*evs = append(*evs, s.mine(Event{Kind: EvMercy, Seat: seat, N: n, Left: 0}))
if alive := s.alive(); len(alive) == 1 {
s.settle(alive[0], OutcomeWon, evs) // the last one standing takes the pot
}
return true
}
// Live reports whether a seat is still in the game. The felt needs it: a seat the
// mercy rule has buried holds no cards, and a seat holding no cards is otherwise
// indistinguishable from the one that just went out and won.
func (s State) Live(seat int) bool { return s.live(seat) }
// alive lists the seats still in the game.
func (s State) alive() []int {
var out []int
for i := range s.Hands {
if s.live(i) {
out = append(out, i)
}
}
return out
}
// live reports whether a seat is still in the current hand. Out is empty between
// hands and in any game saved before No Mercy existed, so a missing entry is a
// living seat.
func (s State) live(seat int) bool {
return seat >= len(s.Out) || !s.Out[seat]
}

View File

@@ -0,0 +1,347 @@
package uno
import (
"math/rand/v2"
"testing"
)
func nmDuel() Tier { t, _ := TierBySlug("nm-duel"); return t }
func nmTable() Tier { t, _ := TierBySlug("nm-table"); return t }
func nmFull() Tier { t, _ := TierBySlug("nm-full"); return t }
func TestNoMercyDeckIsADeck(t *testing.T) {
m := census(State{Deck: NewNoMercyDeck()})
if got := total(m); got != 168 {
t.Fatalf("deck has %d cards, want 168", got)
}
want := map[Card]int{
{Red, Zero}: 2, // two of every number, unlike the normal deck's single zero
{Blue, Seven}: 2,
{Green, Skip}: 3,
{Yellow, SkipAll}: 2,
{Red, Reverse}: 4,
{Blue, DrawTwo}: 2,
{Green, DrawFour}: 2, // the *coloured* +4
{Yellow, DiscardAll}: 3,
{Wild, WildRevFour}: 8,
{Wild, WildDrawSix}: 4,
{Wild, WildDrawTen}: 4,
{Wild, WildRoulette}: 8,
}
for c, n := range want {
if m[c] != n {
t.Errorf("%v %v: got %d, want %d", c.Color, c.Value, m[c], n)
}
}
if m[Card{Wild, WildCard}] != 0 || m[Card{Wild, WildDrawFour}] != 0 {
t.Error("the No Mercy deck should print none of the normal wilds")
}
}
// TestNoMercyCensus is the load-bearing one: 168 cards, each in exactly one place,
// checked after every move of a hundred hands played to the end.
func TestNoMercyCensus(t *testing.T) {
for _, tier := range []Tier{nmDuel(), nmTable(), nmFull()} {
for seed := uint64(0); seed < 100; seed++ {
s := deal(t, tier, seed)
if got := total(census(s)); got != 168 {
t.Fatalf("%s seed %d: dealt %d cards, want 168", tier.Slug, seed, got)
}
rng := rand.New(rand.NewPCG(seed, 99))
for moves := 0; s.playing() && moves < 800; moves++ {
next, _, err := ApplyMove(s, You, naive(s, rng))
if err != nil {
t.Fatalf("%s seed %d: %v (phase %s)", tier.Slug, seed, err, s.Phase)
}
s = next
if got := census(s); total(got) != 168 {
t.Fatalf("%s seed %d: %d cards after a move, want 168",
tier.Slug, seed, total(got))
}
}
if s.playing() {
t.Fatalf("%s seed %d: hand never ended", tier.Slug, seed)
}
}
}
}
// naive is a bad-but-real strategy: play the first legal card, take a stack you
// can't answer, and draw when you have nothing. Always played from the human seat.
func naive(s State, rng *rand.Rand) Move {
if s.Phase == PhaseStack {
if p := s.Playable(You); len(p) > 0 {
return playMove(s, p[0], rng)
}
return Move{Kind: MoveTake}
}
if p := s.Playable(You); len(p) > 0 {
return playMove(s, p[0], rng)
}
return Move{Kind: MoveDraw}
}
// stack loads a seat's hand up to n cards by taking them off the deck, so the
// table still holds 168 of them.
func stack(s *State, seat, n int) {
s.Deck = append(s.Deck, s.Hands[seat]...)
s.Hands[seat] = nil
s.Color = s.top().Color
kept := make([]Card, 0, len(s.Deck))
for _, c := range s.Deck {
if len(s.Hands[seat]) < n {
s.Hands[seat] = append(s.Hands[seat], c)
continue
}
kept = append(kept, c)
}
s.Deck = kept
}
func playMove(s State, idx int, rng *rand.Rand) Move {
m := Move{Kind: MovePlay, Index: idx}
if s.Hands[You][idx].IsWild() {
m.Color = Red + Color(rng.IntN(4))
}
for _, at := range s.UnoAt(You) {
if at == idx {
m.Uno = true
}
}
return m
}
// TestAStackIsPassedOnAndPaid walks the rule the whole mode turns on: a draw card
// opens a bill, and the seat that can't answer pays the whole thing.
func TestAStackIsPassedOnAndPaid(t *testing.T) {
s := deal(t, nmDuel(), 7)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
s.Hands[1] = []Card{{Red, DrawTwo}, {Blue, Nine}}
s.Turn = You
s.Phase = PhasePlay
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play +2: %v", err)
}
if next.Phase != PhaseStack {
t.Fatalf("phase is %s, want stack: a +2 in No Mercy opens a stack", next.Phase)
}
if next.Turn != You {
t.Fatalf("the stack came back to seat %d, want you", next.Turn)
}
if next.Pending != 4 {
t.Fatalf("the bill is %d, want 4 (your two, plus the bot's two)", next.Pending)
}
if !hasKind(evs, EvStack) {
t.Error("no stack event: the felt has nothing to show the player")
}
if _, _, err := ApplyMove(next, You, Move{Kind: MoveDraw}); err != ErrMustStack {
t.Errorf("drawing out of a stack: %v, want ErrMustStack", err)
}
if _, _, err := ApplyMove(next, You, Move{Kind: MovePlay, Index: 0}); err != ErrMustStack {
t.Errorf("playing a plain card under a stack: %v, want ErrMustStack", err)
}
before := len(next.Hands[You])
paid, evs, err := ApplyMove(next, You, Move{Kind: MoveTake})
if err != nil {
t.Fatalf("take: %v", err)
}
var forced int
for _, e := range evs {
if e.Kind == EvForced && e.Seat == You {
forced = e.N
}
}
if forced != 4 {
t.Errorf("the stack made you take %d cards, want 4", forced)
}
if len(paid.Hands[You]) < before+4 {
t.Errorf("hand went %d → %d, want at least four more", before, len(paid.Hands[You]))
}
if paid.Pending == 4 && paid.Phase == PhaseStack {
t.Error("the bill you just paid is still standing")
}
}
// TestMercyKillsThePlayerButNotTheSession: the mercy limit takes the human out of
// the *hand* — the pot goes to whoever is left, and the table plays on. It is no
// longer game over.
func TestMercyKillsThePlayerButNotTheSession(t *testing.T) {
s := deal(t, nmDuel(), 3)
stack(&s, You, 24)
s.Turn = You
s.Phase = PhaseStack
s.Pending = 10
next, evs, err := ApplyMove(s, You, Move{Kind: MoveTake})
if err != nil {
t.Fatalf("take: %v", err)
}
if !hasKind(evs, EvMercy) {
t.Fatal("no mercy event: twenty-five cards should have killed the seat")
}
if next.playing() {
t.Fatalf("the hand should be over once one seat is left: phase %s", next.Phase)
}
if next.live(You) || len(next.Hands[You]) != 0 {
t.Error("a dead seat should hold no cards and be out of the hand")
}
// Heads-up, killing the human leaves the bot alone: it takes the pot.
if next.Winner != 1 {
t.Errorf("winner is seat %d, want the surviving bot", next.Winner)
}
if got := total(census(next)); got != 168 {
t.Errorf("%d cards after a mercy kill, want 168 — the hand goes back in the deck", got)
}
}
// TestOutlivingTheTableWins: the deck buries bots too, and a table with every bot
// dead is a pot you have taken.
func TestOutlivingTheTableWins(t *testing.T) {
s := deal(t, nmDuel(), 11)
pot := s.Pot
before := s.Seats[You].Stack
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
s.Hands[1] = make([]Card, 0, 24)
for i := 0; i < 24; i++ {
s.Hands[1] = append(s.Hands[1], Card{Blue, Nine}) // nothing it can answer with
}
s.Turn = You
s.Phase = PhasePlay
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play +2: %v", err)
}
if !hasKind(evs, EvMercy) {
t.Fatal("the bot should have died taking the stack")
}
if next.playing() || next.Winner != You {
t.Fatalf("phase %s winner %d, want a finished hand won by you", next.Phase, next.Winner)
}
profit := pot - s.Tier.Ante
wantWon := pot - int64(float64(profit)*rake)
if next.Seats[You].Stack != before+wantWon {
t.Errorf("your stack is %d, want %d", next.Seats[You].Stack, before+wantWon)
}
}
func TestYouDrawUntilYouCanPlay(t *testing.T) {
s := deal(t, nmDuel(), 5)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Blue, One}} // nothing playable
s.Deck = []Card{{Green, Two}, {Yellow, Three}, {Red, Nine}, {Blue, Four}}
s.Turn = You
s.Phase = PhasePlay
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if len(next.Hands[You]) != 4 {
t.Fatalf("hand is %d, want 4: you draw until something plays", len(next.Hands[You]))
}
if next.Phase != PhaseDrawn {
t.Fatalf("phase %s, want drawn: the card you stopped on is one you must play", next.Phase)
}
if _, _, err := ApplyMove(next, You, Move{Kind: MovePass}); err != ErrMustPlayNow {
t.Errorf("passing in No Mercy: %v, want ErrMustPlayNow", err)
}
}
func TestSkipAllComesBackToYou(t *testing.T) {
s := deal(t, nmFull(), 13)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, SkipAll}, {Blue, One}}
s.Turn = You
s.Phase = PhasePlay
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play skip-all: %v", err)
}
if next.Turn != You {
t.Errorf("turn went to seat %d, want you: skip-all skips everyone else", next.Turn)
}
if !hasKind(evs, EvSkipAll) {
t.Error("no skipall event")
}
}
func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
s := deal(t, nmDuel(), 17)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Blue, Two}}
s.Turn = You
s.Phase = PhasePlay
before := total(census(s))
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Uno: true})
if err != nil {
t.Fatalf("play discard-all: %v", err)
}
if len(next.Hands[You]) != 1 {
t.Fatalf("hand is %d, want 1: every red should have gone with it", len(next.Hands[You]))
}
if next.Hands[You][0] != (Card{Blue, Two}) {
t.Errorf("kept %v, want the blue two", next.Hands[You][0])
}
// The discard-all was played, so it is on the pile — the bot may have played over
// it since, which is why this looks for it rather than at the very top.
found := false
for _, c := range next.Discard {
if c.Value == DiscardAll {
found = true
}
}
if !found {
t.Error("the discard-all isn't on the pile")
}
if !hasKind(evs, EvDiscardAll) {
t.Error("no discard event")
}
if got := total(census(next)); got != before {
t.Errorf("%d cards, want %d: a dumped colour is buried, not destroyed", got, before)
}
}
func TestRouletteFlipsUntilTheColour(t *testing.T) {
s := deal(t, nmDuel(), 19)
s.Color = Blue
s.Discard = []Card{{Blue, Five}}
s.Hands[You] = []Card{{Wild, WildRoulette}, {Blue, One}}
s.Hands[1] = []Card{{Green, Three}}
s.Deck = []Card{{Blue, Two}, {Green, Four}, {Yellow, Six}, {Red, Seven}, {Blue, Eight}}
s.Turn = You
s.Phase = PhasePlay
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Red})
if err != nil {
t.Fatalf("play roulette: %v", err)
}
var got int
for _, e := range evs {
if e.Kind == EvRoulette {
got = e.N
}
}
if got != 4 {
t.Errorf("flipped %d, want 4 — up to and including the first red", got)
}
if n := len(next.Hands[1]); n != 5 {
t.Errorf("the bot holds %d, want 5", n)
}
if total(census(next)) != total(census(s)) {
t.Error("the roulette lost a card")
}
}

1320
internal/games/uno/uno.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,807 @@
package uno
import (
"encoding/json"
"math/rand/v2"
"testing"
)
const rake = 0.05
// You is the human's seat in the solo tests, a test-local alias for what the
// engine no longer hardcodes: a table is a list of seats, and seat zero being the
// human is a convention only these fixtures keep.
const You = 0
func duel() Tier { t, _ := TierBySlug("duel"); return t }
func full() Tier { t, _ := TierBySlug("full"); return t }
func table() Tier { t, _ := TierBySlug("table"); return t }
// openSolo opens a solo table (one human, the tier's bots) without dealing.
func openSolo(t *testing.T, tier Tier, seed uint64) State {
t.Helper()
s, _, err := New(tier, SoloSeats(tier, tier.Bots, 1000), rake, seed, 0xC0FFEE)
if err != nil {
t.Fatalf("New: %v", err)
}
return s
}
// deal opens a solo table and deals the first hand, so the human is to act.
func deal(t *testing.T, tier Tier, seed uint64) State {
t.Helper()
s := openSolo(t, tier, seed)
s, evs, err := ApplyMove(s, You, Move{Kind: MoveDeal})
if err != nil {
t.Fatalf("deal: %v", err)
}
if !hasKind(evs, EvDeal) {
t.Fatalf("the deal emitted no deal event: %+v", evs)
}
if s.Turn != You {
t.Fatalf("the human should act first at a solo table, turn is %d", s.Turn)
}
return s
}
// census counts every card in the game, wherever it is. It is the invariant the
// whole engine has to hold: 108 cards, each of them in exactly one place.
func census(s State) map[Card]int {
m := map[Card]int{}
for _, h := range s.Hands {
for _, c := range h {
m[c]++
}
}
for _, c := range s.Deck {
m[c]++
}
for _, c := range s.Discard {
if c.Value.Wild() {
c.Color = Wild
}
m[c]++
}
return m
}
func total(m map[Card]int) int {
n := 0
for _, v := range m {
n += v
}
return n
}
func TestNewDeckIsADeck(t *testing.T) {
m := census(State{Deck: NewDeck()})
if got := total(m); got != 108 {
t.Fatalf("deck has %d cards, want 108", got)
}
if m[Card{Red, Zero}] != 1 {
t.Errorf("want one red zero, got %d", m[Card{Red, Zero}])
}
if m[Card{Blue, Seven}] != 2 {
t.Errorf("want two blue sevens, got %d", m[Card{Blue, Seven}])
}
if m[Card{Wild, WildDrawFour}] != 4 {
t.Errorf("want four +4s, got %d", m[Card{Wild, WildDrawFour}])
}
}
func TestNewDeals(t *testing.T) {
s := deal(t, full(), 7)
if len(s.Hands) != 4 {
t.Fatalf("full house is four seats, got %d", len(s.Hands))
}
for i, h := range s.Hands {
if len(h) != HandSize {
t.Errorf("seat %d holds %d cards, want %d", i, len(h), HandSize)
}
}
bots := 0
for _, seat := range s.Seats {
if seat.Bot {
bots++
if seat.Name == "" {
t.Error("a bot seat has no name")
}
}
}
if bots != 3 {
t.Fatalf("want three bots, got %d", bots)
}
if got := total(census(s)); got != 108 {
t.Fatalf("the deal lost cards: %d of 108", got)
}
// Every seat anted, so the pot is one ante per seat.
if want := s.Tier.Ante * int64(len(s.Seats)); s.Pot != want {
t.Errorf("pot is %d, want %d (one ante each)", s.Pot, want)
}
}
// The card turned over to start is never an action card — see dealHand.
func TestOpeningCardIsANumber(t *testing.T) {
for seed := uint64(0); seed < 300; seed++ {
s := deal(t, table(), seed)
if s.Top().Value.Action() {
t.Fatalf("seed %d opened on %v", seed, s.Top())
}
if s.Color != s.Top().Color {
t.Fatalf("seed %d: colour in play is %v, top card is %v", seed, s.Color, s.Top())
}
}
}
// ---- the rules ------------------------------------------------------------
// rig builds a live hand by hand, so a rule can be tested without hunting a seed
// that happens to deal it. Every seat has anted, so the pot is set and a win
// settles for real.
func rig(hands [][]Card, top Card, color Color) State {
left := map[Card]int{}
for _, c := range NewDeck() {
left[c]++
}
take := func(c Card) {
if c.IsWild() {
c.Color = Wild
}
left[c]--
}
for _, h := range hands {
for _, c := range h {
take(c)
}
}
take(top)
var deck []Card
for _, c := range NewDeck() {
if left[c] > 0 {
left[c]--
deck = append(deck, c)
}
}
ante := full().Ante
seats := make([]Seat, len(hands))
for i := range seats {
// The stack is what is left after this seat anted: a real deal moves the ante
// out of the stack and into the pot, so a refund or a win returns it here.
seats[i] = Seat{Name: botPool[i], Bot: i != You, Stack: 1000 - ante, Ante: ante}
}
seats[You].Name = "You"
return State{
Tier: full(), Seats: seats, Hands: hands, Discard: []Card{top}, Color: color,
Deck: deck, Dir: 1, Turn: You, Dealer: len(hands) - 1, Phase: PhasePlay,
Pot: ante * int64(len(hands)), Winner: -1,
Out: make([]bool, len(hands)), Called: make([]bool, len(hands)),
RakePct: rake, Seed1: 1, Seed2: 2,
}
}
func TestPlayMustMatch(t *testing.T) {
s := rig([][]Card{{{Blue, Three}}, {{Red, Five}}}, Card{Red, Nine}, Red)
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}); err != ErrCantPlay {
t.Fatalf("a blue 3 on a red 9 should be refused, got %v", err)
}
}
func TestPlayMatchesFaceOrColor(t *testing.T) {
s := rig([][]Card{{{Blue, Nine}, {Red, Two}}, {{Green, Five}}}, Card{Red, Nine}, Red)
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("a blue 9 on a red 9 is legal: %v", err)
}
if next.Color != Blue {
t.Errorf("colour in play should follow the card: %v", next.Color)
}
if evs[0].Kind != EvPlay || evs[0].Seat != You {
t.Errorf("first event should be your play, got %+v", evs[0])
}
}
func TestWildNeedsAColor(t *testing.T) {
s := rig([][]Card{{{Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor {
t.Fatalf("a wild with no colour should be refused, got %v", err)
}
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Wild}); err != ErrNeedColor {
t.Fatalf("naming 'wild' is not naming a colour, got %v", err)
}
}
func TestWildNamesTheColor(t *testing.T) {
s := rig([][]Card{{{Wild, WildCard}, {Green, One}}, {{Green, Five}}}, Card{Red, Nine}, Red)
next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Green})
if err != nil {
t.Fatalf("play wild: %v", err)
}
top := next.Discard
if len(top) < 2 {
t.Fatalf("expected the wild and the bot's card on the pile: %v", top)
}
if top[1] != (Card{Green, WildCard}) {
t.Errorf("the wild should sit on the pile as green, got %v", top[1])
}
}
func TestDrawTwoHitsTheNextSeat(t *testing.T) {
s := rig([][]Card{{{Red, DrawTwo}, {Red, One}}, {{Blue, Five}, {Blue, Six}}}, Card{Red, Nine}, Red)
s.Tier = duel()
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play +2: %v", err)
}
if len(next.Hands[1]) != 4 {
t.Errorf("the bot should hold 2 + 2 = 4 cards, got %d", len(next.Hands[1]))
}
if next.Turn != You {
t.Errorf("the bot was skipped, so it should be your turn: %d", next.Turn)
}
if !hasKind(evs, EvForced) || !hasKind(evs, EvSkip) {
t.Errorf("a +2 is a forced draw and a skip: %+v", evs)
}
if got := total(census(next)); got != 108 {
t.Fatalf("the +2 lost cards: %d of 108", got)
}
}
func TestReverseIsASkipHeadsUp(t *testing.T) {
s := rig([][]Card{{{Red, Reverse}, {Red, One}}, {{Blue, Five}}}, Card{Red, Nine}, Red)
s.Tier = duel()
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play reverse: %v", err)
}
if next.Dir != 1 {
t.Errorf("with two at the table a reverse doesn't turn the table around: dir %d", next.Dir)
}
if next.Turn != You {
t.Errorf("the bot should have been skipped, turn is %d", next.Turn)
}
if !hasKind(evs, EvSkip) || hasKind(evs, EvReverse) {
t.Errorf("heads up, a reverse reads as a skip: %+v", evs)
}
if len(next.Hands[1]) != 1 {
t.Errorf("the bot never played, so it still holds one card: %d", len(next.Hands[1]))
}
}
func TestReverseTurnsTheTableAround(t *testing.T) {
s := rig([][]Card{
{{Red, Reverse}, {Red, One}},
{{Red, Five}, {Blue, Six}},
{{Red, Six}, {Green, Six}},
{{Red, Seven}, {Yellow, Six}},
}, Card{Red, Nine}, Red)
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play reverse: %v", err)
}
if next.Dir != -1 {
t.Errorf("four at the table: a reverse turns it around, dir %d", next.Dir)
}
if !hasKind(evs, EvReverse) {
t.Errorf("want a reverse event: %+v", evs)
}
if next.Turn != You {
t.Errorf("the bots should have played round to you, turn is %d", next.Turn)
}
var order []int
for _, e := range evs {
if e.Kind == EvPlay && e.Seat != You {
order = append(order, e.Seat)
}
}
if len(order) != 3 || order[0] != 3 || order[1] != 2 || order[2] != 1 {
t.Errorf("the bots played in the order %v, want [3 2 1]", order)
}
}
func TestSkipSkips(t *testing.T) {
s := rig([][]Card{
{{Red, Skip}, {Red, One}},
{{Red, Five}, {Blue, Six}},
{{Red, Six}, {Green, Six}},
}, Card{Red, Nine}, Red)
s.Tier = table()
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play skip: %v", err)
}
if !hasKind(evs, EvSkip) {
t.Errorf("want a skip event: %+v", evs)
}
for _, e := range evs {
if e.Kind == EvPlay && e.Seat == 1 {
t.Errorf("seat 1 was skipped and should not have played: %+v", e)
}
}
if len(next.Hands[1]) != 2 {
t.Errorf("seat 1 was skipped and should still hold two: %d", len(next.Hands[1]))
}
if len(next.Hands[2]) != 1 {
t.Errorf("seat 2 was not skipped and should have played: %d", len(next.Hands[2]))
}
}
// ---- drawing --------------------------------------------------------------
func TestDrawnPlayableWaitsForYou(t *testing.T) {
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
s.Deck = []Card{{Red, Four}} // exactly what you'll draw, and it plays
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.Phase != PhaseDrawn {
t.Fatalf("a playable draw should stop and ask, phase is %v", next.Phase)
}
if next.Turn != You {
t.Fatalf("the turn should still be yours: %d", next.Turn)
}
if evs[0].Kind != EvDraw || evs[0].Card == nil || *evs[0].Card != (Card{Red, Four}) {
t.Fatalf("your own drawn card comes face up: %+v", evs[0])
}
if got := next.Playable(You); len(got) != 1 || got[0] != 1 {
t.Errorf("the drawn card, and only it, is playable: %v", got)
}
if _, _, err := ApplyMove(next, You, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow {
t.Fatalf("only the drawn card may be played, got %v", err)
}
if _, _, err := ApplyMove(next, You, Move{Kind: MoveDraw}); err != ErrMustPlayNow {
t.Fatalf("you can't draw twice, got %v", err)
}
after, _, err := ApplyMove(next, You, Move{Kind: MovePass})
if err != nil {
t.Fatalf("pass: %v", err)
}
if after.Phase != PhasePlay || after.Turn != You {
t.Fatalf("after passing the bot plays and it comes back to you: phase %v turn %d", after.Phase, after.Turn)
}
if len(after.Hands[You]) != 2 {
t.Errorf("you kept the card you drew: %d", len(after.Hands[You]))
}
}
func TestUnplayableDrawPassesTheTurn(t *testing.T) {
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
s.Deck = []Card{{Blue, Four}, {Red, Two}} // draw a blue 4: it doesn't go on a red 9
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.Phase != PhasePlay {
t.Errorf("nothing to decide, so no pause: %v", next.Phase)
}
if !hasKind(evs, EvPass) {
t.Errorf("the turn passed, and the table should be told: %+v", evs)
}
}
func TestPassOnlyAfterADraw(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
if _, _, err := ApplyMove(s, You, Move{Kind: MovePass}); err != ErrCantPass {
t.Fatalf("you can't pass a turn you haven't drawn on, got %v", err)
}
}
// dead is a table nobody can move at: the deck is spent, the discard is one card
// deep, and not a seat holds a card that goes on the pile.
func dead(hands [][]Card) State {
s := rig(hands, Card{Red, Nine}, Red)
s.Deck = nil
return s
}
// A dead table ends the hand rather than passing the turn round forever. It no
// longer ends the *session* — a shared table plays another hand — so it lands on
// PhaseHandOver, not PhaseDone.
func TestDeadTableEnds(t *testing.T) {
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}}) // level: one card each
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.playing() {
t.Fatalf("nobody can move and there is nothing to draw: the hand is over, not %q", next.Phase)
}
if next.Phase != PhaseHandOver {
t.Fatalf("a dead hand at a shared table returns to handover, not %q", next.Phase)
}
if next.Outcome != OutcomeTie {
t.Errorf("level on the shortest hand is a tie, got %q", next.Outcome)
}
// A tie hands the antes back: every seat is whole again.
for i := range next.Seats {
if next.Seats[i].Stack != 1000 {
t.Errorf("seat %d wasn't refunded: stack %d, want 1000", i, next.Seats[i].Stack)
}
}
if !hasKind(evs, EvSettle) {
t.Errorf("the table has to be told the hand is over: %+v", evs)
}
}
// And the shortest hand takes the pot, which is the one way a stuck table pays.
func TestDeadTablePaysTheShortestHand(t *testing.T) {
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}, {Green, Six}}})
pot := s.Pot
before := s.Seats[You].Stack
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.Outcome != OutcomeStuck || next.Winner != You {
t.Fatalf("one card against two is a win for you: outcome %q winner %d", next.Outcome, next.Winner)
}
profit := pot - s.Tier.Ante
wantRake := int64(float64(profit) * rake)
wantWon := pot - wantRake
if next.Seats[You].Won != wantWon {
t.Errorf("you took %d, want %d (pot %d less rake %d)", next.Seats[You].Won, wantWon, pot, wantRake)
}
if next.Seats[You].Stack != before+wantWon {
t.Errorf("your stack is %d, want %d", next.Seats[You].Stack, before+wantWon)
}
}
func TestReshuffleRebuildsTheDeck(t *testing.T) {
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
s.Deck = nil
s.Discard = []Card{{Green, WildCard}, {Red, Two}, {Red, Nine}}
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw on an empty deck: %v", err)
}
if !hasKind(evs, EvReshuffle) {
t.Fatalf("want a reshuffle: %+v", evs)
}
if len(next.Discard) == 0 || next.Discard[0] != (Card{Red, Nine}) {
t.Errorf("the card in play stays on the pile: %v", next.Discard)
}
for _, c := range next.Deck {
if c.Value == WildCard && c.Color != Wild {
t.Errorf("a wild went back into the deck stamped %v", c.Color)
}
}
for _, h := range next.Hands {
for _, c := range h {
if c.Value == WildCard && c.Color != Wild {
t.Errorf("a wild was dealt out stamped %v", c.Color)
}
}
}
}
// ---- the pot --------------------------------------------------------------
// The winner takes the pot, and the house's rake comes out of the winnings, never
// out of a seat's own ante.
func TestWinnerTakesThePotLessRake(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}, {{Blue, One}, {Blue, Two}}}, Card{Red, Nine}, Red)
pot := s.Pot
before := s.Seats[You].Stack
next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) // your last card
if err != nil {
t.Fatalf("go out: %v", err)
}
if next.Outcome != OutcomeWon || next.Winner != You {
t.Fatalf("playing your last card wins: outcome %q winner %d", next.Outcome, next.Winner)
}
profit := pot - s.Tier.Ante
wantRake := int64(float64(profit) * rake)
wantWon := pot - wantRake
if next.Rake != wantRake {
t.Errorf("rake %d, want %d — and never a penny of an ante", next.Rake, wantRake)
}
if next.LastPot != pot {
t.Errorf("last pot %d, want %d", next.LastPot, pot)
}
if next.Seats[You].Stack != before+wantWon {
t.Errorf("your stack is %d, want %d (+%d)", next.Seats[You].Stack, before+wantWon, wantWon)
}
if next.Paid != wantRake {
t.Errorf("the session rake tally is %d, want %d", next.Paid, wantRake)
}
}
// A bot winning rakes nothing: the house already keeps the whole pot when its own
// seat takes it, so there is nothing to charge.
func TestABotWinningRakesNothing(t *testing.T) {
// The bot at seat 1 holds one card that plays; it goes out the moment the turn
// reaches it.
s := rig([][]Card{{{Red, Three}, {Red, Four}}, {{Red, Five}}}, Card{Red, Nine}, Red)
s.Tier = duel()
pot := s.Pot
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play: %v", err)
}
if next.Outcome != OutcomeWon || next.Winner != 1 {
t.Fatalf("the bot went out: outcome %q winner %d", next.Outcome, next.Winner)
}
if next.Rake != 0 {
t.Errorf("a bot winning rakes nothing, got %d", next.Rake)
}
if next.Seats[1].Won != pot {
t.Errorf("the bot took %d, want the whole pot %d", next.Seats[1].Won, pot)
}
// You anted and lost it: your stack is down exactly one ante.
if next.Seats[You].Stack != 1000-s.Tier.Ante {
t.Errorf("your stack is %d, want %d (one ante gone)", next.Seats[You].Stack, 1000-s.Tier.Ante)
}
last := evs[len(evs)-1]
if last.Kind != EvSettle || last.Seat != 1 {
t.Errorf("the settle should name the winner: %+v", last)
}
}
// A hand ending returns the table to handover, ready to deal again — it does not
// take a hand move.
func TestNoHandMoveBetweenHands(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
s.Tier = duel()
over, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("go out: %v", err)
}
if over.Phase != PhaseHandOver {
t.Fatalf("a finished hand returns to handover, got %q", over.Phase)
}
if _, _, err := ApplyMove(over, You, Move{Kind: MoveDraw}); err != ErrNoHand {
t.Fatalf("no hand is in progress between hands, got %v", err)
}
}
// You can deal the next hand, ante again, and play on — the session shape.
func TestDealTheNextHand(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
s.Tier = duel()
over, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("go out: %v", err)
}
again, evs, err := ApplyMove(over, You, Move{Kind: MoveDeal})
if err != nil {
t.Fatalf("deal the next hand: %v", err)
}
if again.HandNo != over.HandNo+1 {
t.Errorf("hand number didn't advance: %d then %d", over.HandNo, again.HandNo)
}
if !again.playing() {
t.Fatalf("the next hand should be live, phase %q", again.Phase)
}
if !hasKind(evs, EvAnte) || !hasKind(evs, EvDeal) {
t.Errorf("the deal antes and turns a card: %+v", evs)
}
}
func TestBadBuyIn(t *testing.T) {
if _, _, err := New(duel(), SoloSeats(duel(), 1, 10), rake, 1, 2); err != ErrBadBuyIn {
t.Fatalf("a buy-in under the minimum should be refused, got %v", err)
}
}
// ---- the whole game -------------------------------------------------------
// playOut plays one hand to its end with a simple strategy: play the first legal
// card, take a stack you can't answer, otherwise draw, otherwise pass.
func playOut(t *testing.T, s State, maxTurns int) State {
t.Helper()
for turn := 0; s.playing(); turn++ {
if turn > maxTurns {
t.Fatalf("the hand never ended in %d turns", maxTurns)
}
if s.Turn != You {
t.Fatalf("ApplyMove left the turn with seat %d — the bots should always run out", s.Turn)
}
var m Move
if p := s.Playable(You); len(p) > 0 {
m = Move{Kind: MovePlay, Index: p[0]}
if s.Hands[You][p[0]].IsWild() {
m.Color = Green
}
} else if s.Phase == PhaseStack {
m = Move{Kind: MoveTake}
} else if s.Phase == PhaseDrawn {
m = Move{Kind: MovePass}
} else {
m = Move{Kind: MoveDraw}
}
next, evs, err := ApplyMove(s, You, m)
if err != nil {
t.Fatalf("turn %d: %v (move %+v, phase %v)", turn, err, m, s.Phase)
}
if len(evs) == 0 {
t.Fatalf("turn %d: a move that happened emitted nothing", turn)
}
if got := total(census(next)); got != 108 {
t.Fatalf("turn %d: %d cards of 108 — a card was lost or minted", turn, got)
}
for c, n := range census(next) {
if want := deckCount(c); n != want {
t.Fatalf("turn %d: %d of %v, want %d — a card was duplicated", turn, n, c, want)
}
}
s = next
}
return s
}
// deckCount is how many of a given card a 108 deck holds.
func deckCount(c Card) int {
switch {
case c.Color == Wild:
return 4
case c.Value == Zero:
return 1
default:
return 2
}
}
// A hundred hands, played out, with the invariants checked at every step.
func TestGamesPlayOut(t *testing.T) {
yous, others, ties := 0, 0, 0
for seed := uint64(0); seed < 100; seed++ {
tier := Tiers[seed%3]
end := playOut(t, deal(t, tier, seed), 500)
if end.Phase != PhaseHandOver {
t.Fatalf("seed %d ended in phase %q", seed, end.Phase)
}
switch {
case end.Winner == You:
yous++
case end.Winner >= 0:
others++
case end.Outcome == OutcomeTie:
ties++
default:
t.Fatalf("seed %d ended with winner %d outcome %q", seed, end.Winner, end.Outcome)
}
if end.Winner >= 0 && end.Outcome == OutcomeWon && len(end.Hands[end.Winner]) != 0 {
t.Fatalf("seed %d: the winner is still holding cards", seed)
}
}
if yous == 0 || others == 0 {
t.Fatalf("100 hands gave %d to you, %d to others, %d tied — one side never happens", yous, others, ties)
}
t.Logf("100 hands: %d to you, %d to others, %d tied", yous, others, ties)
}
// The same seed deals the same hand and the bots make the same choices.
func TestReplaysFromTheSeed(t *testing.T) {
a := playOut(t, deal(t, full(), 42), 500)
b := playOut(t, deal(t, full(), 42), 500)
ja, _ := json.Marshal(a)
jb, _ := json.Marshal(b)
if string(ja) != string(jb) {
t.Fatal("the same seed played the same way gave two different games")
}
if a.Winner < 0 && a.Outcome != OutcomeTie {
t.Fatal("the replay didn't finish")
}
}
// A game in progress survives a redeploy: it round-trips through its JSON.
func TestStateSurvivesStorage(t *testing.T) {
s := deal(t, table(), 9)
s, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
blob, err := json.Marshal(s)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var back State
if err := json.Unmarshal(blob, &back); err != nil {
t.Fatalf("unmarshal: %v", err)
}
again, _ := json.Marshal(back)
if string(again) != string(blob) {
t.Fatal("a stored game came back different")
}
playOut(t, back, 500)
}
// A move the engine refuses leaves the caller's state exactly as it was.
func TestARefusedMoveChangesNothing(t *testing.T) {
s := rig([][]Card{{{Blue, Three}, {Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
before, _ := json.Marshal(s)
for _, m := range []Move{
{Kind: MovePlay, Index: 0}, // doesn't match
{Kind: MovePlay, Index: 1}, // wild with no colour
{Kind: MovePlay, Index: 9}, // no such card
{Kind: MovePass}, // nothing drawn
{Kind: "shuffle-the-deck-in-my-favour"}, // no
} {
if _, _, err := ApplyMove(s, You, m); err == nil {
t.Fatalf("%+v should have been refused", m)
}
}
after, _ := json.Marshal(s)
if string(before) != string(after) {
t.Fatal("a refused move touched the state")
}
}
// The bots choose. Two different seeds should not play the same game.
func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) {
same := 0
for seed := uint64(0); seed < 20; seed++ {
a := playOut(t, deal(t, duel(), seed), 500)
b := playOut(t, deal(t, duel(), seed+1000), 500)
if len(a.Discard) == len(b.Discard) {
same++
}
}
if same == 20 {
t.Fatal("every seed played out to the same length — the bots aren't choosing")
}
}
func TestBotSavesTheDrawFour(t *testing.T) {
hand := []Card{{Wild, WildDrawFour}, {Red, Five}}
top, color := Card{Red, Nine}, Red
rng := rand.New(rand.NewPCG(1, 2))
held := 0
for i := 0; i < 50; i++ {
if _, idx := botPick(hand, top, color, 5, rng); idx == 1 {
held++
}
}
if held < 30 {
t.Errorf("with the table comfortable the bot should mostly play the red 5, held %d/50", held)
}
reached := 0
for i := 0; i < 50; i++ {
if _, idx := botPick(hand, top, color, 1, rng); idx == 0 {
reached++
}
}
if reached < 30 {
t.Errorf("with a player on one card the bot should mostly play the +4, reached %d/50", reached)
}
}
func TestBotPicksItsBestColor(t *testing.T) {
rng := rand.New(rand.NewPCG(3, 4))
hand := []Card{{Blue, One}, {Blue, Two}, {Green, Three}, {Wild, WildCard}}
if got := botColor(hand, rng); got != Blue {
t.Errorf("the bot holds two blues: it should call blue, got %v", got)
}
for i := 0; i < 20; i++ {
if got := botColor([]Card{{Wild, WildCard}}, rng); !got.Playable() {
t.Fatalf("botColor named %v, which is not a colour", got)
}
}
}
func TestBotHasNothingToPlay(t *testing.T) {
if _, idx := botPick([]Card{{Blue, Three}}, Card{Red, Nine}, Red, 3, rand.New(rand.NewPCG(1, 1))); idx != -1 {
t.Errorf("a hand with nothing legal should report -1, got %d", idx)
}
}
func hasKind(evs []Event, kind string) bool {
for _, e := range evs {
if e.Kind == kind {
return true
}
}
return false
}

180
internal/opentdb/opentdb.go Normal file
View File

@@ -0,0 +1,180 @@
// Package opentdb fills the casino's trivia bank from the Open Trivia Database.
//
// The questions are *prefetched* into a local table, not fetched per question,
// and that is a deliberate call rather than an optimisation. A trivia ladder
// asks a question every fifteen seconds with money on the clock: a per-question
// fetch would put somebody else's latency, rate limit and downtime inside a
// timed round the player is being scored against. Pull the bank in the
// background, and a round becomes a local read that either works or doesn't.
//
// OpenTDB allows one request every five seconds per IP and caps a batch at 50,
// so the refill is a slow, polite drip, run in the background and never in the
// path of anything a player is waiting for.
package opentdb
import (
"context"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/url"
"strings"
"time"
"pete/internal/games/trivia"
"pete/internal/safehttp"
)
// endpoint is the API. It is the only host this package ever talks to, and it
// goes through safehttp like every other outbound fetch in Pete.
const endpoint = "https://opentdb.com/api.php"
// Batch is the most OpenTDB will hand over in one request.
const Batch = 50
// Politeness is the gap the API asks for between requests. Going faster earns a
// response_code 5 and nothing else.
const Politeness = 6 * time.Second
// fetchTimeout bounds a single request. The refill runs in the background, so a
// slow answer costs nothing but its own goroutine — but it must still end.
const fetchTimeout = 20 * time.Second
// maxBody caps what we will read from the API, hostile or merely broken.
const maxBody = 1 << 20
// apiResponse is OpenTDB's envelope. ResponseCode is the part that matters:
// zero is the only one that means "here are your questions".
type apiResponse struct {
ResponseCode int `json:"response_code"`
Results []struct {
Category string `json:"category"`
Type string `json:"type"`
Question string `json:"question"`
Correct string `json:"correct_answer"`
Incorrect []string `json:"incorrect_answers"`
} `json:"results"`
}
// responseErr turns a non-zero code into something a log line can explain.
func responseErr(code int) error {
switch code {
case 1:
return fmt.Errorf("opentdb: no results for that query")
case 2:
return fmt.Errorf("opentdb: the query was invalid")
case 3, 4:
return fmt.Errorf("opentdb: session token expired or exhausted")
case 5:
return fmt.Errorf("opentdb: rate limited — slow down")
default:
return fmt.Errorf("opentdb: response code %d", code)
}
}
// Client fetches questions.
type Client struct {
http *http.Client
}
func New() *Client {
return &Client{http: safehttp.NewClient(fetchTimeout)}
}
// Fetch pulls up to n multiple-choice questions of one difficulty.
//
// Only "multiple" questions are asked for: the ladder is four buttons, and a
// true/false question on the same felt would be a coin flip dressed up as a
// question — and a coin flip the player is being paid a difficulty multiple for.
func (c *Client) Fetch(ctx context.Context, difficulty string, n int) ([]trivia.Question, error) {
if n <= 0 || n > Batch {
n = Batch
}
q := url.Values{
"amount": {fmt.Sprint(n)},
"difficulty": {difficulty},
"type": {"multiple"},
}
raw := endpoint + "?" + q.Encode()
if err := safehttp.ValidateURL(raw); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "pete-games/1.0 (+https://games.parodia.dev)")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("opentdb: http %d", resp.StatusCode)
}
body, err := io.ReadAll(safehttp.LimitedBody(resp.Body, maxBody))
if err != nil {
return nil, err
}
var out apiResponse
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("opentdb: %w", err)
}
if out.ResponseCode != 0 {
return nil, responseErr(out.ResponseCode)
}
qs := make([]trivia.Question, 0, len(out.Results))
for _, r := range out.Results {
// The API hands back HTML entities ("Who wrote &quot;Dune&quot;?"), which
// would otherwise be drawn literally onto a button.
text := clean(r.Question)
correct := clean(r.Correct)
if text == "" || correct == "" || len(r.Incorrect) != 3 {
continue // a malformed question is one we simply don't take
}
// Correct: 0 here is a convention, not a tell. The engine reshuffles every
// question against the game's own seed as it builds the ladder, so where
// the right answer sits in the bank never reaches a player.
answers := make([]string, 0, 4)
answers = append(answers, correct)
dupe := false
for _, w := range r.Incorrect {
a := clean(w)
// A wrong answer that reads the same as the right one — usually two
// spellings that collapse once the entities are decoded — is a question
// with two identical buttons on it, and the shuffle can only call one of
// them correct. A player who clicked the right words and was told they
// were wrong has lost the whole ladder to our typography. Drop it.
if a == "" || a == correct {
dupe = true
break
}
answers = append(answers, a)
}
if dupe || len(answers) != 4 {
continue
}
qs = append(qs, trivia.Question{
Category: clean(r.Category),
Text: text,
Answers: answers,
Correct: 0,
})
}
return qs, nil
}
// clean turns an API string into something you can put on a button: entities
// decoded, whitespace tidied.
func clean(s string) string {
return strings.TrimSpace(html.UnescapeString(s))
}

View File

@@ -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,128 @@ 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.
--
-- Prefetched rather than fetched per question because a trivia ladder asks a
-- question every fifteen seconds with money on a clock the player is scored
-- against. A live fetch would put somebody else's latency and rate limit inside
-- that clock. The refill is a slow background drip (internal/opentdb); a round
-- never waits on it.
--
-- The question text is UNIQUE, which is the whole dedup strategy: OpenTDB hands back
-- overlapping batches and the bank would otherwise fill up with the same forty
-- questions. correct/incorrect are stored as the API gives them; the *shuffle*
-- happens in the engine, per game, against that game's seed — so where the right
-- answer sits in this table tells a player nothing.
CREATE TABLE IF NOT EXISTS trivia_questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
difficulty TEXT NOT NULL, -- 'easy' | 'medium' | 'hard'
category TEXT NOT NULL,
question TEXT NOT NULL UNIQUE,
correct TEXT NOT NULL,
incorrect TEXT NOT NULL, -- JSON array of the three wrong answers
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_trivia_difficulty ON trivia_questions(difficulty);
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id);
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);

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

147
internal/storage/trivia.go Normal file
View File

@@ -0,0 +1,147 @@
package storage
import (
"encoding/json"
"fmt"
"math/rand/v2"
"time"
"pete/internal/games/trivia"
)
// The trivia bank.
//
// Questions are pulled from OpenTDB in the background (internal/opentdb) and
// drawn from here when a ladder is built. Nothing in a player's round ever
// touches the network.
// ErrBankEmpty means the bank hasn't got enough questions of that difficulty to
// build a ladder. It is a real state, not a bug: a fresh database has an empty
// bank until the refill loop has been round a few times.
var ErrBankEmpty = fmt.Errorf("trivia: the bank is short of questions")
// AddTriviaQuestions files a fetched batch. Questions already in the bank are
// ignored rather than replaced — OpenTDB hands back overlapping batches, and the
// UNIQUE on the text is what stops the bank becoming forty questions deep.
// Returns how many were actually new, which is what the refill loop logs.
func AddTriviaQuestions(difficulty string, qs []trivia.Question) (int, error) {
if len(qs) == 0 {
return 0, nil
}
tx, err := Get().Begin()
if err != nil {
return 0, fmt.Errorf("trivia: begin: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
stmt, err := tx.Prepare(
`INSERT OR IGNORE INTO trivia_questions
(difficulty, category, question, correct, incorrect, fetched_at)
VALUES (?, ?, ?, ?, ?, ?)`)
if err != nil {
return 0, fmt.Errorf("trivia: prepare: %w", err)
}
defer stmt.Close()
now := time.Now().Unix()
added := 0
for _, q := range qs {
if len(q.Answers) < 2 || q.Correct < 0 || q.Correct >= len(q.Answers) {
continue
}
correct := q.Answers[q.Correct]
wrong := make([]string, 0, len(q.Answers)-1)
for i, a := range q.Answers {
if i != q.Correct {
wrong = append(wrong, a)
}
}
blob, err := json.Marshal(wrong)
if err != nil {
continue
}
res, err := stmt.Exec(difficulty, q.Category, q.Text, correct, string(blob), now)
if err != nil {
return added, fmt.Errorf("trivia: insert: %w", err)
}
if n, err := res.RowsAffected(); err == nil {
added += int(n)
}
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("trivia: commit: %w", err)
}
return added, nil
}
// CountTrivia is how many questions of a difficulty the bank holds. The refill
// loop reads it to decide whether to bother.
func CountTrivia(difficulty string) (int, error) {
var n int
if err := Get().QueryRow(
`SELECT COUNT(*) FROM trivia_questions WHERE difficulty = ?`, difficulty,
).Scan(&n); err != nil {
return 0, fmt.Errorf("trivia: count: %w", err)
}
return n, nil
}
// DrawTrivia deals a ladder: n distinct questions of one difficulty, chosen with
// the game's own rng.
//
// The choice is made in Go rather than with ORDER BY RANDOM() so that the seed
// in the audit log means something: the same seed against the same bank deals
// the same ladder, which is what lets a disputed game be replayed. It reads the
// ids first and picks from them, so a bank of a few thousand questions costs one
// small scan rather than a sort of the whole table.
func DrawTrivia(difficulty string, n int, rng *rand.Rand) ([]trivia.Question, error) {
if n <= 0 {
return nil, nil
}
rows, err := Get().Query(
`SELECT id FROM trivia_questions WHERE difficulty = ? ORDER BY id`, difficulty)
if err != nil {
return nil, fmt.Errorf("trivia: draw ids: %w", err)
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, fmt.Errorf("trivia: scan id: %w", err)
}
ids = append(ids, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("trivia: draw ids: %w", err)
}
if len(ids) < n {
return nil, ErrBankEmpty
}
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
pick := ids[:n]
out := make([]trivia.Question, 0, n)
for _, id := range pick {
var q trivia.Question
var correct, blob string
if err := Get().QueryRow(
`SELECT category, question, correct, incorrect FROM trivia_questions WHERE id = ?`, id,
).Scan(&q.Category, &q.Text, &correct, &blob); err != nil {
return nil, fmt.Errorf("trivia: load question: %w", err)
}
var wrong []string
if err := json.Unmarshal([]byte(blob), &wrong); err != nil {
return nil, fmt.Errorf("trivia: unreadable answers: %w", err)
}
// Correct: 0 is a convention the engine immediately destroys — New()
// reshuffles every question against the game's seed. Nothing that reaches a
// player depends on the order they come out of the table in.
q.Answers = append([]string{correct}, wrong...)
q.Correct = 0
out = append(out, q)
}
return out, nil
}

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

@@ -1,6 +1,7 @@
package web
import (
"context"
"encoding/json"
"fmt"
"io/fs"
@@ -9,6 +10,10 @@ import (
"os"
"testing"
"time"
"pete/internal/games/trivia"
"pete/internal/opentdb"
"pete/internal/storage"
)
// TestDevCasino is not a test. It is the casino, running, on a port, with one
@@ -30,12 +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 {
@@ -44,28 +55,74 @@ func TestDevCasino(t *testing.T) {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
mux.HandleFunc("GET /games", s.handleLobby)
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /api/games/table", s.handleTable)
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
s.casinoRoutes(mux)
ln, err := net.Listen("tcp", addr)
if err != nil {
t.Fatal(err)
}
// Written to a file, not printed: `go test` buffers stdout, and the browser
// driver needs the cookie while the server is still running.
// 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/blackjack\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.
//
// The rig does not run StartTriviaBank — a dev casino that spends its first two
// minutes dripping four hundred questions per difficulty out of a free API is a
// dev casino you cannot use. But a fresh database has an empty bank, and an
// empty bank means every start 503s, so the rig would be unable to show you the
// one game it exists to show you.
//
// One real batch per difficulty, through the real client: fifty questions is
// four ladders' worth, and it means what the browser renders came out of OpenTDB
// and through the same decode-and-store path production uses, entities and all.
func seedTriviaBank(t *testing.T) {
t.Helper()
ctx := context.Background()
client := opentdb.New()
for i, tier := range trivia.Tiers {
have, err := storage.CountTrivia(tier.Difficulty)
if err != nil {
t.Fatal(err)
}
if have >= trivia.Rungs {
continue
}
if i > 0 {
time.Sleep(opentdb.Politeness) // the API asks; asking faster earns nothing
}
qs, err := client.Fetch(ctx, tier.Difficulty, opentdb.Batch)
if err != nil {
t.Fatalf("seeding the trivia bank (%s): %v", tier.Difficulty, err)
}
added, err := storage.AddTriviaQuestions(tier.Difficulty, qs)
if err != nil {
t.Fatal(err)
}
fmt.Printf("BANK %-6s %d questions\n", tier.Difficulty, added)
}
}

214
internal/web/games_clock.go Normal file
View File

@@ -0,0 +1,214 @@
package web
import (
"context"
"encoding/json"
"errors"
"log/slog"
"time"
"pete/internal/storage"
)
// The turn clock: the first goroutine in Pete that has ever mutated game state.
//
// Every other background loop here reads, refills or prunes. This one plays. When
// a human sits at a felt and walks away mid-hand, three other people are waiting
// on a decision that is never coming, and something has to make it for them. That
// something is this.
//
// It is built on one discipline and one guard.
//
// The discipline is rule 1: **collect the due tables and close the rows before
// taking any lock.** The scan reads *sql.Rows from the one-connection pool; a lock
// taken while those rows are open would hold the connection the rows need, and the
// process would wedge. So DueTables returns a plain slice and the connection is
// free before the first table is touched.
//
// The guard is the version. The scan records each table's version; the clock acts
// on a table only if it is *still* that version by the time it holds the lock and
// has reloaded. Without that check the clock and a real move race and both land:
// Bob raises in the same second his clock expires, the action moves to Cara, and
// the clock — still believing the seat that ran out of time is to act — folds
// Cara, who had twenty-five seconds left. The version check turns that into a
// no-op: Bob's move bumped the version, the reload shows the new one, and the
// clock steps aside.
// clockInterval is how often the clock looks for expired turns. Sub-second
// precision buys nothing at a card table and would only spin the CPU.
const clockInterval = time.Second
// reaperInterval is how often idle sessions are cashed out. The reaper is a
// slow-moving safety net — a player who wandered off half an hour ago is not in a
// hurry — so it runs far less often than the clock.
const reaperInterval = time.Minute
// games returns the multiplayer engines by their storage key. It is the registry
// the clock and the handlers both dispatch through. Empty until an engine is
// wired; a clock over no games is a loop that finds nothing, which is correct.
func (s *Server) games() map[string]tableGame {
out := make(map[string]tableGame)
for _, g := range s.tableGames {
out[g.name()] = g
}
return out
}
// StartTableClock launches the turn clock and the session reaper if the casino is
// on. Safe to call unconditionally.
func (s *Server) StartTableClock(ctx context.Context) {
if !s.gamesReady() {
return
}
// A deploy just took every table's clock down with it. Shove the live deadlines
// out so the first tick does not auto-act the whole room at once.
if err := storage.PushDeadlines(bootGrace); err != nil {
slog.Error("games: push deadlines on boot", "err", err)
}
go s.runTableClock(ctx)
go s.runSessionReaper(ctx)
go s.runTableReaper(ctx)
}
func (s *Server) runTableClock(ctx context.Context) {
slog.Info("games: turn clock started", "interval", clockInterval)
ticker := time.NewTicker(clockInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.tickClock()
}
}
}
// tickClock finds the tables whose turn has expired and acts on each. The scan is
// done and the connection released before any table is locked — rule 1.
func (s *Server) tickClock() {
due, err := storage.DueTables(time.Now().Unix())
if err != nil {
slog.Error("games: due tables", "err", err)
return
}
for _, ref := range due {
s.runClockTable(ref)
}
}
// runClockTable acts for the walked-away player at one table, but only if the
// table is still at the version the scan saw.
//
// The whole read-modify-write is done under the table's stripe, which is what
// keeps the clock from racing a second copy of itself — but the stripe is only an
// optimisation. The version check inside CommitTable is the real thing: even
// across a redeploy, when two processes hold two different stripes over this row,
// the one whose write lands first bumps the version and the other's write finds
// zero rows and rolls back.
func (s *Server) runClockTable(ref storage.TableRef) {
err := s.tableLocks.withTable(ref.ID, func() error {
t, seats, err := storage.LoadTable(ref.ID)
if errors.Is(err, storage.ErrNoSuchTable) {
return nil // closed out from under us; nothing to do
}
if err != nil {
return err
}
// Somebody moved between the scan and now. Their move set a fresh deadline (or
// cleared it), so this expiry is stale — step aside.
if t.Version != ref.Version {
return nil
}
// A deadline in the future means the scan is looking at an old view; leave it.
if t.Deadline == 0 || t.Deadline > time.Now().Unix() {
return nil
}
game := s.games()[t.Game]
if game == nil {
slog.Error("games: clock over unknown game", "game", t.Game, "table", t.ID)
return nil
}
st, newSeats, err := game.timeout(t.State, seats)
if errors.Is(err, errNotDue) {
return nil // the race resolved without us; nothing to act on
}
if err != nil {
slog.Error("games: clock timeout", "table", t.ID, "err", err)
return nil
}
t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
if err := storage.CommitTable(storage.TableCommit{Table: t, Seats: newSeats, Audit: st.Audit}); err != nil {
if errors.Is(err, storage.ErrStaleTable) {
return nil // lost the race after all; the winner handled it
}
return err
}
s.publishTable(ref.ID)
return nil
})
if err != nil {
slog.Error("games: clock table", "table", ref.ID, "err", err)
}
}
// publishTable pushes the current table view to everyone watching it. It reads
// the table fresh (the authoritative state) and fans an opaque frame out through
// the hub. Called after every committed write, under no lock the hub cares about
// — the hub's sends are non-blocking, so this never stalls a caller.
//
// A view here is deliberately seat-blind: it carries only what every seat may see
// (the version and the public table shape), and each subscriber's own stream
// redacts and re-renders for the seat that is watching. That keeps a hole card
// from ever entering a frame that fans to the whole table.
func (s *Server) publishTable(tableID string) {
if s.hub.watchers(tableID) == 0 {
return
}
t, _, err := storage.LoadTable(tableID)
if errors.Is(err, storage.ErrNoSuchTable) {
return
}
if err != nil {
slog.Error("games: publish table load", "table", tableID, "err", err)
return
}
// The frame is just a nudge carrying the version: a subscriber that sees a gap
// refetches the authoritative, per-seat table. So the payload can be minimal.
// The type tells the browser a table changed (come and look) from a chat line
// (render it in place) — both ride the one stream.
data, _ := json.Marshal(map[string]any{"type": "table", "version": t.Version, "phase": t.Phase})
s.hub.publish(tableID, hubFrame{Version: t.Version, Data: data})
}
// runSessionReaper cashes out players who wandered off, on a timer. This is the
// loop the plan noted never existed: ReapIdleSessions has always been safe to run
// and nothing ever ran it, so chips in abandoned *solo* sessions sat in limbo.
//
// A seated player is invisible to it — their chips are inside a table blob, not on
// their game_chips stack — so it only ever reaps a player who is genuinely idle
// with loose chips. Getting up from a table returns them to the stack, and then
// this is what eventually sends them home.
func (s *Server) runSessionReaper(ctx context.Context) {
slog.Info("games: session reaper started", "interval", reaperInterval, "idle", storage.SessionIdleAfter)
ticker := time.NewTicker(reaperInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
n, err := storage.ReapIdleSessions(storage.SessionIdleAfter)
if err != nil {
slog.Error("games: reap idle sessions", "err", err)
continue
}
if n > 0 {
slog.Info("games: reaped idle sessions", "count", n)
}
}
}
}

View File

@@ -0,0 +1,142 @@
package web
import (
"path/filepath"
"testing"
"time"
"pete/internal/storage"
)
// fakeGame is a tableGame that records whether its clock ever fired. It lets the
// runtime tests exercise the lock discipline and the version guard without a real
// engine — the thing under test is the clock, not the cards.
type fakeGame struct {
fired int
}
func (g *fakeGame) name() string { return "fake" }
func (g *fakeGame) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
g.fired++
// Act: clear the deadline and bump the hand, as a real settle would.
return step{State: []byte(`{"acted":true}`), Phase: "handover", HandNo: 2, Deadline: 0}, seats, nil
}
func (g *fakeGame) stacks(state []byte) ([]int64, error) { return []int64{0, 0}, nil }
// clockTestServer stands up a Server with just the table machinery wired and a
// fresh DB. Enough to drive the clock, nothing else.
func clockTestServer(t *testing.T, g tableGame) *Server {
t.Helper()
// Reset the storage singleton onto a temp DB.
if err := storage.Init(filepath.Join(t.TempDir(), "clock.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
s := &Server{hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{g}}
return s
}
func openClockTable(t *testing.T, id string, deadline int64) storage.Table {
t.Helper()
tbl := storage.Table{
ID: id, Game: "fake", Tier: "1-2", State: []byte(`{}`),
Seed1: 1, Seed2: 2, Phase: "betting", HandNo: 1, Deadline: deadline,
}
seats := []storage.Seat{{Seat: 0, MatrixUser: "@reala:parodia.dev", Name: "reala"}, {Seat: 1, Name: "bot"}}
if err := storage.OpenTable(tbl, seats); err != nil {
t.Fatal(err)
}
return tbl
}
func TestClock_ActsOnExpiredTable(t *testing.T) {
g := &fakeGame{}
s := clockTestServer(t, g)
openClockTable(t, "t1", time.Now().Unix()-5) // already expired
s.tickClock()
if g.fired != 1 {
t.Fatalf("clock should have fired once, got %d", g.fired)
}
after, _, err := storage.LoadTable("t1")
if err != nil {
t.Fatal(err)
}
if after.Phase != "handover" || after.Deadline != 0 {
t.Errorf("table should have advanced: %+v", after)
}
}
func TestClock_IgnoresFutureDeadlines(t *testing.T) {
g := &fakeGame{}
s := clockTestServer(t, g)
openClockTable(t, "t1", time.Now().Unix()+60)
s.tickClock()
if g.fired != 0 {
t.Fatalf("clock should not have fired on a future deadline, got %d", g.fired)
}
}
// TestClock_VersionGuardStopsTheDoubleMove is the scenario the whole design turns
// on. A move lands in the same tick the clock's scan found the table expired. The
// move bumps the version; the clock, acting on its stale scan, must see the new
// version and step aside rather than acting a second time.
func TestClock_VersionGuardStopsTheDoubleMove(t *testing.T) {
g := &fakeGame{}
s := clockTestServer(t, g)
tbl := openClockTable(t, "t1", time.Now().Unix()-5)
// The scan saw version 0.
due, err := storage.DueTables(time.Now().Unix())
if err != nil {
t.Fatal(err)
}
if len(due) != 1 {
t.Fatalf("want 1 due table, got %d", len(due))
}
// A real move lands first, bumping the version and setting a fresh deadline for
// the next player.
tbl.State = []byte(`{"moved":true}`)
tbl.Deadline = time.Now().Unix() + 30
if err := storage.CommitTable(storage.TableCommit{Table: tbl}); err != nil {
t.Fatal(err)
}
// Now the clock acts on its stale scan (version 0). It must not fire.
s.runClockTable(due[0])
if g.fired != 0 {
t.Fatalf("the version guard should have stopped the clock, but it fired %d time(s)", g.fired)
}
after, _, _ := storage.LoadTable("t1")
if string(after.State) != `{"moved":true}` {
t.Errorf("the real move should stand, got %s", after.State)
}
}
func TestClock_PublishesToWatchers(t *testing.T) {
g := &fakeGame{}
s := clockTestServer(t, g)
openClockTable(t, "t1", time.Now().Unix()-5)
ch, done := s.hub.subscribe("t1")
defer done()
s.tickClock()
select {
case f := <-ch:
if f.Version == 0 {
t.Errorf("frame should carry the bumped version, got %d", f.Version)
}
default:
t.Fatal("a watcher should have received a frame after the clock acted")
}
}

View File

@@ -0,0 +1,210 @@
package web
import (
"encoding/json"
"errors"
"log/slog"
"math/rand/v2"
"net/http"
"pete/internal/games/blackjack"
"pete/internal/games/hangman"
"pete/internal/storage"
)
// Hangman, played for chips.
//
// The same shape as the blackjack table: the browser sends intents, the server
// holds the state, and the payload carries only what the player is entitled to
// see. Here that means the *masked* phrase. The unmasked one is in the engine
// state, which is in game_live_hands, which is on this side of the wire — a
// phrase sent down and flagged hidden is a phrase read out of devtools, and the
// game would be a formality.
// cellView is one position in the phrase, as the browser draws it.
//
// Ch is empty while the letter is hidden — not the letter with a flag beside
// it. Slot says whether this is a position you'd guess at all: a space or an
// exclamation mark is scaffolding, shows from the start, and gets no tile.
type cellView struct {
Ch string `json:"ch"`
Slot bool `json:"slot"`
}
// hangmanView is a game as its player may see it.
type hangmanView struct {
Tier hangman.Tier `json:"tier"`
Cells []cellView `json:"cells"`
Tried []string `json:"tried"` // every letter guessed, right or wrong
Wrong []string `json:"wrong"` // just the misses — the gallows counts these
Lives int `json:"lives"`
MaxWrong int `json:"max_wrong"`
Multiple float64 `json:"multiple"` // what a win is worth right now
Bet int64 `json:"bet"`
Stands int64 `json:"stands"` // what the player would actually be paid if they won now
Phase string `json:"phase"`
Outcome string `json:"outcome,omitempty"`
Phrase string `json:"phrase,omitempty"` // only once it's over
Payout int64 `json:"payout,omitempty"`
Rake int64 `json:"rake,omitempty"`
Net int64 `json:"net"`
}
func viewHangman(g hangman.State) hangmanView {
v := hangmanView{
Tier: g.Tier,
Lives: g.Lives(),
MaxWrong: hangman.MaxWrong,
Multiple: g.Multiple(),
Bet: g.Bet,
// What the player would actually collect, rake already taken out. Quoting
// the pre-rake figure here would have the felt advertising a payout the
// house doesn't hand over.
Stands: g.Pays(),
Phase: string(g.Phase),
Outcome: string(g.Outcome),
Payout: g.Payout,
Rake: g.Rake,
Net: g.Net(),
}
for i, r := range g.Runes {
c := cellView{Slot: hangman.Guessable(r)}
if i < len(g.Shown) && g.Shown[i] {
c.Ch = string(r)
}
v.Cells = append(v.Cells, c)
}
for _, r := range g.Tried {
v.Tried = append(v.Tried, string(r))
}
for _, r := range g.Wrong {
v.Wrong = append(v.Wrong, string(r))
}
// The phrase goes over the wire exactly once: when the game is over and it no
// longer decides anything.
if g.Phase == hangman.PhaseDone {
v.Phrase = g.Phrase
}
return v
}
// handleHangmanStart takes the bet and draws a phrase. Same order as a deal:
// the chips are staked first, in the same statement that checks they exist, so
// two starts fired at once cannot bet the same chip.
func (s *Server) handleHangmanStart(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req struct {
Bet int64 `json:"bet"`
Tier string `json:"tier"`
}
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
return
}
tier, err := hangman.TierBySlug(req.Tier)
if err != nil {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a length"})
return
}
if err := storage.Stake(user, req.Bet); err != nil {
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
return
}
slog.Error("games: hangman stake", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
seed1, seed2 := newSeeds()
rng := rand.New(rand.NewPCG(seed1, seed2))
g, evs, err := hangman.New(req.Bet, tier, blackjack.DefaultRules().RakePct, rng)
if err != nil {
// The game never happened, so the stake never should have left.
_ = storage.Award(user, req.Bet)
slog.Error("games: hangman start", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.persistHangman(w, user, g, evs, seed1, seed2, true)
}
// handleHangmanGuess plays one guess: a letter, or the whole phrase.
func (s *Server) handleHangmanGuess(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var move hangman.Move
if err := decodeJSON(r, &move); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
live, err := storage.LoadLiveHand(user)
if errors.Is(err, storage.ErrNoLiveHand) {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"})
return
}
if err != nil {
slog.Error("games: hangman load", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if live.Game != gameHangman {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand you're in first"})
return
}
var g hangman.State
if err := json.Unmarshal(live.State, &g); err != nil {
slog.Error("games: unreadable hangman game", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
next, evs, err := hangman.ApplyMove(g, move)
if err != nil {
// A letter already tried is the one illegal move a player makes by
// accident rather than by trying it on, so it gets its own answer.
msg := "that guess isn't legal here"
if errors.Is(err, hangman.ErrAlreadyTried) {
msg = "you've already tried that one"
}
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
return
}
s.persistHangman(w, user, next, evs, live.Seed1, live.Seed2, false)
}
// persistHangman writes the game back and answers the browser.
func (s *Server) persistHangman(w http.ResponseWriter, user string, g hangman.State, evs []hangman.Event, seed1, seed2 uint64, fresh bool) {
blob, err := json.Marshal(g)
if err != nil {
slog.Error("games: marshal hangman", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
done := g.Phase == hangman.PhaseDone
v, ok := s.commit(w, user, finished{
Game: gameHangman, Blob: blob,
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
Outcome: string(g.Outcome), Done: done,
Seed1: seed1, Seed2: seed2, Fresh: fresh,
})
if !ok {
return
}
// A finished game is gone from storage, so the table has none to show — but
// the browser still needs the final board to reveal the phrase onto.
if done {
hv := viewHangman(g)
v.Hangman = &hv
}
v.HangEvents = evs
writeJSON(w, v)
}

View File

@@ -0,0 +1,188 @@
package web
import (
"strings"
"testing"
"pete/internal/storage"
)
// The one thing this table cannot get wrong: the stake leaves the stack, and the
// phrase does not leave the server.
func TestHangmanStartTakesTheStakeAndKeepsThePhrase(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
v, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
map[string]any{"bet": 100, "tier": "short"}))
if code != 200 {
t.Fatalf("start = %d, want 200", code)
}
if v.Chips != 900 {
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
}
if v.Hangman == nil {
t.Fatal("start returned no game")
}
if v.Game != gameHangman {
t.Errorf("game = %q, want hangman", v.Game)
}
if v.Hangman.Phrase != "" {
t.Fatalf("the phrase was sent to the browser before it was won: %q", v.Hangman.Phrase)
}
// Nothing is revealed at the start except the scaffolding, and a space is not
// a letter you have to earn.
for _, c := range v.Hangman.Cells {
if c.Slot && c.Ch != "" {
t.Fatalf("a letter was face up before it was guessed: %+v", c)
}
}
if v.Hangman.Lives != 6 {
t.Errorf("lives = %d, want 6", v.Hangman.Lives)
}
}
// A win pays what the felt said it would, and the rake comes out of the winnings.
func TestHangmanWinPaysWhatTheFeltQuoted(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
v, _ := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
map[string]any{"bet": 100, "tier": "short"}))
quoted := v.Hangman.Stands
// The server holds the phrase, so read it out of the live row — which is the
// only place it exists — and solve it.
phrase := livePhrase(t)
v, code := call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess",
map[string]string{"solve": phrase}))
if code != 200 {
t.Fatalf("solve = %d, want 200", code)
}
if v.Hangman.Outcome != "solved" {
t.Fatalf("outcome = %q, want solved", v.Hangman.Outcome)
}
// No wrong guesses, so the full 2.6×: 260 gross, 160 profit, 8 rake, 252 back.
if v.Hangman.Payout != quoted {
t.Errorf("felt quoted %d, house paid %d", quoted, v.Hangman.Payout)
}
if v.Hangman.Payout != 252 || v.Hangman.Rake != 8 {
t.Errorf("payout/rake = %d/%d, want 252/8", v.Hangman.Payout, v.Hangman.Rake)
}
if got := chipsNow(t); got != 900+252 {
t.Errorf("chips = %d, want %d", got, 900+252)
}
// And the phrase is finally allowed out, now that it decides nothing.
if v.Hangman.Phrase == "" {
t.Error("a finished game never told the player what the phrase was")
}
// The game is off the felt.
if _, err := storage.LoadLiveHand(testPlayer); err == nil {
t.Error("a settled game is still sitting in game_live_hands")
}
}
// Six wrong guesses take the stake and nothing more.
func TestHangmanHangingCostsExactlyTheStake(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
map[string]any{"bet": 100, "tier": "short"}))
// Six solves that are certainly wrong — a wrong solve costs a life, same as a
// wrong letter, and this needs no knowledge of the phrase.
var v tableView
for i := 0; i < 6; i++ {
v, _ = call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess",
map[string]string{"solve": "definitely not the phrase at all"}))
}
if v.Hangman == nil || v.Hangman.Outcome != "hung" {
t.Fatalf("outcome = %+v, want hung", v.Hangman)
}
if v.Hangman.Payout != 0 {
t.Errorf("payout = %d, want 0", v.Hangman.Payout)
}
if got := chipsNow(t); got != 900 {
t.Errorf("chips = %d, want 900 — a loss costs the stake and no more", got)
}
if v.Hangman.Phrase == "" {
t.Error("hung without being told the answer")
}
}
// One game at a time, across games: you cannot walk from a hangman into a hand of
// blackjack with chips still riding on a phrase.
func TestHangmanHoldsTheSeatAgainstBlackjack(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
map[string]any{"bet": 100, "tier": "short"}))
_, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal",
map[string]int64{"bet": 100}))
if code != 409 {
t.Fatalf("dealt blackjack on top of a live hangman: %d, want 409", code)
}
// And the stake that was refused came back: 1000 - 100 (the hangman) and not a
// chip more.
if got := chipsNow(t); got != 900 {
t.Errorf("chips = %d, want 900 — the refused deal kept the stake", got)
}
if _, err := storage.LoadLiveHand(testPlayer); err != nil {
t.Errorf("the hangman was evicted by the deal it refused: %v", err)
}
}
// Cashing out mid-phrase is refused, for the same reason as mid-hand.
func TestCannotCashOutMidPhrase(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
map[string]any{"bet": 100, "tier": "short"}))
_, code := call(t, s, s.handleCashOut, as(t, s, "reala", "POST", "/api/games/cashout",
map[string]int64{"amount": 0}))
if code != 409 {
t.Fatalf("cash-out mid-phrase = %d, want 409", code)
}
}
// A tier the browser made up is refused, and costs nothing.
func TestHangmanRefusesAnInventedTier(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
_, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
map[string]any{"bet": 100, "tier": "impossible"}))
if code != 400 {
t.Fatalf("start on a made-up tier = %d, want 400", code)
}
if got := chipsNow(t); got != 1000 {
t.Errorf("chips = %d, want 1000 — a refused game must not take a stake", got)
}
}
// livePhrase digs the phrase out of the live row. Only a test may do this: it is
// reaching past the wire on purpose, to prove the wire doesn't carry it.
func livePhrase(t *testing.T) string {
t.Helper()
live, err := storage.LoadLiveHand(testPlayer)
if err != nil {
t.Fatal(err)
}
blob := string(live.State)
const key = `"phrase":"`
i := strings.Index(blob, key)
if i < 0 {
t.Fatalf("no phrase in the live row: %s", blob)
}
rest := blob[i+len(key):]
j := strings.Index(rest, `"`)
if j < 0 {
t.Fatal("unterminated phrase in the live row")
}
return rest[:j]
}

View File

@@ -0,0 +1,731 @@
package web
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"pete/internal/games/blackjack"
"pete/internal/games/holdem"
"pete/internal/storage"
)
// Texas hold'em, played for chips against the trained bots.
//
// This is the only table in the casino that is a *session* rather than a game.
// Everywhere else you stake, you play once, and a multiple pays: the hand is the
// unit and the money moves at both ends of it. Poker is not that shape. You buy
// chips onto the table, you play as many hands as you feel like, and you leave
// with whatever is in front of you — so the live row lives across hands, and the
// chips move exactly twice: once when you sit down, once when you get up.
//
// Which means there is no "payout" until you stand up, and `commit` is only ever
// told the game is Done when you do (or when you have nothing left to sit with).
// In between, every pot won and lost is inside the engine, and storage sees none
// of it. That is the honest model, and it is also the safe one: a hand that dies
// halfway leaves the chips where they were, on the table, in the live row.
//
// What the browser is allowed to see: your two cards, the board, everybody's
// stacks and bets, and nothing else. Not the deck, and not a bot's hand — until
// a showdown turns it over, which is the moment it stops being a secret.
// holdemSeatView is one seat. Cards are present only when the viewer is entitled
// to them: yours always, a bot's never, until the hand is shown down.
type holdemSeatView struct {
Name string `json:"name"`
Bot bool `json:"bot"`
You bool `json:"you"`
Stack int64 `json:"stack"`
Bet int64 `json:"bet"`
State string `json:"state"` // active | folded | allin | out
Pos string `json:"pos"` // BTN, SB, BB, UTG…
Cards []cardView `json:"cards,omitempty"`
Won int64 `json:"won,omitempty"`
}
var seatStates = map[holdem.SeatState]string{
holdem.Active: "active",
holdem.Folded: "folded",
holdem.AllIn: "allin",
holdem.Out: "out",
}
// holdemView is the table as its player may see it.
type holdemView struct {
Tier holdem.Tier `json:"tier"`
// YourSeat is which chair in Seats is the viewer's. It used to be a convention
// (seat zero is you) that the felt hardcoded; at a shared table it is whatever
// chair you took, so it rides in the view and the browser reads it rather than
// assuming it.
YourSeat int `json:"your_seat"`
Seats []holdemSeatView `json:"seats"`
Button int `json:"button"`
HandNo int `json:"hand_no"`
Board []cardView `json:"board"`
Street string `json:"street"`
Pot int64 `json:"pot"`
Side []int64 `json:"side,omitempty"`
ToAct int `json:"to_act"`
Phase string `json:"phase"`
// What you may do, decided here rather than in the browser — the felt should
// never offer a button the table would refuse.
Owed int64 `json:"owed"`
CanCheck bool `json:"can_check"`
CanRaise bool `json:"can_raise"`
MinRaise int64 `json:"min_raise_to"`
MaxRaise int64 `json:"max_raise_to"`
Stack int64 `json:"stack"` // what's in front of you
BoughtIn int64 `json:"bought_in"`
Rake int64 `json:"rake"` // what the house has taken this session
MaxTopUp int64 `json:"max_topup"`
Payout int64 `json:"payout,omitempty"`
}
// viewHoldem renders the table as one seat may see it. viewer is which seat is
// looking — their cards are the only hole cards it will ever put in the payload
// (until a showdown turns the rest over), and the action panel is filled in only
// when it is that seat's turn.
//
// This is the security boundary. Before SSE a missed check here leaked one bot's
// cards to one player through a bug in one handler; now the same view fans to
// every subscriber's stream, so a seat that renders anyone else's hole card fans
// it to the whole table. TestHoldemViewNeverLeaksAnotherSeatsCards renders every
// seat's view at every street and greps for cards that are not theirs.
func viewHoldem(g holdem.State, viewer int) holdemView {
v := holdemView{
Tier: g.Tier,
YourSeat: viewer,
Button: g.Button,
HandNo: g.HandNo,
Street: g.Street.String(),
Pot: g.Total(),
ToAct: g.ToAct,
Phase: string(g.Phase),
Stack: g.Seats[viewer].Stack,
BoughtIn: g.BoughtIn, // a table total; the caller overrides it with this seat's own stake
Rake: g.Seats[viewer].Paid, // this seat's rake alone, not the table's — see the ledger line on the felt
Payout: g.Payout,
}
for _, p := range g.Side {
v.Side = append(v.Side, p.Amount)
}
// An empty board is an empty board, not null. A Go slice with nothing in it
// marshals to null, and a browser that has to write `(board || [])` everywhere
// is a browser one forgotten guard away from a crash on every preflop.
v.Board = []cardView{}
for _, c := range g.Community {
v.Board = append(v.Board, viewCard(c))
}
// The wall. Another seat's hand crosses the wire in exactly one situation — the
// hand was shown down and they did not fold — because that is the only situation
// in which a player at a real table would be looking at it.
shown := g.Street == holdem.Showdown
for i, p := range g.Seats {
seat := holdemSeatView{
Name: p.Name,
Bot: p.Bot,
You: i == viewer,
Stack: p.Stack,
Bet: p.Bet,
State: seatStates[p.State],
Pos: g.Position(i),
Won: p.Won,
}
mine := i == viewer
dealt := p.State != holdem.Out && p.Hole[0].Rank != 0
if dealt && (mine || (shown && p.State != holdem.Folded)) {
seat.Cards = []cardView{viewCard(p.Hole[0]), viewCard(p.Hole[1])}
}
v.Seats = append(v.Seats, seat)
}
if g.Phase == holdem.PhaseBetting && g.ToAct == viewer {
v.Owed = g.Owed(viewer)
v.CanCheck = v.Owed == 0
v.CanRaise = g.CanRaise(viewer)
v.MinRaise = g.MinRaiseTo(viewer)
v.MaxRaise = g.MaxRaiseTo(viewer)
}
if top := g.Tier.MaxBuy - g.Seats[viewer].Stack; top > 0 {
v.MaxTopUp = top
}
return v
}
// holdemEventView is one beat of the script the felt plays back. The engine only
// ever attaches a bot's cards to a showdown; this drops them again anywhere else,
// which is the second of the two walls.
type holdemEventView struct {
Kind string `json:"kind"`
Seat int `json:"seat"`
Cards []cardView `json:"cards,omitempty"`
Amount int64 `json:"amount,omitempty"`
Total int64 `json:"total,omitempty"`
Text string `json:"text,omitempty"`
}
// viewHoldemEvents redacts the engine's script for one viewer. The engine emits
// every seat's hole cards now (it cannot know who a shared stream is for), so
// this is where the cards that are not the viewer's are stripped — turning a
// "deal seat 3 these two cards" beat into "deal seat 3 two face-down cards".
//
// A card may ride an event only if it is a board card (Seat < 0), the viewer's
// own, or a hand being shown down. Everything else is nulled here, and a missed
// case is the leak that fans one seat's hole card to every subscriber.
func viewHoldemEvents(evs []holdem.Event, viewer int) []holdemEventView {
out := make([]holdemEventView, 0, len(evs))
for _, e := range evs {
v := holdemEventView{Kind: e.Kind, Seat: e.Seat, Amount: e.Amount, Total: e.Total, Text: e.Text}
for _, c := range e.Cards {
v.Cards = append(v.Cards, viewCard(c))
}
if len(v.Cards) > 0 && e.Seat >= 0 && e.Seat != viewer && e.Kind != "show" {
v.Cards = nil
}
out = append(out, v)
}
return out
}
// ---- sitting down: a table of your own, or somebody else's -----------------
// displayName is what goes on the felt. It is the player's session name if they
// have one, the local part of their Matrix id otherwise — never empty, which
// would sit a nameless chair at the table.
func (s *Server) displayName(r *http.Request, user string) string {
if u := s.auth.userFromRequest(r); u != nil {
if u.Name != "" {
return u.Name
}
if u.Username != "" {
return u.Username
}
}
name := strings.TrimPrefix(user, "@")
if i := strings.IndexByte(name, ':'); i > 0 {
name = name[:i]
}
return name
}
// seatRows mirrors the engine's seats into the storage rows that shadow them,
// index for index — seat i in the blob is seat i in game_seats — which is the
// alignment the view redacts by and the audit attributes by. A human's staked is
// the buy-in that actually crossed the border; a bot's is zero, because the only
// real money at the table is in the human seats and staked is where the border
// accounting lives.
func seatRows(g holdem.State, human string, buyIn int64) []storage.Seat {
rows := make([]storage.Seat, len(g.Seats))
for i := range g.Seats {
p := g.Seats[i]
row := storage.Seat{Seat: i, Name: p.Name}
if !p.Bot {
row.MatrixUser = human
row.Staked = buyIn
}
rows[i] = row
}
return rows
}
// handleHoldemSit seats a player: at a fresh table of their own (solo is just a
// table nobody else has joined yet), or at an open chair on somebody else's.
func (s *Server) handleHoldemSit(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req struct {
Tier string `json:"tier"`
Bots int `json:"bots"`
BuyIn int64 `json:"buyin"`
Table string `json:"table"` // set to join an existing table rather than open one
Seat *int `json:"seat"` // which chair to take when joining; optional
}
if err := decodeJSON(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if req.Table != "" {
s.joinHoldem(w, r, user, req.Table, req.Seat, req.BuyIn)
return
}
s.openHoldem(w, r, user, req.Tier, req.Bots, req.BuyIn)
}
// openHoldem opens a fresh table with the player in seat zero and bots in the
// rest. It is the old solo flow, now a real shared table that simply has no other
// humans on it yet.
func (s *Server) openHoldem(w http.ResponseWriter, r *http.Request, user, tierSlug string, bots int, buyIn int64) {
tier, err := holdem.TierBySlug(tierSlug)
if err != nil {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
return
}
if buyIn < tier.MinBuy || buyIn > tier.MaxBuy {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that isn't a legal buy-in for this table"})
return
}
if bots < 1 || bots > holdem.MaxBots {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick some opponents"})
return
}
name := s.displayName(r, user)
seed1, seed2 := newSeeds()
g, _, err := holdem.New(tier, holdem.TableSeats(tier, name, bots, buyIn), blackjack.DefaultRules().RakePct, seed1, seed2)
if err != nil {
slog.Error("games: holdem open", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
blob, err := json.Marshal(g)
if err != nil {
slog.Error("games: marshal new holdem", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
id, err := storage.NewTableID()
if err != nil {
slog.Error("games: mint table id", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
t := storage.Table{
ID: id, Game: gameHoldem, Tier: tier.Slug, State: blob,
Seed1: seed1, Seed2: seed2, Phase: string(g.Phase), HandNo: int64(g.HandNo),
}
err = storage.OpenSoloTable(t, seatRows(g, user, buyIn), buyIn)
switch {
case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"})
return
case errors.Is(err, storage.ErrHandInProgress):
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
return
case err != nil:
slog.Error("games: open solo table", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.writeHoldemTable(w, user, nil)
}
// pickOpenSeat chooses a chair to join: the one the caller asked for if it is a
// bot's, otherwise the first bot seat. Returns -1 if there is nowhere to sit.
func pickOpenSeat(g holdem.State, want *int) int {
if want != nil {
i := *want
if i >= 0 && i < len(g.Seats) && g.Seats[i].Bot {
return i
}
return -1
}
for i := range g.Seats {
if g.Seats[i].Bot {
return i
}
}
return -1
}
// joinHoldem sits a player down at an open chair on an existing table. It runs
// under the table lock, and every step of the sit-down is one transaction in
// SitDown — stake, claim, take the chair out of a bot's hands, save the state —
// so two people racing for the last seat cannot both win it.
func (s *Server) joinHoldem(w http.ResponseWriter, r *http.Request, user, tableID string, wantSeat *int, buyIn int64) {
name := s.displayName(r, user)
var respErr error
err := s.tableLocks.withTable(tableID, func() error {
t, _, err := storage.LoadTable(tableID)
if errors.Is(err, storage.ErrNoSuchTable) {
respErr = storage.ErrNoSuchTable
return nil
}
if err != nil {
return err
}
if t.Game != gameHoldem {
respErr = holdem.ErrUnknownMove
return nil
}
var g holdem.State
if err := json.Unmarshal(t.State, &g); err != nil {
return err
}
if g.Phase == holdem.PhaseBetting {
respErr = holdem.ErrHandLive // you join between hands, not into one
return nil
}
seat := pickOpenSeat(g, wantSeat)
if seat < 0 {
respErr = holdem.ErrTableFull
return nil
}
if err := g.Occupy(seat, name, buyIn); err != nil {
respErr = err
return nil
}
blob, err := json.Marshal(g)
if err != nil {
return err
}
t.State, t.Phase, t.HandNo = blob, string(g.Phase), int64(g.HandNo)
err = storage.SitDown(storage.Sit{
Table: t,
Seat: storage.Seat{Seat: seat, MatrixUser: user, Name: name, Staked: buyIn},
BuyIn: buyIn,
})
switch {
case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
respErr = storage.ErrInsufficientChips
return nil
case errors.Is(err, storage.ErrHandInProgress):
respErr = storage.ErrHandInProgress
return nil
case errors.Is(err, storage.ErrSeatTaken), errors.Is(err, storage.ErrStaleTable):
respErr = storage.ErrSeatTaken
return nil
case err != nil:
return err
}
s.publishTable(tableID)
return nil
})
if err != nil {
slog.Error("games: join holdem", "user", user, "table", tableID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if respErr != nil {
writeJSONStatus(w, joinStatus(respErr), map[string]string{"error": joinMessage(respErr)})
return
}
s.writeHoldemTable(w, user, nil)
}
func joinStatus(err error) int {
switch {
case errors.Is(err, storage.ErrNoSuchTable), errors.Is(err, holdem.ErrTableFull),
errors.Is(err, storage.ErrSeatTaken), errors.Is(err, holdem.ErrHandLive):
return http.StatusConflict
default:
return http.StatusBadRequest
}
}
func joinMessage(err error) string {
switch {
case errors.Is(err, storage.ErrNoSuchTable):
return "that table has closed"
case errors.Is(err, holdem.ErrTableFull), errors.Is(err, storage.ErrSeatTaken):
return "that seat is taken"
case errors.Is(err, holdem.ErrHandLive):
return "a hand is in play — sit down when it's over"
case errors.Is(err, holdem.ErrBadBuyIn):
return "that isn't a legal buy-in for this table"
case errors.Is(err, storage.ErrInsufficientChips):
return "not enough chips to sit down"
case errors.Is(err, storage.ErrHandInProgress):
return "finish the game you're in first"
default:
return "you can't sit there"
}
}
// ---- playing a hand --------------------------------------------------------
// handleHoldemMove plays one move at the player's table: a betting action, or the
// two session moves that are not leaving (deal the next hand, top up between
// them). Leaving is its own endpoint, because it is a storage operation rather
// than an engine one.
func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var move holdem.Move
if err := decodeJSON(r, &move); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if move.Kind == holdem.Leave {
s.leaveHoldem(w, user)
return
}
tableID, seat, err := storage.PlayerSeat(user)
if errors.Is(err, storage.ErrNoLiveHand) {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
return
}
if err != nil {
slog.Error("games: holdem move seat", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var respErr error
var respEvents []holdem.Event
err = s.tableLocks.withTable(tableID, func() error {
t, seats, err := storage.LoadTable(tableID)
if errors.Is(err, storage.ErrNoSuchTable) {
respErr = storage.ErrNoSuchTable
return nil
}
if err != nil {
return err
}
var g holdem.State
if err := json.Unmarshal(t.State, &g); err != nil {
return err
}
// A top-up is real chips crossing the border, so it comes off the stack
// before the engine is asked, and goes straight back if the engine says no —
// the same order, and the same reason, as doubling down at blackjack.
topped := int64(0)
if move.Kind == holdem.TopUp {
if err := storage.Stake(user, move.Amount); err != nil {
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
respErr = holdem.ErrTooBig
return nil
}
return err
}
topped = move.Amount
}
next, evs, aerr := holdem.ApplyMove(g, seat, move)
if aerr != nil {
if topped > 0 {
_ = storage.Award(user, topped) // the top-up didn't happen
}
respErr = aerr
return nil
}
// A solo session that just ended (the one human busted) is not a table any
// more: cash the seat out — for nothing, but the claim still has to be
// released — and close the felt. Only a one-human bust reaches PhaseDone; a
// shared table sits the busted seat Out and plays on.
if next.Phase == holdem.PhaseDone {
if err := s.settleLeave(t, next, seat, user); err != nil {
if topped > 0 {
_ = storage.Award(user, topped)
}
if errors.Is(err, storage.ErrStaleTable) {
respErr = storage.ErrStaleTable
return nil
}
return err
}
respEvents = evs
s.publishTable(tableID)
return nil
}
st, err := holdemStep(g, next, evs, seats)
if err != nil {
if topped > 0 {
_ = storage.Award(user, topped)
}
return err
}
acting := seats[seat]
acting.Away = false
acting.LastSeen = time.Now().Unix()
// A top-up is more real money crossing the border, so the seat's staked row —
// the record of what they brought and can still take home — has to grow with
// it. Without this the storage invariant (stacks + pot == staked - rake) drifts
// by every top-up, and the felt under-reports what they bought in for.
acting.Staked += topped
t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
if err := storage.CommitTable(storage.TableCommit{
Table: t, Seats: []storage.Seat{acting}, Audit: st.Audit,
}); err != nil {
if errors.Is(err, storage.ErrStaleTable) {
if topped > 0 {
_ = storage.Award(user, topped)
}
respErr = storage.ErrStaleTable
return nil
}
return err
}
respEvents = evs
s.publishTable(tableID)
return nil
})
if err != nil {
slog.Error("games: holdem move", "user", user, "table", tableID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if respErr != nil {
writeJSONStatus(w, moveStatus(respErr), map[string]string{"error": moveMessage(respErr)})
return
}
s.writeHoldemTable(w, user, respEvents)
}
func moveStatus(err error) int {
// A 409 is a concurrency verdict: the table is not where the caller thought, so
// reload and look again. Everything else — an illegal move for this state — is
// the caller's mistake, a 400. Leaving mid-hand or acting out of turn is a 400:
// the request was simply not allowed, not raced.
switch {
case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable):
return http.StatusConflict
default:
return http.StatusBadRequest
}
}
func moveMessage(err error) string {
switch {
case errors.Is(err, storage.ErrStaleTable):
return "the table moved on — take another look"
case errors.Is(err, storage.ErrNoSuchTable):
return "that table has closed"
case errors.Is(err, holdem.ErrHandLive):
return "finish the hand first"
case errors.Is(err, holdem.ErrNotYourTurn):
return "it isn't your turn"
case errors.Is(err, holdem.ErrCantCheck):
return "there's a bet to you"
case errors.Is(err, holdem.ErrTooSmall):
return "that's under the minimum raise"
case errors.Is(err, holdem.ErrTooBig):
return "you don't have that many chips"
case errors.Is(err, holdem.ErrBadBuyIn):
return "that would put you over the table maximum"
default:
return "that move isn't legal here"
}
}
// ---- getting up ------------------------------------------------------------
// handleHoldemLeave is the get-up endpoint. Leaving is its own route because it
// is a storage operation, not an engine move — the chips cross the border and the
// felt may close — even though the felt also lets you send it as a "leave" move.
func (s *Server) handleHoldemLeave(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
s.leaveHoldem(w, user)
}
// leaveHoldem gets a player up from their table, turning what is in front of them
// back into chips. It refuses mid-hand — you cannot walk out on chips you have in
// the pot — and closes the felt behind the last human to leave.
func (s *Server) leaveHoldem(w http.ResponseWriter, user string) {
tableID, seat, err := storage.PlayerSeat(user)
if errors.Is(err, storage.ErrNoLiveHand) {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
return
}
if err != nil {
slog.Error("games: holdem leave seat", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var respErr error
err = s.tableLocks.withTable(tableID, func() error {
t, _, err := storage.LoadTable(tableID)
if errors.Is(err, storage.ErrNoSuchTable) {
respErr = storage.ErrNoSuchTable
return nil
}
if err != nil {
return err
}
var g holdem.State
if err := json.Unmarshal(t.State, &g); err != nil {
return err
}
if g.Phase == holdem.PhaseBetting {
respErr = holdem.ErrHandLive
return nil
}
if err := s.settleLeave(t, g, seat, user); err != nil {
if errors.Is(err, storage.ErrStaleTable) {
respErr = storage.ErrStaleTable
return nil
}
return err
}
s.publishTable(tableID)
return nil
})
if err != nil {
slog.Error("games: holdem leave", "user", user, "table", tableID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if respErr != nil {
writeJSONStatus(w, moveStatus(respErr), map[string]string{"error": moveMessage(respErr)})
return
}
s.writeHoldemTable(w, user, nil)
}
// settleLeave vacates a seat, credits the stack home, and closes the table if
// nobody human is left — all inside the caller's lock. The engine's Vacate turns
// the chair back into the house's (its chips become house money, rebought like
// any bot's), and storage.LeaveTable does the border crossing in one transaction
// with the state write, so a crash can never pay a player and then leave their
// seat sitting there to be cashed out again.
func (s *Server) settleLeave(t storage.Table, g holdem.State, seat int, user string) error {
home, err := g.Vacate(seat)
if err != nil {
return err
}
blob, err := json.Marshal(g)
if err != nil {
return err
}
t.State, t.Phase, t.HandNo, t.Deadline = blob, string(g.Phase), int64(g.HandNo), 0
if err := storage.LeaveTable(storage.Leave{
Table: t, Seat: seat, MatrixUser: user, Bot: g.Seats[seat].Name, Amount: home,
}); err != nil {
return err
}
if err := storage.CloseTable(t.ID); err != nil {
return err
}
return nil
}
// ---- the response ----------------------------------------------------------
// writeHoldemTable answers with the whole page state — the money and the table as
// the player's own seat may see it — plus, when a move produced one, the redacted
// event script for that seat to animate. A player who has just got up has no seat
// and no table; the money view carries the leftover verdict and the felt clears.
func (s *Server) writeHoldemTable(w http.ResponseWriter, user string, evs []holdem.Event) {
v, err := s.table(user)
if err != nil {
slog.Error("games: holdem table", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if len(evs) > 0 {
if _, seat, serr := storage.PlayerSeat(user); serr == nil {
v.HoldemEvents = viewHoldemEvents(evs, seat)
}
}
writeJSON(w, v)
}

View File

@@ -0,0 +1,230 @@
package web
import (
"testing"
"time"
"pete/internal/storage"
)
const bobPlayer = "@bob:parodia.dev"
// fundUser puts chips in front of a named player the way the border really does.
func fundUser(t *testing.T, user string, chips int64) {
t.Helper()
e, err := storage.RequestBuyIn(user, chips)
if err != nil {
t.Fatal(err)
}
if _, err := storage.ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
if _, err := storage.SettleEscrow(e.GUID, true, "", 0); err != nil {
t.Fatal(err)
}
}
func chipsOf(t *testing.T, user string) int64 {
t.Helper()
st, err := storage.Chips(user)
if err != nil {
t.Fatal(err)
}
return st.Chips
}
// A second person can sit down at a table somebody else opened, taking a chair a
// bot was keeping warm — which is the whole point of the thing being multiplayer.
func TestHoldemJoinTakesAnOpenSeat(t *testing.T) {
s := newCasino(t)
fund(t, 5000) // reala
fundUser(t, bobPlayer, 5000)
if _, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 2, "buyin": 500})); code != 200 {
t.Fatalf("reala sit = %d, want 200", code)
}
tableID, err := storage.TableOf(testPlayer)
if err != nil {
t.Fatal(err)
}
v, code := call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
map[string]any{"table": tableID, "buyin": 500}))
if code != 200 {
t.Fatalf("bob join = %d, want 200", code)
}
if v.Chips != 4500 {
t.Errorf("bob's chips after a 500 buy-in = %d, want 4500", v.Chips)
}
if v.Holdem == nil {
t.Fatal("join returned no table")
}
_, bobSeat, err := storage.PlayerSeat(bobPlayer)
if err != nil {
t.Fatal(err)
}
if bobSeat == 0 {
t.Errorf("bob took reala's seat %d", bobSeat)
}
if v.Holdem.YourSeat != bobSeat {
t.Errorf("the view says bob is at seat %d, storage says %d", v.Holdem.YourSeat, bobSeat)
}
_, seats, err := storage.LoadTable(tableID)
if err != nil {
t.Fatal(err)
}
humans := 0
for _, seat := range seats {
if seat.MatrixUser != "" {
humans++
}
}
if humans != 2 {
t.Errorf("two people at the table, storage says %d humans", humans)
}
}
// The felt tells each player "you bought in for X" — their own stake, not the
// table's. The engine's BoughtIn is the sum across every human (the audit wants
// that), so a two-human table would quote both of them the pair's total if the
// view read it straight. It reads each seat's own staked row from storage instead.
func TestBoughtInIsPerPlayerNotTheTableTotal(t *testing.T) {
s := newCasino(t)
fund(t, 5000) // reala
fundUser(t, bobPlayer, 5000)
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
tableID, _ := storage.TableOf(testPlayer)
call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
map[string]any{"table": tableID, "buyin": 500}))
for _, who := range []string{testPlayer, bobPlayer} {
v, err := s.table(who)
if err != nil {
t.Fatal(err)
}
if v.Holdem == nil {
t.Fatalf("%s has no table", who)
}
if v.Holdem.BoughtIn != 500 {
t.Errorf("%s is shown bought-in %d, want their own 500 — not the table's 1000",
who, v.Holdem.BoughtIn)
}
}
}
// Leaving a shared table gives you your stack and hands your chair back to the
// house; the table stays open for the people still on it. Only the last human out
// closes the felt.
func TestHoldemLeavingSharedTableKeepsItOpen(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
fundUser(t, bobPlayer, 5000)
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
tableID, _ := storage.TableOf(testPlayer)
call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
map[string]any{"table": tableID, "buyin": 500}))
// Bob gets up. His 500 comes home and his chair goes back to a bot.
if _, code := call(t, s, s.handleHoldemLeave, as(t, s, "bob", "POST", "/api/games/holdem/leave", nil)); code != 200 {
t.Fatalf("bob leave = %d, want 200", code)
}
if got := chipsOf(t, bobPlayer); got != 5000 {
t.Errorf("bob left with %d, want his 5000 back", got)
}
if _, _, err := storage.PlayerSeat(bobPlayer); err != storage.ErrNoLiveHand {
t.Errorf("bob still holds a seat after leaving: %v", err)
}
if _, _, err := storage.LoadTable(tableID); err != nil {
t.Errorf("the table closed under reala when bob left: %v", err)
}
// Reala is the last one out, so the felt closes behind them.
if _, code := call(t, s, s.handleHoldemLeave, as(t, s, "reala", "POST", "/api/games/holdem/leave", nil)); code != 200 {
t.Fatalf("reala leave = %d, want 200", code)
}
if _, _, err := storage.LoadTable(tableID); err != storage.ErrNoSuchTable {
t.Errorf("an empty table survived the last human: %v", err)
}
}
// A table everyone has walked away from is cashed out and closed by the reaper —
// the chips inside a walked-away poker session are not left in limbo.
func TestHoldemReaperCashesOutAbandonedTable(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
tableID, _ := storage.TableOf(testPlayer)
// The seat has been away, and last acted for itself longer ago than the idle
// cutoff — the state the reaper is meant to find.
tbl, seats, err := storage.LoadTable(tableID)
if err != nil {
t.Fatal(err)
}
seats[0].Away = true
seats[0].LastSeen = time.Now().Unix() - int64(storage.SessionIdleAfter.Seconds()) - 60
if err := storage.CommitTable(storage.TableCommit{Table: tbl, Seats: []storage.Seat{seats[0]}}); err != nil {
t.Fatal(err)
}
s.reapAbandonedTables()
if got := chipsOf(t, testPlayer); got != 5000 {
t.Errorf("the reaper sent home %d, want the whole 5000 back (buy-in and all)", got)
}
if _, _, err := storage.LoadTable(tableID); err != storage.ErrNoSuchTable {
t.Errorf("the reaper left the table standing: %v", err)
}
if _, err := storage.TableOf(testPlayer); err != storage.ErrNoLiveHand {
t.Errorf("the reaper left the occupancy claim behind: %v", err)
}
}
// A fresh table is not abandoned, and the reaper leaves it alone.
func TestHoldemReaperSparesALiveTable(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
tableID, _ := storage.TableOf(testPlayer)
s.reapAbandonedTables()
if _, _, err := storage.LoadTable(tableID); err != nil {
t.Errorf("the reaper closed a table someone is sitting at: %v", err)
}
if got := chipsOf(t, testPlayer); got != 4500 {
t.Errorf("chips = %d — the reaper should not have moved a live table's money", got)
}
}
// The lobby lists a table with a seat going spare, and drops it once it is full.
func TestHoldemLobbyListsJoinableTables(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
// A one-bot table: two seats, one human, one open.
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 1, "buyin": 500}))
tables, err := storage.LobbyTables(gameHoldem, 50)
if err != nil {
t.Fatal(err)
}
if len(tables) != 1 {
t.Fatalf("lobby has %d tables, want 1", len(tables))
}
if tables[0].Humans != 1 || tables[0].Seats != 2 {
t.Errorf("lobby table = %d/%d humans/seats, want 1/2", tables[0].Humans, tables[0].Seats)
}
}

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,9 +2,15 @@ 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"
"pete/internal/storage"
)
@@ -24,12 +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."},
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
{Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."},
{Name: "Hangman", Emoji: "🪢", Blurb: "Guess the phrase before the gallows finish."},
}
// 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,11 +69,76 @@ func roomAt(hour int) room {
// back in one convenient field at a time.
type gamesPage struct {
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
Soon []gameTeaser
Denominations []int64
Tiers []hangman.Tier // hangman's three lengths, and what each pays
MaxWrong int
Deals []klondike.Tier // solitaire's three deals
FullDeck int
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.
//
// It exists so there is exactly one list of them. The dev rig (devcasino_test.go)
// has to wire its own mux — New() decides whether the casino exists before the
// rig has signed anybody in — and a second copy of this list is a list that
// 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)
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
mux.HandleFunc("POST /api/games/hangman/start", s.handleHangmanStart)
mux.HandleFunc("POST /api/games/hangman/guess", s.handleHangmanGuess)
mux.HandleFunc("POST /api/games/solitaire/start", s.handleSolitaireStart)
mux.HandleFunc("POST /api/games/solitaire/move", s.handleSolitaireMove)
mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart)
mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer)
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.
@@ -88,19 +157,71 @@ 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
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,
Denominations: betDenominations,
Tiers: hangman.Tiers,
MaxWrong: hangman.MaxWrong,
Deals: klondike.Tiers,
FullDeck: klondike.FullDeck,
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))
@@ -112,3 +233,38 @@ func (s *Server) handleBlackjack(w http.ResponseWriter, r *http.Request) {
}
s.render(w, "blackjack", s.gamesPage(r))
}
func (s *Server) handleHangman(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "hangman", s.gamesPage(r))
}
func (s *Server) handleSolitaire(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "solitaire", s.gamesPage(r))
}
func (s *Server) handleTrivia(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "trivia", s.gamesPage(r))
}
func (s *Server) handleUno(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
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

@@ -3,6 +3,7 @@ package web
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/rand/v2"
@@ -11,6 +12,11 @@ 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"
"pete/internal/storage"
)
@@ -86,34 +92,61 @@ func viewCard(c cards.Card) cardView {
// somebody reads in devtools.
type handView struct {
Phase string `json:"phase"`
Bet int64 `json:"bet"`
Player []cardView `json:"player"`
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
Total int `json:"total"`
Soft bool `json:"soft"`
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"`
Total int `json:"total"`
Soft bool `json:"soft"`
Doubled bool `json:"doubled"`
Done bool `json:"done"`
Outcome string `json:"outcome,omitempty"`
Payout int64 `json:"payout,omitempty"`
}
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 {
@@ -133,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"`
}
@@ -143,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
@@ -162,18 +196,43 @@ func viewEvents(evs []blackjack.Event, phase blackjack.Phase) []eventView {
return out
}
// tableView is the whole page state: the money, and the hand if there is one.
// tableView is the whole page state: the money, and whatever game is in progress.
//
// A player is in at most one game at a time — game_live_hands is keyed on the
// player, so the primary key enforces it — and Game says which. Each game gets
// its own field rather than a shared blob, because a hangman phrase and a
// blackjack shoe have nothing in common and pretending otherwise would mean a
// browser that has to guess what it's holding.
type tableView struct {
Chips int64 `json:"chips"`
Pending int64 `json:"pending"` // buy-ins gogobee hasn't answered yet
Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale
Cap int64 `json:"cap"`
Hand *handView `json:"hand,omitempty"`
Events []eventView `json:"events,omitempty"` // only on a move, for the animation
Game string `json:"game,omitempty"` // "blackjack" | "hangman" | "solitaire", if one is live
Hand *handView `json:"hand,omitempty"` // blackjack
Events []eventView `json:"events,omitempty"` // blackjack, only on a move
Hangman *hangmanView `json:"hangman,omitempty"`
HangEvents []hangman.Event `json:"hang_events,omitempty"`
Solitaire *solitaireView `json:"solitaire,omitempty"`
SolEvents []solEventView `json:"sol_events,omitempty"`
Trivia *triviaView `json:"trivia,omitempty"`
TrivEvents []trivia.Event `json:"triv_events,omitempty"`
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"`
}
// table reads the player's money and any hand in progress.
// table reads the player's money and any game in progress.
func (s *Server) table(user string) (tableView, error) {
st, err := storage.Chips(user)
if err != nil {
@@ -193,16 +252,121 @@ func (s *Server) table(user string) (tableView, error) {
if err != nil {
return tableView{}, err
}
// Dispatch on the game the row says it is. Unmarshalling a hangman state into
// a blackjack one would not fail — JSON is happy to fill nothing in — it would
// just quietly produce an empty hand, which is the worst of both.
v.Game = live.Game
switch live.Game {
case gameBlackjack:
var hand blackjack.State
if err := json.Unmarshal(live.State, &hand); err != nil {
// A hand we can't read is a hand nobody can play. Rather than wedge the
// player out of the casino forever, drop it and tell them.
slog.Error("games: unreadable live hand, discarding", "user", user, "err", err)
_ = storage.ClearLiveHand(user)
return v, nil
return s.dropUnreadable(user, v, err)
}
hv := viewHand(hand)
v.Hand = &hv
case gameHangman:
var g hangman.State
if err := json.Unmarshal(live.State, &g); err != nil {
return s.dropUnreadable(user, v, err)
}
hv := viewHangman(g)
v.Hangman = &hv
case gameSolitaire:
var g klondike.State
if err := json.Unmarshal(live.State, &g); err != nil {
return s.dropUnreadable(user, v, err)
}
sv := viewSolitaire(g)
v.Solitaire = &sv
case gameTrivia:
var g trivia.State
if err := json.Unmarshal(live.State, &g); err != nil {
return s.dropUnreadable(user, v, err)
}
// The clock does not stop for a reload: Left is measured from the AskedAt
// the server stamped, so a player who refreshes to buy themselves a fresh
// twenty seconds finds the countdown exactly where they left it.
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(t.State, &g); err != nil {
return s.dropUnreadable(user, v, err)
}
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))
}
return v, nil
}
// dropUnreadable throws away a live game nobody can play. Rather than wedge the
// player out of the casino forever, it goes, and their stake with it — which is
// why it is logged loudly. The alternative is a player who can never be dealt
// another hand because an old one won't parse.
func (s *Server) dropUnreadable(user string, v tableView, err error) (tableView, error) {
slog.Error("games: unreadable live game, discarding", "user", user, "err", err)
_ = storage.ClearLiveHand(user)
v.Game = ""
return v, nil
}
@@ -406,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
@@ -438,13 +616,81 @@ func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) {
s.persist(w, user, next, evs, live.Seed1, live.Seed2, false)
}
// persist writes the hand back and answers the browser. A finished hand pays
// out, goes in the audit log, and leaves the felt; an unfinished one is saved
// as it stands, so a redeploy mid-hand is survivable.
// The games a live row can be. They're the storage key, so they're constants:
// a typo here is a game nobody can ever load again.
const (
gameBlackjack = "blackjack"
gameHangman = "hangman"
gameSolitaire = "solitaire"
gameTrivia = "trivia"
gameUno = "uno"
gameHoldem = "holdem"
)
// finished is what commit needs to know about a game it's writing back: enough
// to settle it, and nothing about how it's played. Both engines produce one.
type finished struct {
Game string
Blob []byte // the engine's whole state, shoe or phrase and all
Bet int64
Payout int64
Rake int64
Outcome string
Done bool
Seed1 uint64
Seed2 uint64
Fresh bool // a game just started, which is the one write that may be refused
}
// commit writes a game back and settles it if it's over. It is the money path,
// and every game goes through it so that none has to re-derive an ordering that
// took a while to get right.
//
// fresh marks a hand that has just been dealt, which is the one case where the
// write may be refused: the primary key, not an earlier read, is what enforces
// one hand at a time. A Deal that loses that race gets its stake back.
// 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) {
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,
},
})
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
}
v, err := s.table(user)
if err != nil {
slog.Error("games: table", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return tableView{}, false
}
return v, true
}
// persist writes a blackjack hand back and answers the browser.
func (s *Server) persist(w http.ResponseWriter, user string, st blackjack.State, evs []blackjack.Event, seed1, seed2 uint64, fresh bool) {
blob, err := json.Marshal(st)
if err != nil {
@@ -452,62 +698,19 @@ func (s *Server) persist(w http.ResponseWriter, user string, st blackjack.State,
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Seat the hand before doing anything else with it — even one that is already
// over, because a natural settles the instant it's dealt. The insert is what
// enforces one hand at a time, and it has to happen for *every* new hand: a
// natural dealt on top of a hand already in progress would otherwise settle,
// clear the felt, and take the other hand's stake down with it.
hand := storage.LiveHand{Game: "blackjack", State: blob, Seed1: seed1, Seed2: seed2}
save := storage.SaveLiveHand
if fresh {
save = storage.StartLiveHand
}
if err := save(user, hand); err != nil {
if errors.Is(err, storage.ErrHandInProgress) {
// Somebody was already sitting here. This hand was never seated, so the
// chips it staked go back: the player is in one hand, not two.
_ = storage.Award(user, st.Bet)
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a hand"})
return
}
slog.Error("games: save hand", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if st.Phase == blackjack.PhaseDone {
// Pay first, then clear. If Pete dies between the two, the player has been
// paid and the worst case is a settled hand 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, st.Payout); err != nil {
slog.Error("games: award", "user", user, "payout", st.Payout, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := storage.RecordHand(storage.Hand{
MatrixUser: user, Game: "blackjack",
done := st.Phase == blackjack.PhaseDone
v, ok := s.commit(w, user, finished{
Game: gameBlackjack, Blob: blob,
Bet: st.Bet, Payout: st.Payout, Rake: st.Rake,
Outcome: string(st.Outcome), Seed1: seed1, Seed2: seed2,
}); err != nil {
slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's hand
}
if err := storage.ClearLiveHand(user); err != nil {
slog.Error("games: clear hand", "user", user, "err", err)
}
}
storage.Touch(user)
v, err := s.table(user)
if err != nil {
slog.Error("games: table", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
Outcome: string(st.Outcome), Done: done,
Seed1: seed1, Seed2: seed2, Fresh: fresh,
})
if !ok {
return
}
// A settled hand is gone from storage, so the table view has no hand to show —
// but the browser still needs the final cards to animate the reveal onto.
if st.Phase == blackjack.PhaseDone {
if done {
hv := viewHand(st)
v.Hand = &hv
}

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

@@ -0,0 +1,51 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Every page the casino routes to must be in the games template set. This is not
// a fussy test: uno shipped with its handler wired, its engine tested and its
// template written, and every visit to the table answered "unknown page" with a
// 500 — because the page was never added to the list server.go parses. Nothing
// else caught it, since the other tests call the handlers straight and never go
// through render(). Add a game, add it here.
func TestEveryCasinoPageRenders(t *testing.T) {
s := newCasino(t)
pages := []string{
"/games",
"/games/blackjack",
"/games/hangman",
"/games/solitaire",
"/games/trivia",
"/games/uno",
"/games/holdem",
}
mux := http.NewServeMux()
s.casinoRoutes(mux)
for _, path := range pages {
t.Run(path, func(t *testing.T) {
w := httptest.NewRecorder()
mux.ServeHTTP(w, as(t, s, "reala", "GET", path, nil))
if w.Code != http.StatusOK {
t.Fatalf("GET %s = %d, want 200 (body: %s)", path, w.Code, strings.TrimSpace(w.Body.String()))
}
// render() writes the 500 as a body and a page that fails halfway
// through still comes back 200, so look at what was actually served.
body := w.Body.String()
if strings.Contains(body, "unknown page") {
t.Fatalf("GET %s served the unknown-page error: the template is missing from the games set in server.go", path)
}
if !strings.Contains(body, "</html>") {
t.Fatalf("GET %s did not render a whole page (%d bytes) — the template blew up mid-render", path, len(body))
}
})
}
}

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

View File

@@ -0,0 +1,286 @@
package web
import (
"encoding/json"
"errors"
"log/slog"
"math/rand/v2"
"net/http"
"pete/internal/games/blackjack"
"pete/internal/games/cards"
"pete/internal/games/klondike"
"pete/internal/storage"
)
// Solitaire, played for chips. Vegas scoring: you buy the deck, and every card
// you get home pays a slice of it back.
//
// The withheld information here is bigger than blackjack's single hole card —
// it's the stock and every face-down card in the tableau, which between them are
// most of the deck. So the view sends *counts* for both: a column says how many
// cards are face-down under it, never which. A browser that held the stock would
// be a browser that knows whether the next pull is worth taking, and this game is
// nothing but that decision, repeated.
//
// The events, on the other hand, need no filtering at all, and that's worth
// saying out loud because blackjack's do. Every card a klondike event carries is
// a card the move itself just turned face up: the draw puts cards in the waste,
// the flip turns a column's top card over, a move carries cards that were already
// face up. There is no event here that mentions a card the player isn't now
// looking at.
// solPileView is one tableau column: how deep the face-down stack is, and the
// run sitting face up on top of it.
type solPileView struct {
Down int `json:"down"`
Up []cardView `json:"up"`
}
// solFoundView is one foundation. Only the top card matters — it's the only one
// that can be played back off — so it's the only one sent, with a count for the
// height of the pile.
type solFoundView struct {
Suit string `json:"suit"` // the glyph, so the empty pile can show what it wants
Red bool `json:"red"`
N int `json:"n"`
Top *cardView `json:"top,omitempty"`
}
// solitaireView is a board as its player may see it.
type solitaireView struct {
Tier klondike.Tier `json:"tier"`
Stock int `json:"stock"` // how many cards are left in it, not which
Waste []cardView `json:"waste"` // the top few, in the order they were turned
WasteN int `json:"waste_n"`
Table []solPileView `json:"table"`
Found []solFoundView `json:"found"`
Passes int `json:"passes"` // through the stock, counting this one; -1 unlimited
Moves int `json:"moves"`
CanAuto bool `json:"can_auto"`
Home int `json:"home"` // cards on the foundations
PerCard float64 `json:"per_card"` // what one more is worth
BreakEven int `json:"break_even"` // how many gets you square with the house
Bet int64 `json:"bet"`
Stands int64 `json:"stands"` // what cashing out right now actually pays
Phase string `json:"phase"`
Outcome string `json:"outcome,omitempty"`
Payout int64 `json:"payout,omitempty"`
Rake int64 `json:"rake,omitempty"`
Net int64 `json:"net"`
}
// wasteShown is how much of the waste the felt fans out. Three, because that is
// what a three-card draw puts down and the rest of the pile is just a pile.
const wasteShown = 3
func viewSolitaire(g klondike.State) solitaireView {
v := solitaireView{
Tier: g.Tier,
Stock: len(g.Stock),
WasteN: len(g.Waste),
Passes: g.PassesLeft(),
Moves: g.Moves,
CanAuto: g.CanAuto(),
Home: g.Home(),
PerCard: g.PerCard(),
BreakEven: g.Tier.BreakEven(),
Bet: g.Bet,
// What cashing out right now would actually land on the stack, rake already
// out of it. The pre-rake figure would have the felt advertising a number
// the house doesn't hand over.
Stands: g.Pays(),
Phase: string(g.Phase),
Outcome: string(g.Outcome),
Payout: g.Payout,
Rake: g.Rake,
Net: g.Net(),
}
from := len(g.Waste) - wasteShown
if from < 0 {
from = 0
}
for _, c := range g.Waste[from:] {
v.Waste = append(v.Waste, viewCard(c))
}
v.Table = make([]solPileView, klondike.Piles)
for i, p := range g.Table {
v.Table[i] = solPileView{Down: len(p.Down)}
for _, c := range p.Up {
v.Table[i].Up = append(v.Table[i].Up, viewCard(c))
}
}
v.Found = make([]solFoundView, klondike.Foundations)
for i, f := range g.Found {
suit := cards.Suit(i)
fv := solFoundView{Suit: suit.String(), Red: suit == cards.Hearts || suit == cards.Diamonds, N: len(f)}
if len(f) > 0 {
top := viewCard(f[len(f)-1])
fv.Top = &top
}
v.Found[i] = fv
}
return v
}
// solEventView is one thing the table animates. See the note at the top: unlike
// blackjack's, these need nothing stripped out of them.
type solEventView struct {
Kind string `json:"kind"`
Cards []cardView `json:"cards,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Home int `json:"home"`
Pays int64 `json:"pays"`
}
func viewSolEvents(evs []klondike.Event) []solEventView {
out := make([]solEventView, 0, len(evs))
for _, e := range evs {
v := solEventView{Kind: e.Kind, From: e.From, To: e.To, Home: e.Home, Pays: e.Pays}
for _, c := range e.Cards {
v.Cards = append(v.Cards, viewCard(c))
}
out = append(out, v)
}
return out
}
// handleSolitaireStart takes the stake and deals the board. Same order as a
// blackjack deal: the chips are staked first, in the same statement that checks
// they exist, so two starts fired at once cannot buy the same deck twice.
func (s *Server) handleSolitaireStart(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req struct {
Bet int64 `json:"bet"`
Tier string `json:"tier"`
}
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
return
}
tier, err := klondike.TierBySlug(req.Tier)
if err != nil {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a deal"})
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 deck"})
return
}
slog.Error("games: solitaire stake", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
seed1, seed2 := newSeeds()
rng := rand.New(rand.NewPCG(seed1, seed2))
g, evs, err := klondike.New(req.Bet, tier, blackjack.DefaultRules().RakePct, rng)
if err != nil {
// The board never happened, so the stake never should have left.
_ = storage.Award(user, req.Bet)
slog.Error("games: solitaire deal", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.persistSolitaire(w, user, g, evs, seed1, seed2, true)
}
// solitaireErrors are the illegal moves a player makes by playing, rather than
// by tampering. Each gets said back to them in words, because "that move isn't
// legal" over a board with 60 legal-looking targets on it is not an answer.
var solitaireErrors = map[error]string{
klondike.ErrWontGo: "that card doesn't go there",
klondike.ErrNotASequence: "you can only lift a run that goes down in rank and alternates colour",
klondike.ErrEmptyPile: "there's nothing there",
klondike.ErrNoDraw: "the stock is empty",
klondike.ErrNoPasses: "that was your last pass through the stock",
klondike.ErrNothingHome: "nothing can go home right now",
klondike.ErrGameOver: "that board is finished",
}
// handleSolitaireMove plays one move: a draw, a card moved, a card sent home, an
// auto-finish, or cashing the board in.
func (s *Server) handleSolitaireMove(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var move klondike.Move
if err := decodeJSON(r, &move); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
live, err := storage.LoadLiveHand(user)
if errors.Is(err, storage.ErrNoLiveHand) {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"})
return
}
if err != nil {
slog.Error("games: solitaire load", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if live.Game != gameSolitaire {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand you're in first"})
return
}
var g klondike.State
if err := json.Unmarshal(live.State, &g); err != nil {
slog.Error("games: unreadable solitaire board", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
next, evs, err := klondike.ApplyMove(g, move)
if err != nil {
msg, known := solitaireErrors[err]
if !known {
msg = "that move isn't legal here"
}
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
return
}
s.persistSolitaire(w, user, next, evs, live.Seed1, live.Seed2, false)
}
// persistSolitaire writes the board back and answers the browser.
func (s *Server) persistSolitaire(w http.ResponseWriter, user string, g klondike.State, evs []klondike.Event, seed1, seed2 uint64, fresh bool) {
blob, err := json.Marshal(g)
if err != nil {
slog.Error("games: marshal solitaire", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
done := g.Phase == klondike.PhaseDone
v, ok := s.commit(w, user, finished{
Game: gameSolitaire, Blob: blob,
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
Outcome: string(g.Outcome), Done: done,
Seed1: seed1, Seed2: seed2, Fresh: fresh,
})
if !ok {
return
}
// A finished board is gone from storage, so the table has none to show — but
// the browser still needs the final one to animate the last cards onto.
if done {
sv := viewSolitaire(g)
v.Solitaire = &sv
}
v.SolEvents = viewSolEvents(evs)
writeJSON(w, v)
}

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

@@ -0,0 +1,224 @@
package web
import (
"encoding/json"
"errors"
"log/slog"
"math/rand/v2"
"net/http"
"time"
"pete/internal/games/blackjack"
"pete/internal/games/trivia"
"pete/internal/storage"
)
// Trivia, played for chips.
//
// The same shape as the other tables: the browser sends intents, the server
// holds the state, and the payload carries only what the player is entitled to
// see. Here that means the four answers *without* which of them is right. The
// right one is an index in the engine state, which is in game_live_hands, on
// this side of the wire — and it only ever crosses in the event that reveals it,
// once the question has been decided and it can't be used to answer.
//
// The clock is the other half. The countdown in the browser is decoration: the
// only clock that scores anything is time.Now() here, measured against the
// AskedAt the server stamped when it served the question. A player who stops
// their own countdown, or reloads to restart it, changes nothing.
// triviaView is a game as its player may see it.
type triviaView struct {
Tier trivia.Tier `json:"tier"`
Rung int `json:"rung"` // how many they've answered
Rungs int `json:"rungs"` // how many there are
Category string `json:"category,omitempty"`
Question string `json:"question,omitempty"`
Answers []string `json:"answers,omitempty"` // and *not* which one is right
Limit int `json:"limit"` // the tier's seconds per question
Left float64 `json:"left"` // seconds this question has left, by the server's clock
Multiple float64 `json:"multiple"`
Bet int64 `json:"bet"`
Stands int64 `json:"stands"` // what taking the money right now actually pays
CanWalk bool `json:"can_walk"` // false on the first question: see the engine
Phase string `json:"phase"`
Outcome string `json:"outcome,omitempty"`
Payout int64 `json:"payout,omitempty"`
Rake int64 `json:"rake,omitempty"`
Net int64 `json:"net"`
}
func viewTrivia(g trivia.State, now time.Time) triviaView {
v := triviaView{
Tier: g.Tier,
Rung: g.Rung,
Rungs: trivia.Rungs,
Limit: g.Tier.Limit,
// What the player would actually collect, rake already out of it — quoting
// the pre-rake figure would have the felt advertising a payout the house
// doesn't hand over.
Stands: g.Pays(),
Multiple: g.Multiple,
Bet: g.Bet,
CanWalk: g.Rung > 0,
Phase: string(g.Phase),
Outcome: string(g.Outcome),
Payout: g.Payout,
Rake: g.Rake,
Net: g.Net(),
}
// A finished game has no live question, and must not ship the next one — the
// ladder still has rungs on it that a later game might deal.
if g.Phase == trivia.PhasePlaying {
q := g.Live()
v.Category = q.Category
v.Question = q.Text
v.Answers = q.Answers
v.Left = g.Left(now).Seconds()
}
return v
}
// handleTriviaStart takes the bet and builds a ladder. Same order as every other
// table: the chips are staked first, in the same statement that checks they
// exist, so two starts fired at once cannot bet the same chip.
func (s *Server) handleTriviaStart(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req struct {
Bet int64 `json:"bet"`
Tier string `json:"tier"`
}
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
return
}
tier, err := trivia.TierBySlug(req.Tier)
if err != nil {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a difficulty"})
return
}
seed1, seed2 := newSeeds()
rng := rand.New(rand.NewPCG(seed1, seed2))
// Draw the ladder *before* taking the money. A bank too thin to deal from is
// the one failure here that isn't the player's fault, and they should not have
// to be refunded for it.
qs, err := storage.DrawTrivia(tier.Difficulty, trivia.Rungs, rng)
if errors.Is(err, storage.ErrBankEmpty) {
writeJSONStatus(w, http.StatusServiceUnavailable, map[string]string{
"error": "the question bank is still filling up — give it a minute",
})
return
}
if err != nil {
slog.Error("games: trivia draw", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
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: trivia stake", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
now := time.Now()
g, evs, err := trivia.New(req.Bet, tier, blackjack.DefaultRules().RakePct, qs, now, rng)
if err != nil {
// The game never happened, so the stake never should have left.
_ = storage.Award(user, req.Bet)
slog.Error("games: trivia start", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.persistTrivia(w, user, g, evs, seed1, seed2, true, now)
}
// handleTriviaAnswer plays one move: pick an answer, or take the money.
func (s *Server) handleTriviaAnswer(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var move trivia.Move
if err := decodeJSON(r, &move); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
live, err := storage.LoadLiveHand(user)
if errors.Is(err, storage.ErrNoLiveHand) {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"})
return
}
if err != nil {
slog.Error("games: trivia load", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if live.Game != gameTrivia {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
return
}
var g trivia.State
if err := json.Unmarshal(live.State, &g); err != nil {
slog.Error("games: unreadable trivia game", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// The server's clock, and the only one that counts. Read once, so the answer
// and the view that reports it agree about what time it is.
now := time.Now()
next, evs, err := trivia.ApplyMove(g, move, now)
if err != nil {
msg := "that move isn't legal here"
if errors.Is(err, trivia.ErrNothingBanked) {
msg = "answer one before you walk"
}
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
return
}
s.persistTrivia(w, user, next, evs, live.Seed1, live.Seed2, false, now)
}
// persistTrivia writes the game back and answers the browser.
func (s *Server) persistTrivia(w http.ResponseWriter, user string, g trivia.State, evs []trivia.Event, seed1, seed2 uint64, fresh bool, now time.Time) {
blob, err := json.Marshal(g)
if err != nil {
slog.Error("games: marshal trivia", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
done := g.Phase == trivia.PhaseDone
v, ok := s.commit(w, user, finished{
Game: gameTrivia, Blob: blob,
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
Outcome: string(g.Outcome), Done: done,
Seed1: seed1, Seed2: seed2, Fresh: fresh,
})
if !ok {
return
}
// A finished game is gone from storage, so the table has none to show — but the
// browser still needs the final board to land the verdict on.
if done {
tv := viewTrivia(g, now)
v.Trivia = &tv
}
v.TrivEvents = evs
writeJSON(w, v)
}

677
internal/web/games_uno.go Normal file
View File

@@ -0,0 +1,677 @@
package web
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"pete/internal/games/blackjack"
"pete/internal/games/uno"
"pete/internal/storage"
)
// UNO, played for chips at a shared 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.
//
// 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: colour and face as words, not the
// engine's integers.
type unoCardView struct {
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()}
}
// 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"`
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 table as one seat may see it.
type unoView struct {
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
Top unoCardView `json:"top"` // the card in play
Color string `json:"color"` // the colour in play, which a wild renames
Deck int `json:"deck"` // cards left to draw
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
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"`
LastPot int64 `json:"last_pot,omitempty"`
Rake int64 `json:"rake,omitempty"`
Outcome string `json:"outcome,omitempty"`
}
// 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,
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),
}
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)
}
// 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))
}
}
if v.Hand == nil {
v.Hand = []unoCardView{}
}
// 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 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"`
By int `json:"by"`
Text string `json:"text,omitempty"`
Hand []unoCardView `json:"hand,omitempty"`
}
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, 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 {
if e.Kind == uno.EvDraw || e.Kind == uno.EvForced {
if e.Seat == viewer {
c := viewUnoCard(*e.Card)
v.Card = &c
}
} else {
c := viewUnoCard(*e.Card)
v.Card = &c
}
}
out = append(out, v)
}
return out
}
// ---- 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 {
Tier string `json:"tier"`
BuyIn int64 `json:"buyin"`
Table string `json:"table"`
Seat *int `json:"seat"`
}
if err := decodeJSON(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
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 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, _, err := uno.New(tier, uno.TableSeats(tier, name, tier.Bots, buyIn), blackjack.DefaultRules().RakePct, seed1, seed2)
if err != nil {
slog.Error("games: uno 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 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)
}
// 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 {
return
}
var move uno.Move
if err := decodeJSON(r, &move); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if move.Kind == uno.MoveLeave {
s.leaveUno(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: uno move seat", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
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
}
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
}
if respErr != nil {
writeJSONStatus(w, unoMoveStatus(respErr), map[string]string{"error": unoMoveMessage(respErr)})
return
}
s.writeUnoTable(w, user, respEvents)
}
func unoMoveStatus(err error) int {
switch {
case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable):
return http.StatusConflict
default:
return http.StatusBadRequest
}
}
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
}
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)
}
}
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

@@ -0,0 +1,237 @@
package web
import (
"testing"
"pete/internal/games/uno"
)
// 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, 5000)
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("sit = %d, want 200", code)
}
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("sit returned no table: game=%q", v.Game)
}
g := v.Uno
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("three bots and you is four 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)
}
// 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 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)
}
}
// A move plays your turn and every bot turn behind it, and the script that comes
// back never carries a bot's drawn card.
func TestUnoMovePlaysTheWholeLap(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
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)
}
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "draw"}))
if code != 200 {
t.Fatalf("draw = %d, want 200", code)
}
if v.Uno == nil {
t.Fatal("the move returned no game")
}
if len(v.UnoEvents) == 0 {
t.Fatal("a move that happened sent back no events")
}
if v.Uno.Phase != "drawn" && v.Uno.Turn != 0 {
t.Errorf("the bots should have played back round to you, turn is %d", v.Uno.Turn)
}
for _, e := range v.UnoEvents {
if (e.Kind == uno.EvDraw || e.Kind == uno.EvForced) && e.Seat != 0 && e.Card != nil {
t.Fatalf("a bot's drawn card crossed the wire: %+v", e)
}
}
}
// 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)
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)
}
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)
}
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"`
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"}},
{"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,17 +248,18 @@ 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.
if s.gamesReady() {
mux.HandleFunc("GET /games", s.handleLobby)
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /api/games/table", s.handleTable)
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
s.casinoRoutes(mux)
}
if s.auth != nil {
@@ -252,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)

File diff suppressed because it is too large Load Diff

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,25 +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]");
var handsEl = root.querySelector("[data-hands]");
var handTpl = root.querySelector("[data-hand-template]");
// Nothing is bet until a chip is on the felt. The number in the panel is a
// readout of the pile, so it starts where the pile does — at nothing — rather
// than at a default stake nobody put down.
var bet = 0; // what you're building between hands
var staked = 0; // what is actually sitting on the spot right now
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; }
@@ -68,236 +67,148 @@
}
// ---- drawing --------------------------------------------------------------
var dealt = 0; // how many cards this table has put down, ever — the tilt seed
// cardEl builds one card. face === null means face-down: the card is dealt,
// but this browser has not been told what it is.
function cardEl(face) {
var wrap = document.createElement("div");
wrap.className = "pete-card";
wrap.dataset.face = face ? "up" : "down";
// Every card flies out of the shoe, which sits in the top-right of the felt.
// The offset is per-card, so a card landing further left flies further.
wrap.style.setProperty("--deal-x", "14rem");
wrap.style.setProperty("--deal-y", "-6rem");
// Where it comes to rest. A degree or two either way is the whole difference
// between cards that were dealt onto a table and cards that were laid out in
// a grid, and it costs one custom property.
wrap.style.setProperty("--tilt", FX.jitter(dealt++, 2.4).toFixed(2) + "deg");
var inner = document.createElement("div");
inner.className = "pete-card-inner";
var front = document.createElement("div");
front.className = "pete-card-front";
var back = document.createElement("div");
back.className = "pete-card-back";
inner.appendChild(front);
inner.appendChild(back);
wrap.appendChild(inner);
if (face) paintFace(front, face);
return wrap;
}
// ---- the face ------------------------------------------------------------
//
// A card is drawn, not typed. The first attempt set the pips as text — "♠" in a
// span — and at the size a card actually is, a suit character renders as a
// speck: the shape is whatever font happened to answer, it doesn't scale, and
// it can't be positioned to the half-row a real pip layout needs.
// The deck itself — the faces, the pips, the flip — is PeteCards, shared with
// every other table in the room. Here a card is always dealt out of the shoe
// and always lands with a degree or two of tilt on it, which are this table's
// two opinions about a card and the only ones it has.
var CARDS = window.PeteCards;
function cardEl(face) { return CARDS.el(face); }
var turnOver = CARDS.turnOver;
// ---- the hands -------------------------------------------------------------
//
// So each face is one SVG on a 100×140 field (the proportions of a real card),
// with the suits as vector shapes. Everything below is coordinates on that
// field, which is why the pips land where a printed deck puts them instead of
// where a flexbox felt like putting them.
// 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 SUIT_ART = {
"♠": '<path d="M50 6C50 6 16 34 16 58a17 17 0 0 0 28 13c-1 12-5 19-12 25h36c-7-6-11-13-12-25a17 17 0 0 0 28-13C84 34 50 6 50 6Z"/>',
"♥": '<path d="M50 96C50 96 8 66 8 38A22 22 0 0 1 50 24 22 22 0 0 1 92 38c0 28-42 58-42 58Z"/>',
"♦": '<path d="M50 4 88 50 50 96 12 50Z"/>',
"♣": '<g><circle cx="50" cy="28" r="19"/><circle cx="24" cy="62" r="19"/><circle cx="76" cy="62" r="19"/>' +
'<path d="M44 60h12l7 36H37Z"/></g>',
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]"),
}),
};
// Pip layouts, the way a real deck lays them out — which is not "N suits in a
// row". [x, y] on the 100×140 field. The seven canonical rows sit at y = 27,
// 41, 56, 70, 84, 99, 113; sevens, eights and tens carry a pip *between* two of
// them, which is the whole reason this is a table of coordinates and not a
// grid. Anything below the middle is printed upside down, so it is.
var R = [0, 27, 41.4, 55.7, 70, 84.3, 98.6, 113]; // 1-indexed, R[4] is the middle
var L = 30, C = 50, Rr = 70; // the three columns
var PIPS = {
"A": [[C, 70, 2.1]],
"2": [[C, R[1]], [C, R[7]]],
"3": [[C, R[1]], [C, R[4]], [C, R[7]]],
"4": [[L, R[1]], [Rr, R[1]], [L, R[7]], [Rr, R[7]]],
"5": [[L, R[1]], [Rr, R[1]], [C, R[4]], [L, R[7]], [Rr, R[7]]],
"6": [[L, R[1]], [Rr, R[1]], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
"7": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
"8": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
"9": [[L, R[1]], [Rr, R[1]], [L, R[3]], [Rr, R[3]], [C, R[4]], [L, R[5]], [Rr, R[5]], [L, R[7]], [Rr, R[7]]],
"10": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[3]], [Rr, R[3]], [L, R[5]], [Rr, R[5]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
};
var COURT = { "J": "Jack", "Q": "Queen", "K": "King" };
// One pip: the suit art, scaled and dropped at [x, y], turned over if it sits
// below the middle of the card.
function pipAt(suit, x, y, scale) {
var s = (scale || 1) * 0.17;
var turn = y > 70 ? " rotate(180 50 50)" : "";
return '<g transform="translate(' + x + ' ' + y + ') scale(' + s + ') translate(-50 -50)' + turn + '">' +
SUIT_ART[suit] + "</g>";
}
// The corner index: rank over suit. Printed in both corners, the second one
// upside down, which is what lets you read a card from a fanned hand.
function index(face) {
var g =
'<g>' +
'<text x="12" y="24" class="pete-card-idx">' + face.rank + "</text>" +
'<g transform="translate(12 36) scale(0.13) translate(-50 -50)">' + SUIT_ART[face.suit] + "</g>" +
"</g>";
return g + '<g transform="rotate(180 50 70)">' + g + "</g>";
}
// paintFace draws the card. The dealer's cards and yours use the same face,
// because they came out of the same shoe.
function paintFace(front, face) {
front.dataset.red = face.red ? "1" : "0";
var body = "";
if (COURT[face.rank]) {
// Court cards: a framed panel, the suit above the letter and again below it
// the other way up. A real court mirrors a *figure*; mirroring a letter just
// stacks two of them into a blob, which is exactly what the first attempt
// did. No portrait either — a drawn king would fight the room, and this
// reads instantly at the size a card actually is.
body =
'<rect x="20" y="22" width="60" height="96" rx="6" class="pete-card-panel"/>' +
pipAt(face.suit, 50, 38, 0.95) +
'<text x="50" y="82" class="pete-card-court">' + face.rank + "</text>" +
pipAt(face.suit, 50, 102, 0.95);
if (at === undefined || at >= hands.length) {
handsEl.appendChild(el);
hands.push(box);
} else {
var spots = PIPS[face.rank] || [];
for (var i = 0; i < spots.length; i++) {
body += pipAt(face.suit, spots[i][0], spots[i][1], spots[i][2]);
handsEl.insertBefore(el, hands[at].el);
hands.splice(at, 0, box);
}
handsEl.dataset.count = hands.length;
return box;
}
front.innerHTML =
'<svg class="pete-card-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg" ' +
'role="img" aria-label="' + ariaFor(face) + '">' + index(face) + body + "</svg>";
// 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();
}
// "A♠" is not something a screen reader should be asked to pronounce.
function ariaFor(face) {
var SUITS = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" };
var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank);
var suit = SUITS[face.suit];
return suit ? name + " of " + suit : face.label;
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";
});
}
// turnOver flips a face-down card up, now that we've been told what it is.
function turnOver(wrap, face) {
if (!wrap) return;
paintFace(wrap.querySelector(".pete-card-front"), face);
wrap.dataset.face = "up";
// 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 -------------------------------------------------
//
// `staked` is what the spot is holding. Every path that changes it also moves
// chips to say so, so the two can't come apart: renderStack draws the pile,
// and the fly* calls are what put it there.
function renderStack(amount) {
staked = amount || 0;
stackEl.innerHTML = "";
spotEl.dataset.live = staked > 0 ? "1" : "0";
if (!staked) {
spotTotalEl.classList.add("hidden");
return;
}
FX.chipsFor(staked).forEach(function (d, i) {
var c = FX.disc(d);
c.style.setProperty("--i", i);
c.style.setProperty("--spin", FX.jitter(i, 12).toFixed(1) + "deg");
c.style.animationDelay = pace(i * 40) + "ms";
stackEl.appendChild(c);
});
spotTotalEl.textContent = staked.toLocaleString();
spotTotalEl.classList.remove("hidden");
}
// pour throws a run of chips from one place to another and grows the pile on
// the spot as each one lands — by the value of the chip that landed, so the
// total under the pile counts up the way the chips do. The last chip carries
// the remainder, because chipsFor caps how many chips it will make you watch
// and the pile still has to end on the real number.
function pour(from, to, amount, opts) {
if (amount <= 0) return Promise.resolve();
var base = staked;
var chips = FX.chipsFor(amount, 8);
var run = 0;
return FX.flyMany(from, to, chips, Object.assign({
onLand: function (d, i) {
run += d;
renderStack(base + (i === chips.length - 1 ? amount : run));
},
}, opts || {}));
}
// 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 pour(from || purseEl, spotEl, 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.
var lost = FX.chipsFor(final.bet, 8);
var chain = FX.flyMany(spotEl, houseEl, lost, { gap: 45, lift: 0.6, fade: true });
renderStack(0);
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;
}
// The house pays first, into the spot beside your stake, so you watch the
// winnings arrive on top of the bet that earned them.
var pay = pour(houseEl, spotEl, back, { gap: 60 });
// Paid, then swept up: the whole lot comes back to your pile, and only then
// does the number in the bar move.
return pay
.then(function () { return wait(back > 0 ? 380 : 200); })
.then(function () {
var home = FX.flyMany(spotEl, purseEl, FX.chipsFor(payout, 8), { gap: 40, lift: 0.8 });
renderStack(0);
return home;
});
}
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");
@@ -306,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); renderStack(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));
renderStack(v.phase === "done" ? 0 : v.bet);
totals(v);
dealerTotal(v);
setPhase(v);
}
@@ -333,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 -----------------------------------------------------------
@@ -381,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 > staked) {
var extra = final.bet - staked;
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]);
@@ -439,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
@@ -452,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) {
@@ -471,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
@@ -499,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) renderStack(0);
if (v && !v.hand) { reset(1); setPhase(null); }
else if (v && v.hand) paint(v.hand);
});
})
.then(function () { busy = false; });
@@ -517,7 +516,9 @@
if (dealBtn) dealBtn.disabled = bet <= 0 || !money || money.chips < bet;
}
root.querySelectorAll("[data-chip]").forEach(function (btn) {
// Scoped to buttons: the bare [data-chip] spans in the corner are the house's
// rack, and the house is not betting.
root.querySelectorAll("button[data-chip]").forEach(function (btn) {
btn.addEventListener("click", function () {
if (busy) return;
var d = parseInt(btn.dataset.chip, 10);
@@ -531,12 +532,13 @@
// The chip you clicked is the chip that flies: same colour, same size, off
// the button and onto the felt. The pile only grows once it gets there —
// but `staked` moves now, so a Deal pressed mid-flight still knows the chip
// is on its way and doesn't put a second one down.
// 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;
staked = bet;
FX.fly(btn, spotEl, { denom: d }).then(function () {
if (bet >= target) renderStack(target); // unless Clear got there first
var spot = betSpot();
spot.amount = bet;
FX.fly(btn, hands[0].spotEl, { denom: d }).then(function () {
if (bet >= target) spot.render(target); // unless Clear got there first
});
});
});
@@ -544,10 +546,9 @@
var clearBtn = root.querySelector("[data-bet-clear]");
if (clearBtn) {
clearBtn.addEventListener("click", function () {
if (busy || !staked) { bet = 0; showBet(); return; }
FX.flyMany(spotEl, purseEl, FX.chipsFor(staked, 8), { gap: 40, lift: 0.7 });
if (busy || !betSpot().amount) { bet = 0; showBet(); return; }
betSpot().sweep(purseEl, null, { gap: 40, lift: 0.7 });
bet = 0;
renderStack(0);
showBet();
});
}
@@ -555,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 });
});
}
@@ -570,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

@@ -0,0 +1,178 @@
// The deck, as the room draws it.
//
// A card is drawn, not typed. The first attempt set the pips as text — "♠" in a
// span — and at the size a card actually is, a suit character renders as a speck:
// the shape is whatever font happened to answer, it doesn't scale, and it can't
// be positioned to the half-row a real pip layout needs.
//
// So each face is one SVG on a 100×140 field (the proportions of a real card),
// with the suits as vector shapes. Everything below is coordinates on that field,
// which is why the pips land where a printed deck puts them instead of where a
// flexbox felt like putting them.
//
// This started life inside blackjack.js. It's out here because solitaire deals
// off the same deck, and the second table in a casino is exactly the moment a
// copied card renderer becomes two card renderers that drift.
//
// Exposed as window.PeteCards. Nothing in here knows what game is being played.
(function () {
"use strict";
var SUIT_ART = {
"♠": '<path d="M50 6C50 6 16 34 16 58a17 17 0 0 0 28 13c-1 12-5 19-12 25h36c-7-6-11-13-12-25a17 17 0 0 0 28-13C84 34 50 6 50 6Z"/>',
"♥": '<path d="M50 96C50 96 8 66 8 38A22 22 0 0 1 50 24 22 22 0 0 1 92 38c0 28-42 58-42 58Z"/>',
"♦": '<path d="M50 4 88 50 50 96 12 50Z"/>',
"♣": '<g><circle cx="50" cy="28" r="19"/><circle cx="24" cy="62" r="19"/><circle cx="76" cy="62" r="19"/>' +
'<path d="M44 60h12l7 36H37Z"/></g>',
};
// Pip layouts, the way a real deck lays them out — which is not "N suits in a
// row". [x, y] on the 100×140 field. The seven canonical rows sit at y = 27,
// 41, 56, 70, 84, 99, 113; sevens, eights and tens carry a pip *between* two of
// them, which is the whole reason this is a table of coordinates and not a
// grid. Anything below the middle is printed upside down, so it is.
var R = [0, 27, 41.4, 55.7, 70, 84.3, 98.6, 113]; // 1-indexed, R[4] is the middle
var L = 30, C = 50, Rr = 70; // the three columns
var PIPS = {
"A": [[C, 70, 2.1]],
"2": [[C, R[1]], [C, R[7]]],
"3": [[C, R[1]], [C, R[4]], [C, R[7]]],
"4": [[L, R[1]], [Rr, R[1]], [L, R[7]], [Rr, R[7]]],
"5": [[L, R[1]], [Rr, R[1]], [C, R[4]], [L, R[7]], [Rr, R[7]]],
"6": [[L, R[1]], [Rr, R[1]], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
"7": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
"8": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
"9": [[L, R[1]], [Rr, R[1]], [L, R[3]], [Rr, R[3]], [C, R[4]], [L, R[5]], [Rr, R[5]], [L, R[7]], [Rr, R[7]]],
"10": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[3]], [Rr, R[3]], [L, R[5]], [Rr, R[5]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
};
var COURT = { "J": "Jack", "Q": "Queen", "K": "King" };
var SUIT_NAMES = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" };
// One pip: the suit art, scaled and dropped at [x, y], turned over if it sits
// below the middle of the card.
function pipAt(suit, x, y, scale) {
var s = (scale || 1) * 0.17;
var turn = y > 70 ? " rotate(180 50 50)" : "";
return '<g transform="translate(' + x + ' ' + y + ') scale(' + s + ') translate(-50 -50)' + turn + '">' +
SUIT_ART[suit] + "</g>";
}
// The corner index: rank over suit. Printed in both corners, the second one
// upside down, which is what lets you read a card from a fanned hand — and in
// solitaire, from a column where all you can see is the top eighth of it.
function index(face) {
var g =
'<g>' +
'<text x="12" y="24" class="pete-card-idx">' + face.rank + "</text>" +
'<g transform="translate(12 36) scale(0.13) translate(-50 -50)">' + SUIT_ART[face.suit] + "</g>" +
"</g>";
return g + '<g transform="rotate(180 50 70)">' + g + "</g>";
}
// paint draws the face. Every table uses this one, because they all deal out of
// the same deck.
function paint(front, face) {
front.dataset.red = face.red ? "1" : "0";
var body = "";
if (COURT[face.rank]) {
// Court cards: a framed panel, the suit above the letter and again below it
// the other way up. A real court mirrors a *figure*; mirroring a letter just
// stacks two of them into a blob, which is exactly what the first attempt
// did. No portrait either — a drawn king would fight the room, and this
// reads instantly at the size a card actually is.
body =
'<rect x="20" y="22" width="60" height="96" rx="6" class="pete-card-panel"/>' +
pipAt(face.suit, 50, 38, 0.95) +
'<text x="50" y="82" class="pete-card-court">' + face.rank + "</text>" +
pipAt(face.suit, 50, 102, 0.95);
} else {
var spots = PIPS[face.rank] || [];
for (var i = 0; i < spots.length; i++) {
body += pipAt(face.suit, spots[i][0], spots[i][1], spots[i][2]);
}
}
front.innerHTML =
'<svg class="pete-card-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg" ' +
'role="img" aria-label="' + aria(face) + '">' + index(face) + body + "</svg>";
}
// "A♠" is not something a screen reader should be asked to pronounce.
function aria(face) {
var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank);
var suit = SUIT_NAMES[face.suit];
return suit ? name + " of " + suit : face.label;
}
var dealt = 0; // how many cards this page has put down, ever — the tilt seed
// el builds one card. face === null means face-down: the card is on the table,
// but this browser has not been told what it is.
//
// opts.deal — fly it in from the shoe (blackjack). Solitaire turns this off:
// it animates its own cards from wherever they actually came from,
// and a board that re-renders would otherwise re-deal itself out of
// the corner on every single move.
// opts.tilt — a degree or two of resting angle. A dealt hand wants it; a
// solitaire column does not, because thirteen tilted cards stacked
// an eighth of an inch apart read as a mistake rather than as a
// hand that was handled.
function el(face, opts) {
opts = opts || {};
var deal = opts.deal !== false;
var tilt = opts.tilt !== false;
var wrap = document.createElement("div");
wrap.className = "pete-card";
wrap.dataset.face = face ? "up" : "down";
if (face) wrap.dataset.key = face.label; // one deck, so the label is an id
if (deal) {
// Every card flies out of the shoe, which sits in the top-right of the felt.
wrap.style.setProperty("--deal-x", "14rem");
wrap.style.setProperty("--deal-y", "-6rem");
} else {
wrap.style.animation = "none";
}
wrap.style.setProperty("--tilt", tilt ? window.PeteFX.jitter(dealt++, 2.4).toFixed(2) + "deg" : "0deg");
var inner = document.createElement("div");
inner.className = "pete-card-inner";
var front = document.createElement("div");
front.className = "pete-card-front";
var back = document.createElement("div");
back.className = "pete-card-back";
inner.appendChild(front);
inner.appendChild(back);
wrap.appendChild(inner);
if (face) paint(front, face);
return wrap;
}
// 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 = {
el: el,
paint: paint,
turnOver: turnOver,
aria: aria,
art: SUIT_ART,
};
})();

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) {
@@ -73,19 +81,22 @@
return ((n - Math.floor(n)) * 2 - 1) * (spread || 1);
}
// fly moves one chip from one place to another and resolves when it lands.
// flyNode moves *anything* from one place to another and resolves when it
// lands: a chip, a playing card, a card face down.
//
// The arc is the point. A straight line between two rects reads as a UI
// transition; a chip that rises, travels and drops reads as a throw. WAAPI does
// this in one keyframe list because it can interpolate a midpoint — a CSS
// transition; something that rises, travels and drops reads as a throw. WAAPI
// does this in one keyframe list because it can interpolate a midpoint — a CSS
// transition can't, which is why this isn't a class toggle.
function fly(from, to, opts) {
//
// The node is yours: build it, hand it over, and it is gone when the promise
// resolves. Nothing in here knows or cares what it was.
function flyNode(node, from, to, opts) {
opts = opts || {};
var a = centre(from);
var b = centre(to);
var el = disc(opts.denom || 25);
el.className += " pete-fly";
stage().appendChild(el);
node.classList.add("pete-fly");
stage().appendChild(node);
var dur = opts.duration || 420;
if (reduced) dur = 1;
@@ -96,23 +107,50 @@
var midX = (a.x + b.x) / 2;
var midY = (a.y + b.y) / 2 - lift;
var spin = opts.spin == null ? jitter(opts.index || 0, 90) : opts.spin;
var s0 = opts.fromScale == null ? 0.85 : opts.fromScale;
var s1 = opts.toScale == null ? 1 : opts.toScale;
// Bigger at the top of the arc than at either end — that swell is what sells
// the thing as coming towards you. Taken off the larger end, so a throw that
// starts small and lands full size still peaks above where it lands, rather
// than averaging itself back down to nothing.
var sMid = Math.max(s0, s1) * 1.12;
var anim = el.animate(
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, 0.85, 0), opacity: 1, offset: 0 },
{ transform: t(midX, midY, 1.12, spin * 0.6), opacity: 1, offset: 0.5 },
{ transform: t(b.x, b.y, 1, spin), opacity: opts.fade ? 0 : 1, offset: 1 },
{ 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
.catch(function () {})
.then(function () {
el.remove();
node.remove();
});
}
// fly throws one chip. It is flyNode with a chip in it, which is what it always
// 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);
}
function t(x, y, scale, rot) {
return "translate(" + x + "px," + y + "px) scale(" + scale + ") rotate(" + rot + "deg)";
}
@@ -144,8 +182,83 @@
return Promise.all(each);
}
// burst: confetti out of a point. Saved for the things worth celebrating.
// A bet spot: the pile of chips sitting on it, and the number printed under
// the pile.
//
// The rule the whole room is built on lives in here, which is why it's one
// object and not two variables on a table: **the number is a readout of the
// pile, never the other way round.** There is no way to change one without the
// other, so a settled game can't leave "your bet: 300" printed over an empty
// circle, and a payout can't be counted before the chips that justify it have
// landed.
//
// els: {spot, stack, total}. Blackjack's spot holds the stake; solitaire's
// holds what you've banked, which grows a card at a time. Same object.
function spot(els) {
var api = {
// What the pile is holding. Written by render, and readable by a table that
// needs to know whether a chip is already on its way down.
amount: 0,
render: function (n) {
api.amount = n || 0;
els.stack.innerHTML = "";
if (els.spot) els.spot.dataset.live = api.amount > 0 ? "1" : "0";
if (!api.amount) {
if (els.total) els.total.classList.add("hidden");
return;
}
chipsFor(api.amount).forEach(function (d, i) {
var c = disc(d);
c.style.setProperty("--i", i);
c.style.setProperty("--spin", jitter(i, 12).toFixed(1) + "deg");
c.style.animationDelay = (reduced ? 0 : i * 40) + "ms";
els.stack.appendChild(c);
});
if (els.total) {
els.total.textContent = api.amount.toLocaleString();
els.total.classList.remove("hidden");
}
},
// pour throws a run of chips onto the spot and grows the pile as each one
// lands — by the value of the chip that landed, so the total under the pile
// counts up the way the chips do. The last chip carries the remainder,
// because chipsFor caps how many chips it will make you watch and the pile
// still has to end on the real number.
pour: function (from, amount, opts) {
if (amount <= 0) return Promise.resolve();
var base = api.amount;
var chips = chipsFor(amount, 8);
var run = 0;
return flyMany(from, els.spot, chips, Object.assign({
onLand: function (d, i) {
run += d;
api.render(base + (i === chips.length - 1 ? amount : run));
},
}, opts || {}));
},
// sweep sends chips off the spot to somewhere else — your pile, or the
// house's rack. The spot is emptied *now* rather than when they land, so
// nothing that is already in the air can be bet a second time.
sweep: function (to, amount, opts) {
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;
},
};
return api;
}
// 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);
@@ -237,11 +350,14 @@
window.PeteFX = {
reduced: reduced,
sfx: sfx, // so a table can make a noise without reaching past this file
chipsFor: chipsFor,
disc: disc,
jitter: jitter,
fly: fly,
flyNode: flyNode,
flyMany: flyMany,
spot: spot,
burst: burst,
count: count,
centre: centre,

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

@@ -62,8 +62,10 @@
}
}
// You cannot cash out mid-hand: the stake is already on the table.
if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.hand;
// You cannot cash out mid-game: the stake is already on the table. `game` is
// set by whichever game you're in, so this holds for any of them — checking
// for a blackjack hand specifically would let you walk out on a hangman.
if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.game;
if (buyBtn) buyBtn.disabled = v.chips + v.pending >= v.cap;
listeners.forEach(function (fn) { fn(v); });
@@ -152,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

@@ -0,0 +1,562 @@
// The hangman table.
//
// Same bargain as the blackjack table: the browser holds no game. It sends a
// letter, and the server answers with the board you're allowed to see — the
// phrase with the letters you haven't earned still blank — plus the script of
// what just happened. The phrase itself only ever arrives once the game is over
// and it no longer decides anything.
//
// The gallows is the meter. It counts your lives down and your winnings down at
// the same time, which is why a miss draws a limb *and* knocks the multiple back
// in the same beat: they are the same event, and showing them as one thing is
// the whole reason to bet on this rather than play it on paper.
(function () {
"use strict";
var root = document.querySelector("[data-hangman]");
if (!root) return;
var FX = window.PeteFX;
var boardEl = root.querySelector("[data-board]");
var gallowsEl = root.querySelector("[data-gallows]");
var wrongEl = root.querySelector("[data-wrong]");
var wrongLbl = root.querySelector("[data-wrong-label]");
var multEl = root.querySelector("[data-multiple]");
var meterEl = root.querySelector("[data-meter]");
var standsEl = root.querySelector("[data-stands]");
var standsLbl = root.querySelector("[data-stands-label]");
var livesEl = root.querySelector("[data-lives]");
var verdictEl = root.querySelector("[data-verdict]");
var betting = root.querySelector("[data-betting]");
var guessing = root.querySelector("[data-guessing]");
var keysEl = root.querySelector("[data-keyboard]");
var betAmount = root.querySelector("[data-bet-amount]");
var startBtn = root.querySelector("[data-start]");
var solveIn = root.querySelector("[data-solve-input]");
var solveBtn = root.querySelector("[data-solve]");
var msgEl = root.querySelector("[data-table-msg]");
var gameMsgEl = root.querySelector("[data-game-msg]");
// The three places a chip can be, exactly as at the other table.
var purseEl = document.querySelector("[data-chips]");
var spotEl = root.querySelector("[data-spot]");
var houseEl = root.querySelector("[data-house]");
// The bet spot, and the rule that comes with it: the number under the pile is a
// readout of the pile, never the other way round.
var spot = FX.spot({
spot: spotEl,
stack: root.querySelector("[data-stack]"),
total: root.querySelector("[data-spot-total]"),
});
var bet = 0; // what you're building between games
var busy = false;
var game = null; // the board as the server last described it
var tier = "medium";
var FLIP_MS = 320;
var MISS_MS = 520;
var reduced = FX.reduced;
function pace(ms) { return reduced ? 0 : ms; }
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
function say(text, tone, where) {
var el = where || msgEl;
if (!el) return;
if (!text) { el.classList.add("hidden"); return; }
el.textContent = text;
el.classList.remove("hidden");
el.style.color = tone === "bad" ? "#cc3d4a" : "";
}
// ---- the board -------------------------------------------------------------
// renderBoard lays the phrase out as tiles. A tile is a letter you have to earn;
// a space or a piece of punctuation is scaffolding and gets no tile, because a
// row of blanks with the word breaks hidden is a puzzle about typography.
//
// Words are kept whole: the board wraps between words, never inside one.
function renderBoard(cells) {
boardEl.innerHTML = "";
if (!cells) return;
var word = document.createElement("div");
word.className = "pete-word";
cells.forEach(function (c, i) {
if (!c.slot && (c.ch === " " || c.ch === "")) {
// A space: end the word and start the next one.
if (word.childNodes.length) boardEl.appendChild(word);
word = document.createElement("div");
word.className = "pete-word";
return;
}
var t = document.createElement("span");
t.className = "pete-tile";
t.dataset.at = String(i);
if (!c.slot) {
t.dataset.punct = "1"; // an exclamation mark is not a blank to fill
t.textContent = c.ch;
} else {
t.dataset.up = c.ch ? "1" : "0";
t.textContent = c.ch || "";
}
word.appendChild(t);
});
if (word.childNodes.length) boardEl.appendChild(word);
}
// turnUp flips the tiles a hit just earned, one after the other so a letter that
// appears three times reads as three finds rather than one repaint.
function turnUp(at, ch) {
if (!at || !at.length) return Promise.resolve();
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
// this one put different text in the same tile.
t.textContent = ch;
t.dataset.up = "1";
t.classList.add("pete-tile-hit");
}, pace(n * 90));
});
FX.sfx("flip");
return wait(FLIP_MS + (at.length - 1) * 90);
}
// ---- the gallows -----------------------------------------------------------
// drawGallows shows the first n parts. Each one draws itself in along its own
// length rather than fading up — the difference between a limb being *drawn* and
// a limb appearing is the whole character of the game.
function drawGallows(n, animateLast) {
var parts = gallowsEl.querySelectorAll("[data-part]");
parts.forEach(function (p, i) {
var on = i < n;
var was = p.dataset.on === "1";
p.dataset.on = on ? "1" : "0";
if (on && !was && animateLast && i === n - 1 && !reduced) {
// Restart the draw-in animation on the part that was just earned.
p.classList.remove("pete-part-draw");
void p.getBoundingClientRect();
p.classList.add("pete-part-draw");
} else if (on && !animateLast) {
p.classList.remove("pete-part-draw");
}
});
}
function shake() {
if (reduced) return;
gallowsEl.classList.remove("pete-shake");
void gallowsEl.getBoundingClientRect();
gallowsEl.classList.add("pete-shake");
}
// ---- the meter -------------------------------------------------------------
function renderMeter(v) {
if (!v) {
multEl.textContent = "—";
standsEl.textContent = "—";
standsLbl.textContent = "if you get it";
livesEl.textContent = "";
meterEl.dataset.cold = "0";
return;
}
multEl.textContent = v.multiple.toFixed(2) + "×";
standsEl.textContent = (v.stands || 0).toLocaleString();
standsLbl.textContent = v.phase === "done" ? "was on it" : "if you get it";
livesEl.textContent = v.lives + (v.lives === 1 ? " life left" : " lives left");
// The meter goes cold once the multiple is down at its floor: from here a win
// hands back the stake and nothing more.
meterEl.dataset.cold = v.multiple <= 1.001 ? "1" : "0";
}
// knock ticks the multiple down to its new value, so the number falls rather
// than simply being different.
function knock(v) {
if (reduced) { renderMeter(v); return; }
var from = parseFloat(multEl.textContent) || v.multiple;
var to = v.multiple;
var t0 = performance.now();
meterEl.dataset.hit = "1";
setTimeout(function () { meterEl.dataset.hit = "0"; }, 400);
(function step(now) {
var p = Math.min(1, (now - t0) / 380);
var eased = 1 - Math.pow(1 - p, 3);
multEl.textContent = (from + (to - from) * eased).toFixed(2) + "×";
if (p < 1) requestAnimationFrame(step);
else renderMeter(v);
})(t0);
}
// ---- the keyboard ----------------------------------------------------------
var ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm", "0123456789"];
function buildKeys() {
keysEl.innerHTML = "";
ROWS.forEach(function (row, r) {
var line = document.createElement("div");
line.className = "pete-key-row";
if (r === 3) line.dataset.digits = "1";
row.split("").forEach(function (ch) {
var b = document.createElement("button");
b.type = "button";
b.className = "pete-key";
b.dataset.key = ch;
b.textContent = ch.toUpperCase();
b.addEventListener("click", function () { guessLetter(ch); });
line.appendChild(b);
});
keysEl.appendChild(line);
});
}
// paintKeys marks every letter that's been tried, and how it went. A key that
// has been spent should look spent — otherwise you spend it again.
function paintKeys(v) {
var tried = (v && v.tried) || [];
var wrong = (v && v.wrong) || [];
keysEl.querySelectorAll(".pete-key").forEach(function (b) {
var ch = b.dataset.key;
var used = tried.indexOf(ch) !== -1;
b.disabled = used || !v || v.phase !== "playing";
b.dataset.state = !used ? "" : wrong.indexOf(ch) !== -1 ? "miss" : "hit";
});
}
function renderWrong(v) {
wrongEl.innerHTML = "";
var wrong = (v && v.wrong) || [];
// A wrong *solve* is recorded as a miss with no letter on it — it cost a life
// and it's on the gallows, but there's no key to grey out for it.
var letters = wrong.filter(function (c) { return c !== "·"; });
wrongLbl.classList.toggle("hidden", letters.length === 0);
letters.forEach(function (ch) {
var s = document.createElement("span");
s.className = "pete-missed";
s.textContent = ch.toUpperCase();
wrongEl.appendChild(s);
});
}
// ---- the money on the felt -------------------------------------------------
// The spot is PeteFX's, the same one every other table in the room bets onto: a
// chip has to behave the same way in both rooms or it isn't a chip, it's a
// widget.
function stake(amount, from) {
return spot.pour(from || purseEl, amount);
}
function settleChips(final) {
var payout = final.payout || 0;
var back = payout - final.bet;
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 });
}
// Paid into the spot beside your stake, then the whole lot swept home.
return spot
.pour(houseEl, back, { gap: 60 })
.then(function () { return wait(back > 0 ? 380 : 200); })
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
}
// ---- phases ----------------------------------------------------------------
var VERDICTS = {
solved: "Got it! 🎉",
filled: "That's the lot!",
hung: "Hung.",
};
function verdict(v) {
var text = VERDICTS[v.outcome] || "";
if (!text) { verdictEl.classList.add("hidden"); return; }
if (v.outcome === "hung" && v.phrase) text = "Hung. It was “" + v.phrase + "”.";
if (v.net > 0) text += " +" + v.net.toLocaleString();
else if (v.net < 0) text += " " + v.net.toLocaleString();
verdictEl.textContent = text;
verdictEl.classList.remove("hidden");
// 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) {
game = v;
var live = !!v && v.phase === "playing";
betting.classList.toggle("hidden", live);
guessing.classList.toggle("hidden", !live);
paintKeys(v);
if (!live && solveIn) solveIn.value = "";
if (!v || !v.outcome) verdictEl.classList.add("hidden");
}
// paint puts a board up with no animation: the resume path, after a reload or a
// redeploy. Your stake is still on the spot because the server still has it.
function paint(v) {
if (!v) {
renderBoard(null);
drawGallows(0, false);
renderWrong(null);
renderMeter(null);
spot.render(0);
setPhase(null);
return;
}
renderBoard(v.cells);
drawGallows((v.wrong || []).length, false);
renderWrong(v);
renderMeter(v);
spot.render(v.phase === "done" ? 0 : v.bet);
setPhase(v);
}
// ---- the script ------------------------------------------------------------
// play walks the server's events. Same rule as the other table: on a live game
// the money is already right (your stake left your pile when you pressed Play,
// and it's on the spot), but on a settling one the chip bar is held back until
// the chips have physically come home.
function play(view, money) {
var events = view.hang_events || [];
var final = view.hangman;
var settles = !!final && final.phase === "done";
var chain = Promise.resolve();
if (!settles) money();
if (final && final.bet > spot.amount) {
var extra = final.bet - spot.amount;
chain = chain.then(function () { return stake(extra); });
}
events.forEach(function (e) {
chain = chain.then(function () {
switch (e.kind) {
case "start":
verdictEl.classList.add("hidden");
renderBoard(final && final.cells);
drawGallows(0, false);
renderWrong(null);
renderMeter(final);
return;
case "hit":
return turnUp(e.at, e.letter);
case "miss":
// The limb, the shake and the multiple falling are one event, because
// 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);
case "solve":
return wait(220);
case "settle":
return;
}
});
});
return chain.then(function () {
if (!final) { paint(null); money(); return; }
renderWrong(final);
if (!settles) {
renderMeter(final);
setPhase(final);
return;
}
// Over: the board goes fully face up (the server has finally sent the
// phrase), then the money moves, and only then does the bar catch up.
guessing.classList.add("hidden");
renderBoard(final.cells);
renderMeter(final);
verdict(final);
return settleChips(final)
.then(money)
.then(function () { return standing(final.bet); })
.then(function () { setPhase(final); });
});
}
// countMisses works out how many limbs should be on the gallows by the time
// this miss has been played — the misses already on the board when the request
// went out, plus every miss in this batch up to and including this one.
function countMisses(events, upTo) {
var before = game ? (game.wrong || []).length : 0;
var n = 0;
for (var i = 0; i < events.length; i++) {
if (events[i].kind === "miss") n++;
if (events[i] === upTo) break;
}
return before + n;
}
// standing puts the stake back on the spot for the next phrase, the way the
// blackjack table leaves your bet up.
function standing(amount) {
var money = window.PeteGames.view();
if (!amount || !money || money.chips < amount) {
bet = 0;
showBet();
return;
}
bet = amount;
showBet();
return stake(amount);
}
// ---- talking to the table ---------------------------------------------------
function send(path, body, where) {
if (busy) return;
busy = true;
say("", null, where);
return window.PeteGames.post(path, body)
.then(function (view) {
return play(view, function () { window.PeteGames.apply(view); });
})
.catch(function (err) {
say(err.message, "bad", where);
return window.PeteGames.refresh().then(function (v) {
if (v && !v.hangman) spot.render(0);
});
})
.then(function () { busy = false; });
}
function guessLetter(ch) {
if (busy || !game || game.phase !== "playing") return;
if ((game.tried || []).indexOf(ch) !== -1) return;
send("/api/games/hangman/guess", { letter: ch }, gameMsgEl);
}
// ---- betting ----------------------------------------------------------------
function showBet() {
betAmount.textContent = bet.toLocaleString();
var money = window.PeteGames.view();
if (startBtn) startBtn.disabled = bet <= 0 || !money || money.chips < bet;
}
function pickTier(slug) {
tier = slug;
root.querySelectorAll("[data-tier]").forEach(function (b) {
b.dataset.on = b.dataset.tier === slug ? "1" : "0";
});
}
root.querySelectorAll("[data-tier]").forEach(function (b) {
b.addEventListener("click", function () {
if (busy) return;
pickTier(b.dataset.tier);
});
});
// Scoped to buttons: the bare [data-chip] spans in the corner are the house's
// rack, and the house is not betting.
root.querySelectorAll("button[data-chip]").forEach(function (btn) {
btn.addEventListener("click", function () {
if (busy) return;
var d = parseInt(btn.dataset.chip, 10);
var money = window.PeteGames.view();
if (money && bet + d > money.chips) {
say("You haven't got that many chips.", "bad");
return;
}
bet += d;
showBet();
var target = bet;
spot.amount = bet;
FX.fly(btn, spotEl, { denom: d }).then(function () {
if (bet >= target) spot.render(target); // unless Clear got there first
});
});
});
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 });
bet = 0;
showBet();
});
}
if (startBtn) {
startBtn.addEventListener("click", function () {
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
send("/api/games/hangman/start", { bet: bet, tier: tier });
});
}
function solve() {
if (busy || !game || game.phase !== "playing") return;
var attempt = (solveIn.value || "").trim();
if (!attempt) { say("Say what it is, then.", "bad", gameMsgEl); return; }
send("/api/games/hangman/guess", { solve: attempt }, gameMsgEl);
}
if (solveBtn) solveBtn.addEventListener("click", solve);
if (solveIn) {
solveIn.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); solve(); }
});
}
// Type a letter to guess it — but not while you're typing a solution into the
// box, which is the whole reason this checks what has focus.
document.addEventListener("keydown", function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
if (!game || game.phase !== "playing" || busy) return;
var ch = (e.key || "").toLowerCase();
if (!/^[a-z0-9]$/.test(ch)) return;
e.preventDefault();
guessLetter(ch);
});
buildKeys();
pickTier(tier);
var resumed = false;
window.PeteGames.onUpdate(function (v) {
if (!resumed) {
resumed = true;
if (v.hangman) {
paint(v.hangman);
if (v.hangman.phase === "done") verdict(v.hangman);
} else {
paint(null);
}
}
showBet();
});
})();

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

@@ -0,0 +1,763 @@
// The solitaire table.
//
// Blackjack plays back a *script*: the server sends one event per card off the
// shoe and the table deals them out in order. That works because a blackjack hand
// only ever grows at one end. Solitaire doesn't: a move takes a run from anywhere
// and puts it anywhere, an auto-finish moves eleven cards at once, and a single
// move can turn a card over three columns away. A script of "append this card
// there" would be a second engine over here, and it would be the one that's wrong.
//
// So this table re-renders the whole board from the server's view after every
// move, and then animates the difference — FLIP: measure where every card *was*,
// re-render, measure where it *is*, and play each card from its old place to its
// new one. The board on screen is therefore always exactly the board the server
// says exists, and the animation is derived from it rather than the other way
// round. The events are still used, for the two things a diff genuinely cannot
// tell you: where a newly-revealed card came from (out of the stock, or turned
// over in place), and what the board is now worth.
//
// The money follows the same rule as every other table in the room: nothing about
// it changes without a chip crossing the felt to make it change. Here the stake
// buys the deck — it goes to the house and does not come back — and the spot in
// the rail holds what you've *banked*, which grows by one card's worth every time
// a card reaches a foundation and shrinks again if you take one back off.
(function () {
"use strict";
var root = document.querySelector("[data-solitaire]");
if (!root) return;
var FX = window.PeteFX;
var CARDS = window.PeteCards;
var stockEl = root.querySelector("[data-stock]");
var stockCountEl = root.querySelector("[data-stock-count]");
var stockRecycleEl = root.querySelector("[data-stock-recycle]");
var wasteEl = root.querySelector("[data-waste]");
var foundEl = root.querySelector("[data-foundations]");
var tableauEl = root.querySelector("[data-tableau]");
var verdictEl = root.querySelector("[data-verdict]");
var homeEl = root.querySelector("[data-home]");
var perCardEl = root.querySelector("[data-per-card]");
var breakEvenEl = root.querySelector("[data-break-even]");
var meterEl = root.querySelector("[data-meter]");
var playing = root.querySelector("[data-playing]");
var betting = root.querySelector("[data-betting]");
var autoBtn = root.querySelector("[data-auto]");
var cashBtn = root.querySelector("[data-cash]");
var cashAmountEl = root.querySelector("[data-cash-amount]");
var startBtn = root.querySelector("[data-start]");
var betAmountEl = root.querySelector("[data-bet-amount]");
var gameMsg = root.querySelector("[data-game-msg]");
var tableMsg = root.querySelector("[data-table-msg]");
var purseEl = document.querySelector("[data-chips]");
var spotEl = root.querySelector("[data-spot]");
var houseEl = root.querySelector("[data-house]");
// The spot in the rail. On this table it holds what you've banked, not a bet.
var spot = FX.spot({
spot: spotEl,
stack: root.querySelector("[data-stack]"),
total: root.querySelector("[data-spot-total]"),
});
var FULL = 52;
var MOVE_MS = 300; // one card's journey across the board
var STEP_MS = 95; // the gap between two cards in a cascade
var reduced = FX.reduced;
var bet = 0; // the deck you're building the price of
var tier = null; // which deal
var busy = false; // a request is in flight, or cards are still moving
var board = null; // the board as the server last described it
var held = null; // {pile, count, cards} — the run in your hand
function wait(ms) {
return new Promise(function (r) { setTimeout(r, reduced ? 0 : ms); });
}
function say(el, text, tone) {
if (!text) { el.classList.add("hidden"); return; }
el.textContent = text;
el.classList.remove("hidden");
el.style.color = tone === "bad" ? "#cc3d4a" : "";
}
// ---- the rules, as the *browser* understands them --------------------------
//
// These mirror the engine, and they are not the engine: the server decides every
// move and will refuse one this file thought was fine. They exist because you
// cannot light up the columns a card can go to without knowing which those are,
// and a table that makes you find that out by being told no is a table that
// teaches Klondike by refusal. When the two disagree the server wins and the
// board snaps back to whatever it says — see send().
var RANKS = { A: 1, J: 11, Q: 12, K: 13 };
function rank(c) { return RANKS[c.rank] || parseInt(c.rank, 10); }
// isRun: descending by one, alternating colour — the only thing you may lift
// off a column as a block.
function isRun(cs) {
for (var i = 1; i < cs.length; i++) {
if (rank(cs[i]) !== rank(cs[i - 1]) - 1 || cs[i].red === cs[i - 1].red) return false;
}
return true;
}
// accepts: what may be put where. A foundation takes its own suit in order from
// the ace; a column takes a run that descends and alternates from its top card,
// and an empty column takes a King and nothing else.
function accepts(pile, cs) {
if (!board || !cs || !cs.length) return false;
if (pile.charAt(0) === "f") {
var f = board.found[parseInt(pile.slice(1), 10)];
return !!f && cs.length === 1 && cs[0].suit === f.suit && rank(cs[0]) === f.n + 1;
}
var col = board.table[parseInt(pile.slice(1), 10)];
if (!col || !isRun(cs)) return false;
if (!col.up || !col.up.length) return col.down === 0 && rank(cs[0]) === 13;
var top = col.up[col.up.length - 1];
return rank(cs[0]) === rank(top) - 1 && cs[0].red !== top.red;
}
// cardsAt is the run you'd be picking up by clicking this card.
function cardsAt(pile, idx) {
if (!board) return null;
if (pile === "waste") {
return board.waste && board.waste.length ? [board.waste[board.waste.length - 1]] : null;
}
if (pile.charAt(0) === "f") {
var f = board.found[parseInt(pile.slice(1), 10)];
return f && f.top ? [f.top] : null;
}
var col = board.table[parseInt(pile.slice(1), 10)];
if (!col || !col.up || idx >= col.up.length) return null;
return col.up.slice(idx);
}
// ---- drawing the board -----------------------------------------------------
// face builds a card element wired up for clicking. No deal flight and no tilt:
// this table animates its own cards from wherever they actually came from, and a
// column of thirteen tilted cards overlapping by an eighth of an inch reads as a
// mistake rather than as a hand that was handled.
function face(c, pile, idx) {
var el = CARDS.el(c, { deal: false, tilt: false });
el.dataset.pile = pile;
el.dataset.idx = String(idx);
return el;
}
function slot(pile, glyph, red) {
var el = document.createElement("div");
el.className = "pete-slot";
el.dataset.pile = pile;
if (glyph) {
el.innerHTML = '<span class="pete-slot-glyph" data-red="' + (red ? "1" : "0") + '">' + glyph + "</span>";
}
return el;
}
function render(v) {
board = v;
if (!v) {
wasteEl.innerHTML = "";
foundEl.innerHTML = "";
tableauEl.innerHTML = "";
stockEl.dataset.dead = "1";
stockCountEl.classList.add("hidden");
stockRecycleEl.classList.add("hidden");
meter(null);
return;
}
// The stock. Empty with a pass left is not the same thing as empty with none:
// one is a gesture you can still make and the other is a dead pile, and the
// difference is worth showing rather than leaving to a click that gets refused.
var canRecycle = v.stock === 0 && v.waste_n > 0 && (v.passes < 0 || v.passes > 1);
stockEl.dataset.empty = v.stock === 0 ? "1" : "0";
stockEl.dataset.dead = v.stock === 0 && !canRecycle ? "1" : "0";
stockCountEl.textContent = String(v.stock);
stockCountEl.classList.toggle("hidden", v.stock === 0);
stockRecycleEl.classList.toggle("hidden", !canRecycle);
// The waste: the last three, fanned, and only the top one is yours to take.
wasteEl.innerHTML = "";
(v.waste || []).forEach(function (c, i, all) {
var el = face(c, "waste", i);
if (i === all.length - 1) el.dataset.live = "1";
wasteEl.appendChild(el);
});
// The foundations. Each is a slot that stays put whether or not there's a card
// on it, so the board doesn't reflow the moment an ace goes home — and so a
// drop target has somewhere to be even when it's empty.
foundEl.innerHTML = "";
v.found.forEach(function (f, i) {
var s = slot("f" + i, f.suit, f.red);
if (f.top) {
var el = face(f.top, "f" + i, 0);
el.dataset.live = "1";
s.appendChild(el);
}
foundEl.appendChild(s);
});
// The seven columns.
tableauEl.innerHTML = "";
v.table.forEach(function (col, i) {
var c = document.createElement("div");
c.className = "pete-col";
c.dataset.pile = "t" + i;
if (!col.down && !(col.up && col.up.length)) {
c.appendChild(slot("t" + i));
}
for (var d = 0; d < col.down; d++) {
c.appendChild(CARDS.el(null, { deal: false, tilt: false }));
}
(col.up || []).forEach(function (card, j) {
var el = face(card, "t" + i, j);
// A card is pickable if the run from it down is a run. Anything else is a
// card you can see and can't lift, and it shouldn't offer.
if (isRun(col.up.slice(j))) el.dataset.live = "1";
c.appendChild(el);
});
tableauEl.appendChild(c);
});
meter(v);
controls(v);
if (held) mark(); // a selection survives a re-render, so redraw what it lit
}
// meter is what the board is worth. Every number in it comes off the server —
// this file does no arithmetic about money, which is the point.
function meter(v) {
if (!v) {
homeEl.innerHTML = '0<span class="text-white/40">/' + FULL + "</span>";
perCardEl.textContent = "—";
breakEvenEl.textContent = "";
return;
}
homeEl.innerHTML = v.home + '<span class="text-white/40">/' + FULL + "</span>";
perCardEl.textContent = "+" + v.per_card.toFixed(1);
breakEvenEl.textContent =
v.home >= v.break_even
? "You're ahead of the house"
: v.break_even - v.home + " more to break even";
meterEl.dataset.cold = v.home === 0 ? "1" : "0";
}
function controls(v) {
var live = !!v && v.phase === "playing";
playing.classList.toggle("hidden", !live);
betting.classList.toggle("hidden", live);
if (!live) return;
autoBtn.disabled = !v.can_auto;
cashAmountEl.textContent = (v.stands || 0).toLocaleString();
}
// ---- FLIP ------------------------------------------------------------------
//
// Where every card is, right now. One deck, so a card's label is its identity.
function snapshot() {
var map = {};
root.querySelectorAll(".pete-card[data-key]").forEach(function (el) {
map[el.dataset.key] = el.getBoundingClientRect();
});
map["#stock"] = stockEl.getBoundingClientRect();
return map;
}
// plan reads the events for the two things a before/after diff can't tell you:
// where a card that is *new to the board* came from, and how much of a beat to
// leave before it moves, so that an auto-finish cascades rather than teleporting.
function planOf(events) {
var origins = {}, delays = {}, at = 0;
(events || []).forEach(function (e) {
(e.cards || []).forEach(function (c) {
if (e.kind === "draw") origins[c.label] = "draw";
if (e.kind === "flip") origins[c.label] = "flip";
delays[c.label] = at;
});
if (e.kind === "move" || e.kind === "home") at += STEP_MS;
});
return { origins: origins, delays: delays };
}
// animate plays every card from where it was to where it is.
function animate(before, plan) {
if (reduced) return Promise.resolve();
var waits = [];
root.querySelectorAll(".pete-card[data-key]").forEach(function (el) {
var key = el.dataset.key;
var now = el.getBoundingClientRect();
var was = before[key];
var delay = plan.delays[key] || 0;
var origin = plan.origins[key];
// A card that was already on the board: play it from its old place. This is
// every ordinary move, and it is also what makes an eleven-card auto-finish
// animate itself for free.
if (was && !origin) {
var dx = was.left - now.left;
var dy = was.top - now.top;
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;
waits.push(slide(el, dx, dy, delay));
return;
}
// A card that has just been turned over. Out of the stock it flies as well as
// turns; in a column it turns where it lies.
if (origin === "draw") {
var from = before["#stock"];
waits.push(slide(el, from.left - now.left, from.top - now.top, delay));
waits.push(turn(el, delay));
} else if (origin === "flip") {
waits.push(turn(el, delay));
}
});
return Promise.all(waits);
}
function slide(el, dx, dy, delay) {
return el.animate(
[{ transform: "translate(" + dx + "px," + dy + "px)" }, { transform: "none" }],
{
duration: MOVE_MS,
delay: delay,
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
fill: "backwards",
}
).finished.catch(noop);
}
// The card turns over on its own axis. The wrapper is doing the travelling, so
// this has to be the inner face or the two transforms would fight.
function turn(el, delay) {
var inner = el.querySelector(".pete-card-inner");
return inner.animate(
[{ transform: "rotateY(180deg)" }, { transform: "rotateY(0deg)" }],
{ duration: MOVE_MS, delay: delay, easing: "cubic-bezier(0.4, 0, 0.2, 1)", fill: "backwards" }
).finished.catch(noop);
}
// The recycle is the one move where cards *leave* the board, so FLIP has nothing
// to animate: they're gone from the new render before it can measure them. They
// get their flight here instead, out of the old DOM, before it's replaced.
function recycleOut() {
if (reduced) return Promise.resolve();
var to = stockEl.getBoundingClientRect();
var waits = [];
wasteEl.querySelectorAll(".pete-card").forEach(function (el, i) {
var now = el.getBoundingClientRect();
waits.push(
el.animate(
[
{ transform: "none", opacity: 1 },
{ transform: "translate(" + (to.left - now.left) + "px," + (to.top - now.top) + "px) rotateY(180deg)", opacity: 1 },
],
{ duration: 260, delay: i * 50, easing: "cubic-bezier(0.4, 0, 1, 1)", fill: "forwards" }
).finished.catch(noop)
);
});
return Promise.all(waits);
}
function noop() {}
// A card reaching a foundation is the only move in this game that pays you, so
// it's the only one that makes a noise about it.
function flashHome(events) {
if (reduced) return;
var at = 0;
(events || []).forEach(function (e) {
if (e.kind !== "home" || !e.to) {
if (e.kind === "move") at += STEP_MS;
return;
}
var when = at + MOVE_MS;
at += STEP_MS;
setTimeout(function () {
var pile = foundEl.querySelector('[data-pile="' + e.to + '"]');
if (!pile) return;
pile.classList.remove("pete-home-flash");
void pile.offsetWidth; // restart the animation if it's still running
pile.classList.add("pete-home-flash");
}, when);
});
}
// 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
// out of the house's rack; down — a card taken back off a foundation — is chips
// going back to it. Either way the pile moves before the number does.
function bank(pays) {
var delta = (pays || 0) - spot.amount;
if (delta === 0) return Promise.resolve();
if (delta > 0) return spot.pour(houseEl, delta, { gap: 55 });
return spot.sweep(houseEl, -delta, { gap: 40, lift: 0.6, fade: true });
}
// ---- picking cards up ------------------------------------------------------
function mark() {
root.querySelectorAll('[data-held="1"]').forEach(function (el) { delete el.dataset.held; });
root.querySelectorAll('[data-drop="1"]').forEach(function (el) { delete el.dataset.drop; });
if (!held) return;
// The run in your hand lifts off the felt.
root.querySelectorAll('.pete-card[data-pile="' + held.pile + '"]').forEach(function (el) {
if (held.pile === "waste" || held.pile.charAt(0) === "f") {
if (el.dataset.live === "1") el.dataset.held = "1";
} else if (parseInt(el.dataset.idx, 10) >= held.idx) {
el.dataset.held = "1";
}
});
// And everywhere it could go lights up. This is the whole reason the rules are
// mirrored over here: being shown where a card goes is the game teaching you,
// and being told no after you commit is the game scolding you.
root.querySelectorAll("[data-pile]").forEach(function (el) {
var pile = el.dataset.pile;
if (pile === held.pile || pile === "waste" || el.classList.contains("pete-card")) return;
if (accepts(pile, held.cards)) el.dataset.drop = "1";
});
}
function pick(pile, idx) {
var cs = cardsAt(pile, idx);
if (!cs || !isRun(cs)) return;
held = { pile: pile, idx: idx, count: cs.length, cards: cs };
mark();
}
function drop() {
held = null;
mark();
}
function nope(el) {
if (!el || reduced) return;
el.classList.remove("pete-nope");
void el.offsetWidth;
el.classList.add("pete-nope");
}
// A click on the board. The order matters: if something is in your hand and the
// thing you clicked will take it, that's a move — otherwise it's you picking up
// something else. Which means you never have to put a card down before choosing
// a different one.
root.querySelector(".pete-felt").addEventListener("click", function (e) {
if (busy || !board || board.phase !== "playing") return;
if (e.target.closest("[data-stock]")) return; // the stock has its own handler
var pileEl = e.target.closest("[data-pile]");
if (!pileEl) { drop(); return; }
var cardEl = e.target.closest(".pete-card[data-key]");
var pile = pileEl.dataset.pile;
if (held) {
if (pile === held.pile) { drop(); return; } // clicking your own run puts it down
if (accepts(pile, held.cards)) {
var move = { kind: "move", from: held.pile, to: pile, count: held.count };
drop();
send(move);
return;
}
// Not a place it goes. If what you clicked is a card you *could* pick up,
// this was a change of mind rather than a bad move; only shake at a genuine
// dead end.
if (!cardEl || cardEl.dataset.live !== "1") { nope(pileEl); return; }
}
if (cardEl && cardEl.dataset.live === "1") {
pick(pile, parseInt(cardEl.dataset.idx, 10));
} else {
drop();
}
});
// Double-click sends a card home. It's the idiom every solitaire has used for
// thirty years, and the alternative is asking the player which foundation — a
// question with exactly one right answer.
root.addEventListener("dblclick", function (e) {
if (busy || !board || board.phase !== "playing") return;
var cardEl = e.target.closest('.pete-card[data-live="1"]');
if (!cardEl) return;
var pile = cardEl.dataset.pile;
if (pile.charAt(0) === "f") return; // it's already home
e.preventDefault();
drop();
send({ kind: "home", from: pile });
});
stockEl.addEventListener("click", function () {
if (busy || !board || board.phase !== "playing") return;
if (stockEl.dataset.dead === "1") {
say(gameMsg, "That was your last pass through the stock.", "bad");
nope(stockEl);
return;
}
drop();
send({ kind: "draw" });
});
autoBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
cashBtn.addEventListener("click", function () {
drop();
send({ kind: "concede" });
});
document.addEventListener("keydown", function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
if (e.key === "Escape") { drop(); return; }
if (busy || !board || board.phase !== "playing") return;
var k = e.key.toLowerCase();
if (k === " " || k === "d") { e.preventDefault(); stockEl.click(); }
else if (k === "a" && !autoBtn.disabled) { e.preventDefault(); autoBtn.click(); }
});
// ---- talking to the table --------------------------------------------------
var VERDICTS = {
cleared: "Cleared the board! 🎉",
cashed: "Board cashed.",
};
function verdict(v) {
var text = VERDICTS[v.outcome] || "";
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");
// 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
// cards animate from where they were, and the chips follow.
//
// `money` — the chip bar catching up — is held back on a *settling* board until
// the payout has physically swept home. A counter that pays you before the chips
// arrive is a counter that has told you the ending.
function play(view, money) {
var v = view.solitaire;
var events = view.sol_events || [];
var settles = !!v && v.phase === "done";
var recycled = events.some(function (e) { return e.kind === "recycle"; });
var pre = recycled ? recycleOut() : Promise.resolve();
return pre
.then(function () {
var before = snapshot();
render(v);
var plan = planOf(events);
flashHome(events);
noises(events);
return animate(before, plan);
})
.then(function () {
if (!v) { money(); return; }
return bank(v.stands).then(function () {
if (!settles) { money(); return; }
// The board is finished. Everything banked comes home, and only then
// does the number in the bar move.
verdict(v);
return wait(300)
.then(function () { return spot.sweep(purseEl, v.payout, { gap: 40, lift: 0.8 }); })
.then(money)
.then(function () { controls(null); showBet(); });
});
});
}
function send(move) {
if (busy) return;
busy = true;
say(gameMsg, "");
return window.PeteGames.post("/api/games/solitaire/move", move)
.then(function (view) { return play(view, function () { window.PeteGames.apply(view); }); })
.catch(function (err) {
say(gameMsg, err.message, "bad");
// Whatever this file thought the board was, the server is the authority on
// it. Ask, and draw what it says.
return window.PeteGames.refresh().then(function (v) {
if (v) { render(v.solitaire || null); spot.render(v.solitaire ? v.solitaire.stands : 0); }
});
})
.then(function () { busy = false; });
}
// ---- buying a deck ---------------------------------------------------------
function showBet() {
betAmountEl.textContent = bet.toLocaleString();
var money = window.PeteGames.view();
startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet;
}
root.querySelectorAll("[data-tier]").forEach(function (btn) {
btn.addEventListener("click", function () {
tier = btn.dataset.tier;
root.querySelectorAll("[data-tier]").forEach(function (b) {
b.dataset.on = b === btn ? "1" : "0";
});
showBet();
});
});
// The chip you click is the chip that flies. It lands on the spot in the rail,
// which is where the price of the deck is stacked up before you pay it.
root.querySelectorAll("[data-chip]").forEach(function (btn) {
if (!btn.dataset.chip || btn.tagName !== "BUTTON") return;
btn.addEventListener("click", function () {
if (busy) return;
var d = parseInt(btn.dataset.chip, 10);
var money = window.PeteGames.view();
if (money && bet + d > money.chips) {
say(tableMsg, "You haven't got that many chips.", "bad");
return;
}
bet += d;
showBet();
var target = bet;
spot.amount = bet;
FX.fly(btn, spotEl, { denom: d }).then(function () {
if (bet >= target) spot.render(target); // unless Clear got there first
});
});
});
root.querySelector("[data-bet-clear]").addEventListener("click", function () {
if (busy) return;
if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
bet = 0;
showBet();
});
startBtn.addEventListener("click", function () {
if (busy) return;
if (!tier) { say(tableMsg, "Pick a deal first.", "bad"); return; }
if (bet <= 0) { say(tableMsg, "Put something down for the deck.", "bad"); return; }
busy = true;
say(tableMsg, "");
var price = bet;
window.PeteGames.post("/api/games/solitaire/start", { bet: price, tier: tier })
.then(function (view) {
// The deck is *bought*: the chips on the spot go across to the house and
// do not come back. Then the spot starts again at nothing, and from here
// on it holds what the board has earned back.
return spot
.sweep(houseEl, price, { gap: 45, lift: 0.6 })
.then(function () {
bet = 0;
showBet();
window.PeteGames.apply(view);
return dealOut(view.solitaire);
});
})
.catch(function (err) {
say(tableMsg, err.message, "bad");
return window.PeteGames.refresh();
})
.then(function () { busy = false; });
});
// dealOut lays the board and flies every card onto it out of the stock, one at a
// time, across the columns — the way a deal actually goes down.
function dealOut(v) {
render(v);
if (reduced || !v) return Promise.resolve();
var from = stockEl.getBoundingClientRect();
var waits = [];
// The order a real deal goes in: one card to each column, then round again,
// starting a column further along each time.
var order = 0;
for (var row = 0; row < 7; row++) {
for (var col = row; col < 7; col++) {
var colEl = tableauEl.children[col];
var cardEl = colEl.children[row];
if (!cardEl) continue;
var now = cardEl.getBoundingClientRect();
waits.push(slide(cardEl, from.left - now.left, from.top - now.top, order * 34));
if (cardEl.dataset.face === "up") waits.push(turn(cardEl, order * 34));
order++;
}
}
return Promise.all(waits);
}
// ---- coming in ------------------------------------------------------------
// The money bar owns the first fetch. The table picks up whatever it found —
// including a board left mid-game by a reload or a redeploy, which comes back
// exactly as it was, right down to what it has banked.
var resumed = false;
window.PeteGames.onUpdate(function (v) {
if (!resumed) {
resumed = true;
if (v.solitaire) {
render(v.solitaire);
spot.render(v.solitaire.stands);
} else {
controls(null);
}
}
showBet();
});
})();

View File

@@ -0,0 +1,563 @@
// The trivia table.
//
// Same bargain as every other table in the room: the browser holds no game. It
// sends an answer, and the server says how it went. The four buttons arrive
// without any mark on which of them is right — that index is in the engine
// state, on the server — and the reveal only comes back in the event that
// decides the question, by which point knowing it is worth nothing.
//
// The countdown here is decoration, and it is important to be clear about that.
// Nothing it does scores anything: the server timed the answer the moment it
// arrived, against the clock it started when it served the question. Stopping
// this bar, or reloading to restart it, changes nothing at all. What the bar
// owes the player is *honesty* — so it is seeded from the seconds the server
// says are left, not from when the browser got round to painting.
(function () {
"use strict";
var root = document.querySelector("[data-trivia]");
if (!root) return;
var FX = window.PeteFX;
var questionEl = root.querySelector("[data-question]");
var categoryEl = root.querySelector("[data-category]");
var answersEl = root.querySelector("[data-answers]");
var clockEl = root.querySelector("[data-clock]");
var clockFillEl = root.querySelector("[data-clock-fill]");
var countdownEl = root.querySelector("[data-countdown]");
var ladderEl = root.querySelector("[data-ladder]");
var multEl = root.querySelector("[data-multiple]");
var meterEl = root.querySelector("[data-meter]");
var standsEl = root.querySelector("[data-stands]");
var standsLbl = root.querySelector("[data-stands-label]");
var rungEl = root.querySelector("[data-rung]");
var verdictEl = root.querySelector("[data-verdict]");
var betting = root.querySelector("[data-betting]");
var playing = root.querySelector("[data-playing]");
var walkBtn = root.querySelector("[data-walk]");
var walkAmtEl = root.querySelector("[data-walk-amount]");
var betAmount = root.querySelector("[data-bet-amount]");
var startBtn = root.querySelector("[data-start]");
var msgEl = root.querySelector("[data-table-msg]");
var gameMsgEl = root.querySelector("[data-game-msg]");
var purseEl = document.querySelector("[data-chips]");
var spotEl = root.querySelector("[data-spot]");
var houseEl = root.querySelector("[data-house]");
// The bet spot, and the rule that comes with it: the number under the pile is
// a readout of the pile, never the other way round.
var spot = FX.spot({
spot: spotEl,
stack: root.querySelector("[data-stack]"),
total: root.querySelector("[data-spot-total]"),
});
var bet = 0; // what you're building between games
var busy = false;
var game = null; // the round as the server last described it
var tier = "medium";
var reduced = FX.reduced;
function pace(ms) { return reduced ? 0 : ms; }
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
function say(text, tone, where) {
var el = where || msgEl;
if (!el) return;
if (!text) { el.classList.add("hidden"); return; }
el.textContent = text;
el.classList.remove("hidden");
el.style.color = tone === "bad" ? "#cc3d4a" : "";
}
// ---- the clock -------------------------------------------------------------
var raf = null;
var clockDeadline = 0; // performance.now() ms at which this question dies
var clockLimit = 1;
var timedOut = false;
// HOT is when the bar stops being a progress meter and starts being a warning.
var HOT = 5;
// GRACE is how long past its own zero the browser waits before reporting the
// timeout. It cannot be negative: the browser's countdown starts when the
// response *arrives*, so it is already behind the server's by the latency of
// that response, and it therefore always reaches zero after the server has.
// The grace is only there so that "always" survives a rounding error.
var GRACE = 400;
function stopClock() {
if (raf) cancelAnimationFrame(raf);
raf = null;
}
function startClock(left, limit) {
stopClock();
timedOut = false;
clockLimit = limit > 0 ? limit : 1;
clockDeadline = performance.now() + left * 1000;
tick();
}
function tick() {
var left = (clockDeadline - performance.now()) / 1000;
if (left < 0) left = 0;
// A transform, not a width: the browser can run this one without laying the
// page out again on every frame.
clockFillEl.style.transform = "scaleX(" + (left / clockLimit).toFixed(4) + ")";
countdownEl.textContent = left.toFixed(1) + "s";
clockEl.dataset.hot = left <= HOT && left > 0 ? "1" : "0";
if (left <= 0) {
countdownEl.textContent = "0.0s";
clockEl.dataset.hot = "0";
timeUp();
return;
}
raf = requestAnimationFrame(tick);
}
// timeUp reports the clock running out. It is not the browser *deciding* the
// question — it is the browser telling the server the player never answered,
// and the server (which has been holding the real clock all along) agreeing.
function timeUp() {
stopClock();
if (timedOut || !game || game.phase !== "playing") return;
// A move is already in flight. Come back rather than give up: this fires when
// the server has rejected our last timeout report (its clock hadn't run out
// yet) and the refresh has re-armed a countdown that is already at zero. Give
// up here and the clock sits frozen at 0.0s and the question never resolves.
if (busy) { setTimeout(timeUp, 250); return; }
timedOut = true;
lockAnswers();
setTimeout(function () {
// -1 is "no answer". The engine checks the clock before it checks the
// choice, so this resolves as the timeout it is rather than a bad move.
send("/api/games/trivia/answer", { choice: -1 }, gameMsgEl);
}, GRACE);
}
function clearClock() {
stopClock();
clockFillEl.style.transform = "scaleX(0)";
countdownEl.textContent = "";
clockEl.dataset.hot = "0";
}
// ---- the question ----------------------------------------------------------
var KEYS = ["1", "2", "3", "4"];
function renderQuestion(v) {
answersEl.innerHTML = "";
if (!v || v.phase !== "playing" || !v.answers) {
questionEl.textContent = "";
categoryEl.textContent = "";
return;
}
categoryEl.textContent = v.category || "";
questionEl.textContent = v.question || "";
v.answers.forEach(function (text, i) {
var b = document.createElement("button");
b.type = "button";
b.className = "pete-answer";
b.dataset.at = String(i);
var key = document.createElement("span");
key.className = "pete-answer-key";
key.textContent = KEYS[i] || "";
key.setAttribute("aria-hidden", "true");
var label = document.createElement("span");
label.textContent = text;
b.appendChild(key);
b.appendChild(label);
b.addEventListener("click", function () { answer(i); });
answersEl.appendChild(b);
});
}
function lockAnswers() {
answersEl.querySelectorAll(".pete-answer").forEach(function (b) { b.disabled = true; });
}
// reveal marks the board once the server has decided. Nothing in here is known
// until it comes back: the right answer arrives in the event, not in the view.
function reveal(choice, correct) {
answersEl.querySelectorAll(".pete-answer").forEach(function (b) {
var i = parseInt(b.dataset.at, 10);
b.disabled = true;
if (i === choice && i === correct) b.dataset.state = "right";
else if (i === choice) b.dataset.state = "wrong";
else if (i === correct) b.dataset.state = "missed";
else b.dataset.state = "dim";
});
}
// ---- the meters ------------------------------------------------------------
function renderLadder(v) {
ladderEl.innerHTML = "";
var rungs = (v && v.rungs) || 12;
var done = (v && v.rung) || 0;
for (var i = 0; i < rungs; i++) {
var pip = document.createElement("span");
pip.className = "pete-rung";
pip.dataset.on = i < done ? "1" : "0";
ladderEl.appendChild(pip);
}
}
function renderMeter(v) {
if (!v) {
multEl.textContent = "—";
standsEl.textContent = "—";
standsLbl.textContent = "if you walk";
meterEl.dataset.cold = "1";
rungEl.textContent = "";
return;
}
multEl.textContent = v.multiple.toFixed(2) + "×";
standsEl.textContent = (v.stands || 0).toLocaleString();
meterEl.dataset.cold = v.rung === 0 ? "1" : "0";
if (v.phase === "done") {
standsLbl.textContent = v.net > 0 ? "banked" : "gone";
rungEl.textContent = "";
return;
}
standsLbl.textContent = v.can_walk ? "if you walk" : "answer one to unlock";
rungEl.textContent = "Question " + (v.rung + 1) + " of " + v.rungs;
}
// knock rolls the multiple up to its new value rather than swapping it, so a
// right answer reads as the total *growing* — which is the thing you're
// deciding whether to risk.
function climb(v) {
var from = parseFloat(multEl.textContent) || 1;
var to = v.multiple;
if (reduced) { renderMeter(v); return; }
var t0 = performance.now();
meterEl.dataset.hit = "0";
(function step(now) {
var p = Math.min(1, (now - t0) / 420);
var eased = 1 - Math.pow(1 - p, 3);
multEl.textContent = (from + (to - from) * eased).toFixed(2) + "×";
if (p < 1) requestAnimationFrame(step);
else renderMeter(v);
})(t0);
}
// ---- the money -------------------------------------------------------------
function settleChips(final) {
var payout = final.payout || 0;
var back = payout - final.bet;
if (payout <= 0) {
var chain = spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
return chain;
}
return spot
.pour(houseEl, back, { gap: 60 })
.then(function () { return wait(back > 0 ? 380 : 200); })
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
}
// standing puts the stake back on the spot for the next ladder, the way every
// other table in the room leaves your bet up.
function standing(amount) {
var money = window.PeteGames.view();
if (!amount || !money || money.chips < amount) {
bet = 0;
showBet();
return;
}
bet = amount;
showBet();
// pour grows the pile from whatever is on the spot, and settle has just swept
// it clean — so it must not be told the chips are already there. Setting the
// amount first counted the standing bet twice, and the spot printed 400 under
// a 200 stake.
return spot.pour(purseEl, amount);
}
// ---- phases ----------------------------------------------------------------
var VERDICTS = {
walked: "Banked it.",
cleared: "Cleared the board! 🎉",
wrong: "Wrong.",
timeout: "Out of time.",
};
function verdict(v) {
var text = VERDICTS[v.outcome] || "";
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");
// Confetti only for clearing all twelve — the one thing in here worth it.
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 34 });
}
function setPhase(v) {
game = v;
var live = !!v && v.phase === "playing";
betting.classList.toggle("hidden", live);
playing.classList.toggle("hidden", !live);
if (walkBtn) {
walkBtn.disabled = !live || !v.can_walk;
walkAmtEl.textContent = (v && v.can_walk ? v.stands : 0).toLocaleString();
}
if (!v || !v.outcome) verdictEl.classList.add("hidden");
}
// paint puts a round up with no animation: the resume path, after a reload or a
// redeploy. The clock picks up exactly where the server says it is — which is
// the whole point of it being the server's clock.
function paint(v) {
if (!v) {
clearClock();
renderQuestion(null);
renderLadder(null);
renderMeter(null);
spot.render(0);
setPhase(null);
return;
}
renderQuestion(v);
renderLadder(v);
renderMeter(v);
spot.render(v.phase === "done" ? 0 : v.bet);
setPhase(v);
if (v.phase === "playing") startClock(v.left, v.limit);
else clearClock();
}
// ---- the script ------------------------------------------------------------
// play walks the server's events. Same rule as the other tables: on a live
// round the money is already right (your stake left your pile when you pressed
// Play, and it's on the spot), but on a settling one the chip bar is held back
// until the chips have physically come home. A counter that pays you before
// the reveal is a counter that has told you the ending.
function play(view, money) {
var events = view.triv_events || [];
var final = view.trivia;
var settles = !!final && final.phase === "done";
var chain = Promise.resolve();
if (!settles) money();
stopClock();
events.forEach(function (e) {
chain = chain.then(function () {
switch (e.kind) {
case "ask":
// A fresh question. Everything about the last one goes.
verdictEl.classList.add("hidden");
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.
climb({ multiple: e.multiple, rung: final.rung, rungs: final.rungs,
stands: final.stands, can_walk: true, phase: "playing" });
renderLadder(final);
}
return wait(900);
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":
return;
}
});
});
return chain.then(function () {
if (!final) { paint(null); money(); return; }
if (!settles) {
renderMeter(final);
setPhase(final);
return;
}
// Over: the clock stops, the money moves, and only then does the bar catch up.
clearClock();
playing.classList.add("hidden");
renderMeter(final);
renderLadder(final);
verdict(final);
return settleChips(final)
.then(money)
.then(function () { return standing(final.bet); })
.then(function () { setPhase(final); });
});
}
// ---- talking to the table ---------------------------------------------------
function send(path, body, where) {
if (busy) return;
busy = true;
say("", null, where);
return window.PeteGames.post(path, body)
.then(function (view) {
return play(view, function () { window.PeteGames.apply(view); });
})
.catch(function (err) {
say(err.message, "bad", where);
return window.PeteGames.refresh().then(function (v) {
if (v && v.trivia) paint(v.trivia);
else { paint(null); spot.render(0); }
});
})
.then(function () { busy = false; });
}
function answer(i) {
if (busy || timedOut || !game || game.phase !== "playing") return;
stopClock();
lockAnswers();
send("/api/games/trivia/answer", { choice: i }, gameMsgEl);
}
if (walkBtn) {
walkBtn.addEventListener("click", function () {
if (busy || !game || game.phase !== "playing" || !game.can_walk) return;
stopClock();
lockAnswers();
send("/api/games/trivia/answer", { walk: true }, gameMsgEl);
});
}
// 14 answers the question. The key is printed on the button it answers.
document.addEventListener("keydown", function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
if (!game || game.phase !== "playing" || busy) return;
var i = KEYS.indexOf(e.key);
if (i === -1) return;
var btn = answersEl.querySelector('.pete-answer[data-at="' + i + '"]');
if (!btn || btn.disabled) return;
e.preventDefault();
answer(i);
});
// ---- betting ----------------------------------------------------------------
function showBet() {
betAmount.textContent = bet.toLocaleString();
var money = window.PeteGames.view();
if (startBtn) startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet;
}
function pickTier(slug) {
tier = slug;
root.querySelectorAll("[data-tier]").forEach(function (b) {
b.dataset.on = b.dataset.tier === slug ? "1" : "0";
});
showBet();
}
root.querySelectorAll("[data-tier]").forEach(function (b) {
b.addEventListener("click", function () {
if (busy) return;
pickTier(b.dataset.tier);
});
});
// The chip you click is the chip that flies. Scoped to buttons: the bare
// [data-chip] spans in the corner are the house's rack, and it is not betting.
root.querySelectorAll("button[data-chip]").forEach(function (btn) {
btn.addEventListener("click", function () {
if (busy) return;
var d = parseInt(btn.dataset.chip, 10);
var money = window.PeteGames.view();
if (money && bet + d > money.chips) {
say("You haven't got that many chips.", "bad");
return;
}
bet += d;
showBet();
var target = bet;
spot.amount = bet;
FX.fly(btn, spotEl, { denom: d }).then(function () {
if (bet >= target) spot.render(target); // unless Clear got there first
});
});
});
var clearBtn = root.querySelector("[data-bet-clear]");
if (clearBtn) {
clearBtn.addEventListener("click", function () {
if (busy) return;
if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
bet = 0;
showBet();
});
}
if (startBtn) {
startBtn.addEventListener("click", function () {
if (busy) return;
if (!tier) { say("Pick a difficulty first.", "bad"); return; }
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
// The stake stays on the spot for the whole ladder: it is what's at risk,
// and it is riding on every question until you take it back or lose it.
send("/api/games/trivia/start", { bet: bet, tier: tier });
});
}
pickTier(tier);
var resumed = false;
window.PeteGames.onUpdate(function (v) {
if (!resumed) {
resumed = true;
if (v.trivia) {
paint(v.trivia);
if (v.trivia.phase === "done") verdict(v.trivia);
} else {
paint(null);
}
}
showBet();
});
})();

View File

@@ -0,0 +1,969 @@
// The UNO table, gone multiplayer.
//
// Same bargain as hold'em: the browser holds no game. You sit down with a stack,
// and every hand each seat antes into a pot the winner takes. One move goes up and
// what comes back is that move *and every bot turn it handed off to*, as a script
// of events this file plays back slowly enough to follow. Other people are at the
// felt, so a stream keeps your view live while they play; a nudge on it means the
// table changed, and you refetch your own seat's redacted view.
//
// Two rules carried over, both load-bearing: the browser never learns another
// seat's hand (it gets a count), and the chip bar only moves when chips actually
// cross the border — at sit-down and get-up. A pot won mid-session moves inside
// the engine and never touches your purse; it moves your *stack*, which is the
// number on the rail.
(function () {
"use strict";
var root = document.querySelector("[data-uno]");
if (!root) return;
var FX = window.PeteFX;
var G = window.PeteGames;
var seatsEl = root.querySelector("[data-seats]");
var handEl = root.querySelector("[data-hand]");
var deckEl = root.querySelector("[data-deck]");
var deckCntEl = root.querySelector("[data-deck-count]");
var discardEl = root.querySelector("[data-discard]");
var colourEl = root.querySelector("[data-colour]");
var turnEl = root.querySelector("[data-turn-label]");
var countEl = root.querySelector("[data-count-label]");
var verdictEl = root.querySelector("[data-verdict]");
var potEl = root.querySelector("[data-pot]");
var stackEl = root.querySelector("[data-stack]");
var tableEl = root.querySelector("[data-table-name]");
var feltEl = root.querySelector(".pete-uno");
var wildEl = root.querySelector("[data-wild]");
var gateEl = root.querySelector("[data-unogate]");
var callBtn = root.querySelector("[data-uno-call]");
var timerEl = root.querySelector("[data-uno-timer]");
var billEl = root.querySelector("[data-bill]");
var actingEl = root.querySelector("[data-acting]");
var betweenEl = root.querySelector("[data-between]");
var sittingEl = root.querySelector("[data-sitting]");
var chatEl = root.querySelector("[data-chat]");
var drawBtn = root.querySelector("[data-draw]");
var passBtn = root.querySelector("[data-pass]");
var takeBtn = root.querySelector("[data-take]");
var takeN = root.querySelector("[data-take-n]");
var hintEl = root.querySelector("[data-hint]");
var dealBtn = root.querySelector("[data-deal]");
var leaveBtn = root.querySelector("[data-leave]");
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 tableStackEl = root.querySelector("[data-table-stack]");
var boughtInEl = root.querySelector("[data-bought-in]");
var anteNoteEl = root.querySelector("[data-ante-note]");
var gameMsgEl = root.querySelector("[data-game-msg]");
var betweenMsg = root.querySelector("[data-between-msg]");
var tableMsg = root.querySelector("[data-table-msg]");
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 lobbyEl = root.querySelector("[data-lobby]");
var lobbyWrap = root.querySelector("[data-lobby-wrap]");
var busy = false;
var game = null; // the table as the server last described it (a v.uno)
var me = 0; // your seat at the table
var tier = null; // the selected tier button, when sitting
var pendingWild = -1;
var played = -1;
var stream = null;
var pendingSync = false;
var CALL_MS = 2600;
var gate = null;
var gateTimer = 0;
var reduced = FX.reduced;
function pace(ms) { return reduced ? 0 : ms; }
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
function say(text, tone, where) {
var el = where || tableMsg;
if (!el) return;
if (!text) { el.classList.add("hidden"); return; }
el.textContent = text;
el.classList.remove("hidden");
el.style.color = tone === "bad" ? "#cc3d4a" : "";
}
function fmt(n) { return (n || 0).toLocaleString(); }
// ---- drawing the cards -----------------------------------------------------
var FACES = {
"skip": { mid: "🚫", corner: "🚫" },
"reverse": { mid: "⇄", corner: "⇄" },
"+2": { mid: "+2", corner: "+2" },
"wild": { mid: "★", corner: "★" },
"+4": { mid: "+4", corner: "+4" },
"skip all": { mid: "SKIP ALL", corner: "⊘⊘", size: "words" },
"discard all": { mid: "DISCARD ALL", corner: "≡", size: "words" },
"rev +4": { mid: "⇄+4", corner: "⇄4", size: "mid" },
"+6": { mid: "+6", corner: "+6" },
"+10": { mid: "+10", corner: "+10", size: "mid" },
"roulette": { mid: "?", corner: "?" },
};
function faceOf(c) { return FACES[c.value] || { mid: c.value, corner: c.value }; }
var DRAWS = { "+2": true, "+4": true, "+6": true, "+10": true, "rev +4": true };
function card(c, opts) {
opts = opts || {};
var f = faceOf(c);
var el = document.createElement(opts.button ? "button" : "div");
if (opts.button) el.type = "button";
el.className = "pete-uno-card";
el.dataset.c = c.wild ? "wild" : c.color;
var face = document.createElement("span");
face.className = "pete-uno-face";
var oval = document.createElement("span");
oval.className = "pete-uno-oval";
if (f.size) oval.dataset.size = f.size;
oval.textContent = f.mid;
face.appendChild(oval);
["tl", "br"].forEach(function (at) {
var corner = document.createElement("span");
corner.className = "pete-uno-corner";
corner.dataset.at = at;
corner.textContent = f.corner;
corner.setAttribute("aria-hidden", "true");
face.appendChild(corner);
});
if (c.wild) {
var wheel = document.createElement("span");
wheel.className = "pete-uno-wheel";
face.appendChild(wheel);
if (c.color && c.color !== "wild") el.dataset.named = c.color;
}
if (c.wild && DRAWS[c.value]) el.dataset.glow = "1";
el.appendChild(face);
el.setAttribute("aria-label", label(c));
return el;
}
var SPOKEN = {
"+2": "draw two", "+4": "draw four", "+6": "draw six", "+10": "draw ten",
"wild": "wild", "skip": "skip", "reverse": "reverse",
"skip all": "skip everyone", "discard all": "discard all",
"rev +4": "reverse and draw four", "roulette": "colour roulette",
};
function label(c) {
var v = SPOKEN[c.value] || c.value;
if (c.wild) {
var name = c.value === "wild" ? "wild" : "wild " + v;
return name + (c.color && c.color !== "wild" ? ", played as " + c.color : "");
}
return c.color + " " + v;
}
function back() {
var el = document.createElement("div");
el.className = "pete-uno-card pete-uno-card-back";
var b = document.createElement("span");
b.className = "pete-uno-back";
el.appendChild(b);
return el;
}
// ---- the board -------------------------------------------------------------
var FAN = 8;
function live(v) { return !!v && (v.phase === "play" || v.phase === "drawn" || v.phase === "stack"); }
function renderSeats(v) {
seatsEl.innerHTML = "";
if (!v) return;
var catchable = {};
(v.catchable || []).forEach(function (i) { catchable[i] = true; });
v.seats.forEach(function (s, i) {
if (i === me) return; // you are the hand at the bottom, not a seat up here
var el = document.createElement("div");
el.className = "pete-uno-seat";
el.dataset.seat = String(i);
el.dataset.turn = v.turn === i && live(v) ? "1" : "0";
if (s.uno) el.dataset.uno = "1";
if (s.out) el.dataset.out = "1";
if (catchable[i]) {
var got = document.createElement("button");
got.type = "button";
got.className = "pete-uno-catch";
got.dataset.catch = String(i);
got.textContent = "Catch!";
got.setAttribute("aria-label", "Catch " + s.name + " — one card, never called");
got.addEventListener("click", function (e) { e.stopPropagation(); catchSeat(i); });
el.appendChild(got);
}
var fan = document.createElement("div");
fan.className = "pete-uno-fan";
var show = Math.min(s.cards, FAN);
for (var n = 0; n < show; n++) {
var b = back();
b.style.setProperty("--i", n);
b.style.setProperty("--n", show);
fan.appendChild(b);
}
el.appendChild(fan);
var name = document.createElement("p");
name.className = "pete-uno-name";
name.textContent = s.name + (s.bot ? "" : " ●");
el.appendChild(name);
var count = document.createElement("p");
count.className = "pete-uno-count";
count.dataset.count = "";
var stk = s.stack != null ? " · " + fmt(s.stack) : "";
count.textContent = (s.out ? "Buried" : s.cards + (s.cards === 1 ? " card" : " cards")) + stk;
el.appendChild(count);
seatsEl.appendChild(el);
});
}
function seatEl(i) { return seatsEl.querySelector('[data-seat="' + i + '"]'); }
function seatAnchor(i) {
if (i === me) return handEl;
var el = seatEl(i);
return el ? el.querySelector(".pete-uno-fan") : deckEl;
}
var shownHand = [];
function key(c) { return c.color + "/" + c.value; }
function paintHand(cards, playable, clickable) {
cards = cards || [];
var pool = shownHand.map(key);
handEl.innerHTML = "";
var fresh = 0;
cards.forEach(function (c, i) {
var el = card(c, { button: true });
el.dataset.at = String(i);
var ok = !!clickable && !!playable[i];
el.dataset.on = ok ? "1" : "0";
el.disabled = !ok || busy;
if (ok) el.addEventListener("click", function () { pick(i, c); });
var was = pool.indexOf(key(c));
if (was >= 0) { pool.splice(was, 1); el.dataset.settled = "1"; }
else { el.style.setProperty("--i", fresh++); }
handEl.appendChild(el);
});
shownHand = cards.slice();
}
function renderHand(v) {
if (!v || !v.hand) { handEl.innerHTML = ""; shownHand = []; return; }
var playable = {};
(v.playable || []).forEach(function (i) { playable[i] = true; });
paintHand(v.hand, playable, live(v) && v.turn === me);
}
function showHand(cards) {
if (!cards) return;
paintHand(cards, {}, false);
countEl.textContent = cards.length + (cards.length === 1 ? " card — UNO!" : " cards");
}
function renderPiles(v) {
discardEl.innerHTML = "";
if (!v) { deckCntEl.textContent = "0"; colourEl.textContent = ""; if (feltEl) feltEl.dataset.c = ""; return; }
deckCntEl.textContent = String(v.deck || 0);
var dealt = (v.deck || 0) > 0 || (v.hand && v.hand.length) || (v.seats || []).some(function (s) { return s.cards > 0; });
if (dealt && v.top && v.top.value) {
var top = card(v.top);
top.classList.add("pete-uno-top");
discardEl.appendChild(top);
colourEl.textContent = v.color;
colourEl.dataset.c = v.color;
if (feltEl) feltEl.dataset.c = v.color;
} else {
colourEl.textContent = "";
if (feltEl) feltEl.dataset.c = "";
}
pending(live(v) ? (v.pending || 0) : 0);
}
function pending(n) {
if (billEl) {
billEl.textContent = n > 0 ? "+" + n + " on you" : "";
billEl.classList.toggle("hidden", n <= 0);
}
if (takeN) takeN.textContent = String(n);
}
function renderRail(v) {
if (potEl) potEl.textContent = fmt(v ? v.pot : 0);
if (stackEl) stackEl.textContent = v ? fmt(v.stack) : "—";
if (tableEl) tableEl.textContent = v && v.tier ? v.tier.name : "";
if (tableStackEl) tableStackEl.textContent = fmt(v ? v.stack : 0);
if (boughtInEl) boughtInEl.textContent = fmt(v ? v.bought_in : 0);
if (anteNoteEl) anteNoteEl.textContent = fmt(v && v.tier ? v.tier.ante : 0);
}
function renderTurn(v) {
if (!v || !live(v)) { turnEl.textContent = ""; countEl.textContent = ""; return; }
var yours = v.turn === me;
var who = yours ? "Your turn" : (v.seats[v.turn] || {}).name + " is thinking…";
if (yours && v.phase === "drawn") who = "Play it, or keep it";
if (yours && v.phase === "stack") who = "+" + (v.pending || 0) + " — stack it, or take it";
turnEl.textContent = who;
turnEl.dataset.you = yours ? "1" : "0";
var n = (v.hand || []).length;
countEl.textContent = n + (n === 1 ? " card — UNO!" : " cards");
}
// ---- phases ----------------------------------------------------------------
function verdict(v) {
if (!v || v.winner == null) { verdictEl.classList.add("hidden"); return; }
var text = "";
var ante = (v.seats[me] && v.seats[me].ante) || 0;
if (v.outcome === "tie") {
text = "Nobody went out — antes back.";
} else if (v.winner === me) {
var net = ((v.seats[me] && v.seats[me].won) || 0) - ante;
var howYou = (v.seats[me] && v.seats[me].cards === 0) ? "You went out! 🎉" : "Last one standing! 🎉";
text = howYou + (net > 0 ? " +" + fmt(net) : "");
} else if (v.winner >= 0) {
var w = v.seats[v.winner] || {};
text = (w.name || "They") + " took the pot." + (ante > 0 ? " " + fmt(ante) : "");
}
if (!text) { verdictEl.classList.add("hidden"); return; }
verdictEl.textContent = text;
verdictEl.classList.remove("hidden");
if (v.winner === me && v.outcome !== "tie") FX.burst(verdictEl, { count: 34 });
else if (v.winner >= 0 && v.winner !== me) FX.sfx("lose");
}
var HINTS = {
play: "Click a card that lights up. Nothing lights up? Draw one.",
drawn: "You drew a card you can play. Play it, or keep it and pass.",
stack: "Only draw cards will do here. Answer it, and it lands on somebody else — or take the lot.",
};
function controls() {
var lv = live(game);
var yours = lv && game.turn === me;
var drawn = lv && game.phase === "drawn";
var stack = lv && game.phase === "stack";
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
b.disabled = busy || !yours || b.dataset.on !== "1";
});
seatsEl.querySelectorAll("[data-catch]").forEach(function (b) { b.disabled = busy || !yours; });
if (drawBtn) drawBtn.disabled = busy || !yours || drawn || stack;
if (passBtn) passBtn.disabled = busy || !yours;
if (takeBtn) takeBtn.disabled = busy || !yours;
if (deckEl) deckEl.disabled = busy || !yours || drawn || stack;
if (dealBtn) dealBtn.disabled = busy;
if (leaveBtn) leaveBtn.disabled = busy;
}
function setPhase(v) {
game = v;
var seated = !!v;
var lv = live(v);
var yours = lv && v.turn === me;
var drawn = lv && v.phase === "drawn";
var stack = lv && v.phase === "stack";
if (sittingEl) sittingEl.classList.toggle("hidden", seated);
if (chatEl) chatEl.classList.toggle("hidden", !seated);
if (actingEl) actingEl.classList.toggle("hidden", !yours);
if (betweenEl) betweenEl.classList.toggle("hidden", !(seated && !lv));
hideWild();
hideGate();
if (drawBtn) drawBtn.classList.toggle("hidden", drawn || stack);
if (takeBtn) takeBtn.classList.toggle("hidden", !stack);
var canPass = drawn && !(v && v.tier && v.tier.no_mercy);
if (passBtn) passBtn.classList.toggle("hidden", !canPass);
if (hintEl && yours) {
hintEl.textContent = (v.catchable || []).length
? "Somebody's down to one card and never said so. Catch them before you play."
: (HINTS[v.phase] || HINTS.play);
}
controls();
if (!v || v.winner == null) verdictEl.classList.add("hidden");
}
function paint(v) {
if (v && v.your_seat != null) me = v.your_seat;
renderSeats(v);
renderPiles(v);
renderHand(v);
renderRail(v);
renderTurn(v);
setPhase(v);
if (v && !live(v) && v.winner != null) verdict(v);
}
// ---- the script ------------------------------------------------------------
function throwCard(node, from, to, opts) {
opts = opts || {};
return FX.flyNode(node, from, to, {
duration: opts.duration || 380,
lift: opts.lift == null ? 0.7 : opts.lift,
spin: opts.spin == null ? FX.jitter((opts.index || 0) + 3, 14) : opts.spin,
fromScale: opts.fromScale == null ? 0.9 : opts.fromScale,
delay: opts.delay || 0,
index: opts.index || 0,
sound: opts.sound === undefined ? "card" : opts.sound,
});
}
function bump(seat, left) {
if (seat === me || left == null) return;
var el = seatEl(seat);
if (!el) return;
var fan = el.querySelector(".pete-uno-fan");
var count = el.querySelector("[data-count]");
var buried = el.dataset.out === "1";
if (count) {
var stk = count.textContent.indexOf("·") >= 0 ? count.textContent.slice(count.textContent.indexOf("·")) : "";
count.textContent = (buried ? "Buried" : left + (left === 1 ? " card" : " cards")) + (stk ? " " + stk : "");
}
if (!fan) return;
var show = Math.min(left, FAN);
while (fan.children.length > show) fan.removeChild(fan.lastChild);
while (fan.children.length < show) fan.appendChild(back());
Array.prototype.forEach.call(fan.children, function (b, i) {
b.style.setProperty("--i", i);
b.style.setProperty("--n", fan.children.length);
});
el.dataset.uno = left === 1 ? "1" : "0";
}
function spotlight(seat) {
seatsEl.querySelectorAll(".pete-uno-seat").forEach(function (el) {
el.dataset.turn = parseInt(el.dataset.seat, 10) === seat ? "1" : "0";
});
}
function badge(seat, text, tone) {
var host = seat === me ? handEl : seatEl(seat);
if (!host || reduced) return Promise.resolve();
var b = document.createElement("span");
b.className = "pete-uno-badge";
b.dataset.tone = tone || "";
b.textContent = text;
host.appendChild(b);
return new Promise(function (r) { setTimeout(function () { b.remove(); r(); }, 900); });
}
// play walks the server's script for a move the acting player just made.
function play(view) {
var events = view.uno_events || [];
var final = view.uno;
if (final && final.your_seat != null) me = final.your_seat;
var chain = Promise.resolve();
events.forEach(function (e, n) {
chain = chain.then(function () {
switch (e.kind) {
case "ante":
return; // the antes are tallied into the pot the deal paints
case "deal":
FX.sfx("shuffle");
for (var c = 0; c < 5; c++) FX.sfx("deal", { delay: 0.28 + c * 0.06, v: c });
paint(final);
return wait(320);
case "play": {
spotlight(e.seat);
var node = card(e.card);
var from = seatAnchor(e.seat);
if (e.seat === me && played >= 0) {
var liveEl = handEl.querySelector('.pete-uno-card[data-at="' + played + '"]');
if (liveEl) liveEl.style.visibility = "hidden";
played = -1;
}
bump(e.seat, e.left);
return throwCard(node, from, discardEl, { index: n }).then(function () {
discardEl.innerHTML = "";
var top = card(e.card);
top.classList.add("pete-uno-top", "pete-uno-land");
discardEl.appendChild(top);
if (e.color) { colourEl.textContent = e.color; colourEl.dataset.c = e.color; if (feltEl) feltEl.dataset.c = e.color; }
showHand(e.hand);
return wait(e.seat === me ? 120 : 300);
});
}
case "draw":
case "forced": {
spotlight(e.seat);
var to = seatAnchor(e.seat);
var backs = [];
for (var i = 0; i < Math.min(e.n, 4); i++) {
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90, sound: "deal" }));
}
var punished = e.kind === "forced";
if (punished) { FX.sfx("bad"); pending(0); badge(e.seat, "+" + e.n, "bad"); }
return Promise.all(backs).then(function () {
bump(e.seat, e.left);
showHand(e.hand);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
if (e.seat === me && e.card) return wait(160);
return wait(punished ? 380 : 180);
});
}
case "caught": {
spotlight(e.seat);
var mine = e.seat === me;
var caughtBy = (game && game.seats && game.seats[e.by]) || {};
var whom = mine ? "Caught! " + (caughtBy.name || "They") + " saw it" : "Caught! +" + e.n;
FX.sfx(mine ? "bad" : "catch");
var lands = [];
var at = seatAnchor(e.seat);
for (var k = 0; k < e.n; k++) {
lands.push(throwCard(back(), deckEl, at, { index: k, delay: k * 100, sound: "deal" }));
}
badge(e.seat, whom, "bad");
return Promise.all(lands).then(function () {
bump(e.seat, e.left);
showHand(e.hand);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
return wait(520);
});
}
case "miscall": {
FX.sfx("bad");
var wrong = [];
for (var w = 0; w < e.n; w++) {
wrong.push(throwCard(back(), deckEl, seatAnchor(e.seat), { index: w, delay: w * 100, sound: "deal" }));
}
badge(e.seat, "They called it. +" + e.n, "bad");
return Promise.all(wrong).then(function () {
bump(e.seat, e.left);
showHand(e.hand);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
return wait(520);
});
}
case "stack":
pending(e.n);
spotlight(e.seat);
FX.sfx("bad");
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
case "skipall":
return badge(e.seat, "Everyone skipped").then(function () { return wait(240); });
case "discard": {
if (e.seat === me && e.color) {
handEl.querySelectorAll('.pete-uno-card[data-c="' + e.color + '"]').forEach(function (el) { el.style.visibility = "hidden"; });
}
bump(e.seat, e.left);
var dumpFrom = seatAnchor(e.seat);
var dumped = [];
for (var d = 0; d < Math.min(e.n, 4); d++) {
dumped.push(throwCard(back(), dumpFrom, discardEl, { index: d, delay: d * 70 }));
}
badge(e.seat, "" + e.n + " " + (e.color || ""));
return Promise.all(dumped).then(function () { showHand(e.hand); return wait(220); });
}
case "roulette": {
spotlight(e.seat);
var spinTo = seatAnchor(e.seat);
var spun = [];
for (var r = 0; r < Math.min(e.n, 4); r++) {
spun.push(throwCard(back(), deckEl, spinTo, { index: r, delay: r * 80 }));
}
badge(e.seat, "Roulette +" + e.n, "bad");
return Promise.all(spun).then(function () {
bump(e.seat, e.left);
showHand(e.hand);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
return wait(400);
});
}
case "mercy": {
var grave = seatEl(e.seat);
if (grave) grave.dataset.out = "1";
bump(e.seat, 0);
showHand(e.hand);
pending(0);
FX.sfx("bad");
return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); });
}
case "skip":
return badge(e.seat, "Skipped", "bad").then(function () { return wait(80); });
case "reverse":
if (feltEl) feltEl.dataset.rev = feltEl.dataset.rev === "1" ? "0" : "1";
return badge(e.seat, "Reverse").then(function () { return wait(60); });
case "uno":
FX.sfx("uno");
return badge(e.seat, "UNO!", "uno");
case "reshuffle":
deckCntEl.textContent = String(e.n);
deckEl.classList.add("pete-uno-shuffle");
FX.sfx("shuffle");
return wait(420).then(function () { deckEl.classList.remove("pete-uno-shuffle"); });
case "pass":
spotlight(e.seat);
return wait(140);
case "settle":
case "sit":
case "leave":
return;
}
});
});
return chain.then(function () {
if (!final) { paint(null); return; }
paint(final);
if (!live(final) && final.winner != null) verdict(final);
});
}
// ---- moves ------------------------------------------------------------------
function pick(i, c) {
if (busy || !live(game) || game.turn !== me) return;
if (c.wild) { askColour(i); return; }
offer(i, 0);
}
function offer(i, color) {
if (game && (game.uno_at || []).indexOf(i) >= 0) { openGate(i, color); return; }
move({ kind: "play", index: i, color: color });
}
function openGate(i, color) {
gate = { index: i, color: color };
gateEl.classList.remove("hidden");
if (timerEl) {
timerEl.style.animation = "none";
void timerEl.offsetWidth;
timerEl.style.animation = "";
timerEl.style.animationDuration = CALL_MS + "ms";
}
if (callBtn) callBtn.focus();
gateTimer = setTimeout(function () { shut(false); }, CALL_MS);
}
function shut(called) {
if (!gate) return;
var g = gate;
gate = null;
clearTimeout(gateTimer);
gateTimer = 0;
gateEl.classList.add("hidden");
move({ kind: "play", index: g.index, color: g.color, uno: called });
}
function hideGate() {
if (gateTimer) clearTimeout(gateTimer);
gateTimer = 0;
gate = null;
if (gateEl) gateEl.classList.add("hidden");
}
if (callBtn) callBtn.addEventListener("click", function () { shut(true); });
function catchSeat(seat) {
if (busy || !live(game) || game.turn !== me) return;
if ((game.catchable || []).indexOf(seat) < 0) return;
move({ kind: "catch", seat: seat });
}
function askColour(i) { pendingWild = i; wildEl.classList.remove("hidden"); }
function hideWild() { pendingWild = -1; if (wildEl) wildEl.classList.add("hidden"); }
var COLOURS = { red: 1, blue: 2, yellow: 3, green: 4 };
root.querySelectorAll("[data-colour-pick]").forEach(function (b) {
b.addEventListener("click", function () {
if (busy || pendingWild < 0) return;
var i = pendingWild;
var c = COLOURS[b.dataset.colourPick];
hideWild();
offer(i, c);
});
});
var cancelWild = root.querySelector("[data-colour-cancel]");
if (cancelWild) cancelWild.addEventListener("click", hideWild);
function move(body) {
played = body.kind === "play" ? body.index : -1;
send("/api/games/uno/move", body, gameMsgEl);
}
if (drawBtn) drawBtn.addEventListener("click", function () { drawOne(); });
if (deckEl) deckEl.addEventListener("click", function () { drawOne(); });
if (passBtn) passBtn.addEventListener("click", function () {
if (busy || !live(game) || game.turn !== me || game.phase !== "drawn") return;
move({ kind: "pass" });
});
function drawOne() {
if (busy || !game || game.phase !== "play" || game.turn !== me) return;
move({ kind: "draw" });
}
if (takeBtn) takeBtn.addEventListener("click", function () {
if (busy || !game || game.turn !== me || game.phase !== "stack") return;
move({ kind: "take" });
});
if (dealBtn) dealBtn.addEventListener("click", function () {
if (busy) return;
move({ kind: "deal" });
});
if (leaveBtn) leaveBtn.addEventListener("click", function () {
if (busy) return;
busy = true;
say("", null, betweenMsg);
G.post("/api/games/uno/leave", {})
.then(function (v) { G.apply(v); unseated(); paint(null); loadLobby(); })
.catch(function (err) { say(err.message, "bad", betweenMsg); })
.then(function () { busy = false; controls(); });
});
// ---- talking to the table --------------------------------------------------
// send takes the table away for the whole move — the request and the script that
// comes back with it — so a click mid-animation can't move against a board the
// server has already left behind. busy comes off at the end of the script.
function send(path, body, where) {
if (busy) return;
busy = true;
say("", null, where);
controls();
return G.post(path, body)
.then(function (view) {
G.apply(view);
return play(view);
})
.catch(function (err) {
say(err.message, "bad", where);
return G.refresh().then(function (v) {
if (v && v.uno) paint(v.uno); else paint(null);
});
})
.then(function () {
busy = false;
controls();
if (pendingSync) { pendingSync = false; sync(); }
});
}
// ---- the live stream -------------------------------------------------------
function seated() { loadChat(); connectLive(); }
function unseated() { disconnectLive(); if (chatLog) chatLog.innerHTML = ""; chatSeen = {}; }
function connectLive() {
if (stream || !window.EventSource) return;
stream = new EventSource("/api/games/uno/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();
};
stream.addEventListener("sync", function () { sync(); });
}
function disconnectLive() { if (stream) { stream.close(); stream = null; } }
// sync repaints from the authoritative table — a snapshot, no animation. Held
// while a move of ours is animating, and applied once it finishes.
function sync() {
if (busy) { pendingSync = true; return; }
G.refresh().then(function (v) {
if (!v) return;
if (v.uno) paint(v.uno); else { paint(null); }
});
}
// ---- chat ------------------------------------------------------------------
function loadChat() {
fetch("/api/games/uno/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 = "";
G.post("/api/games/uno/say", { body: body })
.then(function (line) { addChat(line); })
.catch(function () { chatInput.value = body; });
});
// ---- sitting down ----------------------------------------------------------
var DEFAULT_TABLE = { normal: "table", nomercy: "nm-table" };
function pickRules(which, slug) {
root.querySelectorAll("[data-rules]").forEach(function (b) { b.dataset.on = b.dataset.rules === which ? "1" : "0"; });
root.querySelectorAll("[data-grid]").forEach(function (g) { g.classList.toggle("hidden", g.dataset.grid !== which); });
root.querySelectorAll("[data-note]").forEach(function (p) { p.classList.toggle("hidden", p.dataset.note !== which); });
var el = root.querySelector('[data-tier="' + (slug || DEFAULT_TABLE[which]) + '"]');
if (el) pickTier(el);
}
root.querySelectorAll("[data-rules]").forEach(function (b) {
b.addEventListener("click", function () { if (!busy) pickRules(b.dataset.rules); });
});
function pickTier(btn) {
tier = btn;
root.querySelectorAll("[data-tier]").forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; });
syncSit();
}
root.querySelectorAll("[data-tier]").forEach(function (b) {
b.addEventListener("click", function () { if (!busy) pickTier(b); });
});
// syncSit keeps the buy-in slider inside the table's range and the sit button
// honest about whether you can afford the low end.
function syncSit() {
if (!tier || !buySlider) return;
var min = Number(tier.dataset.min), max = Number(tier.dataset.max);
buySlider.min = String(min);
buySlider.max = String(max);
buySlider.step = String(Math.max(1, Math.round((max - min) / 100)));
var cur = Number(buySlider.value);
if (!cur || cur < min || cur > max) buySlider.value = String(min);
var money = G.view();
var chips = money ? money.chips : 0;
if (buyLabel) buyLabel.textContent = fmt(Number(buySlider.value));
if (buyNote) buyNote.textContent = min + "" + max + " · you have " + fmt(chips);
if (sitBtn) sitBtn.disabled = busy || !tier || chips < Number(buySlider.value);
}
if (buySlider) buySlider.addEventListener("input", syncSit);
if (sitBtn) sitBtn.addEventListener("click", function () {
if (busy || !tier) return;
busy = true;
say("", null, tableMsg);
G.post("/api/games/uno/sit", { tier: tier.dataset.tier, buyin: Number(buySlider.value) })
.then(function (v) { G.apply(v); paint(v.uno); seated(); say("You're in. Deal when you're ready.", null, betweenMsg); })
.catch(function (err) { say(err.message, "bad", tableMsg); })
.then(function () { busy = false; controls(); });
});
// ---- the lobby -------------------------------------------------------------
function loadLobby() {
if (!lobbyEl) return;
fetch("/api/games/uno/tables", { headers: { "Accept": "application/json" } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) { renderLobby((data && data.tables) || []); })
.catch(function () {});
}
function renderLobby(tables) {
lobbyEl.innerHTML = "";
if (lobbyWrap) lobbyWrap.classList.toggle("hidden", tables.length === 0);
tables.forEach(function (t) {
var stakes = root.querySelector('[data-tier="' + t.tier + '"]');
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 lbl = document.createElement("span");
lbl.className = "font-display font-bold";
lbl.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(lbl);
btn.appendChild(seats);
btn.addEventListener("click", function () { joinTable(t, stakes); });
lobbyEl.appendChild(btn);
});
}
function joinTable(t, stakes) {
if (busy) return;
var min = stakes ? Number(stakes.dataset.min) : Number(buySlider.value);
var max = stakes ? Number(stakes.dataset.max) : Number(buySlider.value);
var buyIn = Math.min(max, Math.max(min, Number(buySlider.value)));
busy = true;
say("", null, tableMsg);
G.post("/api/games/uno/sit", { table: t.id, buyin: buyIn })
.then(function (v) { G.apply(v); paint(v.uno); seated(); say("You're in. The next hand deals when the table is ready.", null, betweenMsg); })
.catch(function (err) { say(err.message, "bad", tableMsg); })
.then(function () { busy = false; controls(); });
}
// ---- boot ------------------------------------------------------------------
pickRules("normal");
var resumed = false;
G.onUpdate(function () {
if (resumed) return;
resumed = true;
G.refresh().then(function (v) {
if (v && v.uno) { paint(v.uno); seated(); }
else { paint(null); loadLobby(); syncSit(); }
});
});
})();

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

@@ -23,7 +23,7 @@
always travelling between one of those and the spot in front of you. -->
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<div class="pete-rack" data-house aria-hidden="true">
<div class="pete-rack" data-at="shoe" 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>
@@ -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>
</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>
@@ -127,6 +147,7 @@
{{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/blackjack.js" defer></script>
{{end}}

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

@@ -42,6 +42,87 @@
</p>
</a>
<a href="/games/hangman"
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">Hangman</h3>
<p class="text-sm text-[color:var(--ink)]/60">Guess the phrase, keep the multiple.</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">
Short phrases pay up to 2.6×. You get {{.MaxWrong}} lives, and every wrong guess
takes a tenth off what a win is worth.
</p>
</a>
<a href="/games/solitaire"
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">Solitaire</h3>
<p class="text-sm text-[color:var(--ink)]/60">Buy the deck, win it back a card at a time.</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">
Vegas rules. Your stake buys the deck and doesn't come back — every card you
get home pays a slice of it in. Cash the board whenever you like.
</p>
</a>
<a href="/games/trivia"
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">Trivia</h3>
<p class="text-sm text-[color:var(--ink)]/60">Climb the ladder, or take the money.</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">
{{.Rungs}} questions, and every right answer multiplies what you're holding. A wrong
one loses the lot. Answer fast: the multiple decays as the clock runs.
</p>
</a>
<a href="/games/uno"
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">UNO</h3>
<p class="text-sm text-[color:var(--ink)]/60">Go out first, take the table.</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">
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>
{{range .Soon}}
<div class="rounded-3xl bg-[color:var(--card)]/60 p-6 shadow-pete border-2 border-dashed border-[color:var(--ink)]/15">
<div class="flex items-center gap-3">

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,176 @@
{{define "title"}}Hangman · {{.Room.Name}}{{end}}
{{define "main"}}
<div class="space-y-6" data-hangman>
<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">Hangman</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">{{.MaxWrong}} lives · every wrong guess costs you a tenth of the win</p>
</div>
{{template "_chipbar" .}}
<!-- The felt. The gallows is on it because the gallows is the meter: it counts
down your lives and your winnings at the same time. -->
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<div class="pete-rack" 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>
<!-- The gallows and the stake stack up on the left; the phrase and what it's
worth take the rest. The right column keeps clear of the rack in the
corner, which is why nothing in it is pushed to the far edge. -->
<div class="relative grid gap-x-10 gap-y-8 lg:grid-cols-[auto,1fr] lg:items-start">
<div class="flex flex-col items-center gap-6 lg:items-start">
<svg data-gallows viewBox="0 0 130 150" class="pete-gallows h-48 w-auto sm:h-56" role="img"
aria-labelledby="gallows-title">
<title id="gallows-title">The gallows</title>
<g class="pete-gallows-frame">
<path d="M8 145 H72" />
<path d="M28 145 V8" />
<path d="M28 8 H92" />
<path d="M92 8 V26" />
</g>
<g class="pete-gallows-body">
<circle data-part="0" cx="92" cy="38" r="12" />
<path data-part="1" d="M92 50 V88" />
<path data-part="2" d="M92 58 L74 76" />
<path data-part="3" d="M92 58 L110 76" />
<path data-part="4" d="M92 88 L76 114" />
<path data-part="5" d="M92 88 L108 114" />
</g>
</svg>
<!-- The stake sits under the gallows it's riding on, the same spot the
blackjack table puts your bet on. -->
<div class="flex items-center gap-4">
<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>
<p data-lives class="text-xs font-bold uppercase tracking-wider text-white/50"></p>
</div>
</div>
<div class="min-w-0 space-y-6">
<!-- What a win is worth right now. It falls as the figure fills in.
This row is the only one level with the house rack in the corner, so
it is the only one that has to keep clear of it — the board below
gets the full width, and needs it. -->
<div class="flex flex-wrap items-center gap-3 pr-24 sm:pr-28">
<div class="pete-meter" data-meter>
<span class="pete-meter-label">Pays</span>
<span data-multiple class="pete-meter-value"></span>
</div>
<p class="text-sm text-white/60">
<span data-stands class="font-bold tabular-nums text-white/90"></span>
<span data-stands-label>if you get it</span>
</p>
</div>
<!-- The phrase. -->
<div data-board class="pete-board" aria-live="polite" aria-label="The phrase"></div>
<!-- Letters that missed. -->
<div class="flex min-h-[1.75rem] flex-wrap items-center gap-1.5">
<span data-wrong-label class="hidden text-xs font-bold uppercase tracking-wider text-white/40">Missed</span>
<div data-wrong class="flex flex-wrap gap-1.5"></div>
</div>
<!-- What just happened. -->
<div class="flex min-h-[2.75rem] items-center">
<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>
</div>
</div>
</section>
<!-- Guessing: shown while a game is live. -->
<section data-guessing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div data-keyboard class="pete-keys"></div>
<div class="mt-4 flex flex-wrap items-center gap-2">
<label for="solve" class="sr-only">Guess the whole phrase</label>
<input id="solve" data-solve-input type="text" autocomplete="off" enterkeyhint="go"
placeholder="Or just say what it is…"
class="min-w-0 flex-1 rounded-full bg-[color:var(--ink)]/5 px-4 py-2.5 text-sm font-semibold
border-2 border-[color:var(--ink)]/10 focus:outline-none focus:border-[color:var(--accent)]">
<button type="button" data-solve
class="rounded-full bg-[color:var(--accent)] px-6 py-2.5 font-display font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Solve
</button>
</div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
Type a letter to guess it. A wrong solve costs a life, same as a wrong letter.
</p>
<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">How long a phrase?</div>
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Tiers}}
<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>
</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>
</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">
Play
</button>
</div>
<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/games.js" defer></script>
<script src="/static/js/hangman.js" defer></script>
{{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

@@ -0,0 +1,168 @@
{{define "title"}}Solitaire · {{.Room.Name}}{{end}}
{{define "main"}}
<div class="space-y-6" data-solitaire>
<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">Solitaire</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">You buy the deck. Every card you get home buys some of it back.</p>
</div>
{{template "_chipbar" .}}
<!-- The felt. The board takes the room; the money lives in a rail down the
right of it, where the house rack can't collide with the foundations. -->
<section class="pete-felt pete-solitaire 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-6 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
<!-- The board. -->
<div class="min-w-0 space-y-4 sm:space-y-6">
<!-- The stock and the waste on the left, the foundations on the right,
exactly where a real layout puts them. -->
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-2 sm:gap-3">
<button type="button" data-stock class="pete-slot pete-stock" aria-label="Turn over the stock">
<span data-stock-count class="pete-slot-count">0</span>
<span data-stock-recycle class="pete-slot-recycle hidden" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path><polyline points="21 3 21 8 16 8"></polyline>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path><polyline points="3 21 3 16 8 16"></polyline>
</svg>
</span>
</button>
<div data-waste class="pete-waste" aria-label="The waste"></div>
</div>
<div data-foundations class="flex items-start gap-1.5 sm:gap-2" aria-label="The foundations"></div>
</div>
<!-- The seven columns. -->
<div data-tableau class="pete-tableau" aria-label="The tableau"></div>
<!-- What just happened. -->
<div class="flex min-h-[2.75rem] items-center">
<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>
</div>
<!-- The rail: the house's rack, what you've banked, and the meter that
reads it. Everything the money does happens between these two. -->
<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="pete-spot" data-spot>
<span class="pete-spot-label">Banked</span>
<div class="pete-stack" data-stack></div>
<span data-spot-total class="pete-spot-total hidden"></span>
</div>
<div class="pete-meter w-full justify-center" data-meter>
<span class="pete-meter-label">Home</span>
<span data-home class="pete-meter-value">0<span class="text-white/40">/{{.FullDeck}}</span></span>
</div>
<p class="text-center text-xs leading-relaxed text-white/55">
<span data-per-card class="font-bold text-white/85"></span> a card<br>
<span data-break-even></span>
</p>
</aside>
</div>
</section>
<!-- Playing: shown while a board 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">
<div class="flex flex-wrap items-center gap-3">
<button type="button" data-auto
class="rounded-full bg-[color:var(--ink)]/5 px-5 py-2.5 font-display font-bold border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/10 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Send home ⤴
</button>
<p class="text-xs text-[color:var(--ink)]/45">
Click a card, then where it goes. Double-click sends it home.
</p>
<button type="button" data-cash
class="ml-auto rounded-full bg-[color:var(--accent)] px-6 py-2.5 font-display font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Cash the board · <span data-cash-amount class="tabular-nums">0</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>
<!-- Buying a deck: 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">Which deal?</div>
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Deals}}
<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">
Square with the house at {{.BreakEven}} cards home.
</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">The deck costs</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="Put {{.}} more down"
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
<span>{{.}}</span>
</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">
Buy the deck
</button>
</div>
<p class="mt-3 text-xs text-[color:var(--ink)]/40">
The stake buys the deck outright, so it doesn't come back. What comes back is
whatever you get home, a fifty-second of the multiple at a time. Stop whenever
you like and keep it. There's no undo.
</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/solitaire.js" defer></script>
{{end}}

View File

@@ -0,0 +1,149 @@
{{define "title"}}Trivia · {{.Room.Name}}{{end}}
{{define "main"}}
<div class="space-y-6" data-trivia>
<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">Trivia</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">{{.Rungs}} questions · answer fast, or don't bother</p>
</div>
{{template "_chipbar" .}}
<!-- The felt. The clock is the biggest thing on it, because the clock is the
game: a right answer is worth what it's worth *when you give it*. -->
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<div class="pete-rack" 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>
<!-- The meter and the ladder. This row is the only one level with the house
rack in the corner, so it is the only one that has to keep clear of it. -->
<div class="flex flex-wrap items-center gap-x-4 gap-y-3 pr-32 sm:pr-28">
<div class="pete-meter" data-meter>
<span class="pete-meter-label">Worth</span>
<span data-multiple class="pete-meter-value"></span>
</div>
<p class="text-sm text-white/60">
<span data-stands class="font-bold tabular-nums text-white/90"></span>
<span data-stands-label>if you walk</span>
</p>
<div class="pete-ladder ml-auto" data-ladder aria-hidden="true"></div>
</div>
<!-- The question. -->
<div class="mt-7 min-h-[16rem]" data-round>
<div class="pete-clock" data-clock>
<div class="pete-clock-fill" data-clock-fill></div>
</div>
<div class="mt-4 flex flex-wrap items-baseline justify-between gap-2">
<p data-category class="text-xs font-bold uppercase tracking-wider text-white/40"></p>
<p data-countdown class="font-display text-lg font-bold tabular-nums text-white/70"></p>
</div>
<h2 data-question class="mt-1 font-display text-xl font-bold leading-snug text-white sm:text-2xl" aria-live="polite"></h2>
<div data-answers class="mt-5 grid gap-2.5 sm:grid-cols-2"></div>
<div class="mt-5 flex min-h-[2.75rem] items-center">
<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>
</div>
<!-- The stake, on the same spot every other table puts it. -->
<div class="mt-2 flex items-center gap-4">
<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>
<p data-rung class="text-xs font-bold uppercase tracking-wider text-white/50"></p>
</div>
</section>
<!-- Playing: shown while a ladder 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">
<div class="flex flex-wrap items-center gap-3">
<p class="text-sm text-[color:var(--ink)]/50">
Press <span class="font-bold">1</span><span class="font-bold">4</span>, or click one. A wrong answer, or the clock, loses the lot.
</p>
<button type="button" data-walk
class="ml-auto 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">
Take the money · <span data-walk-amount>0</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>
<!-- 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">How hard?</div>
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Quizzes}}
<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 "%.2f" .Fast}}×</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">
{{.Limit}}s a question · slowest answer still pays {{printf "%.2f" .Buzzer}}×
</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>
</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">
Play
</button>
</div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
The first question is the price of sitting down: you can only walk once you've answered one.
</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/games.js" defer></script>
<script src="/static/js/trivia.js" defer></script>
{{end}}

View File

@@ -0,0 +1,261 @@
{{define "title"}}UNO · {{.Room.Name}}{{end}}
{{define "main"}}
<div class="space-y-6" data-uno>
<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">UNO</h1>
</div>
<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. 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 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, 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>
<span data-deck-count class="pete-uno-deck-count">0</span>
</button>
<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>
<!-- Your hand. -->
<div class="space-y-2">
<div class="flex items-baseline justify-between gap-2">
<p data-turn-label class="text-xs font-bold uppercase tracking-wider text-white/50" aria-live="polite"></p>
<p data-count-label class="text-xs font-bold uppercase tracking-wider text-white/40"></p>
</div>
<div data-hand class="pete-uno-hand" aria-label="Your hand"></div>
</div>
<div class="flex min-h-[2.75rem] items-center justify-center">
<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>
</div>
<!-- The rail. -->
<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">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. -->
<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>
<div class="mt-3 grid grid-cols-2 gap-2.5">
<button type="button" data-colour-pick="red" class="pete-uno-swatch" data-c="red">Red</button>
<button type="button" data-colour-pick="blue" class="pete-uno-swatch" data-c="blue">Blue</button>
<button type="button" data-colour-pick="yellow" class="pete-uno-swatch" data-c="yellow">Yellow</button>
<button type="button" data-colour-pick="green" class="pete-uno-swatch" data-c="green">Green</button>
</div>
<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>
<!-- 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 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">
<button type="button" data-draw
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">
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">
Keep it
</button>
</div>
</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, 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>
</div>
<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}}
</div>
</div>
<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>
</div>
{{end}}
{{define "scripts"}}
<script src="/static/js/casino-fx.js" defer></script>
<script src="/static/js/games.js" defer></script>
<script src="/static/js/uno.js" defer></script>
{{end}}

Some files were not shown because too many files have changed in this diff Show More