33 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two bugs only the browser could show:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-11 00:53:59 -07:00
94 changed files with 24921 additions and 62 deletions

89
cmd/holdem-train/main.go Normal file
View File

@@ -0,0 +1,89 @@
// Command holdem-train trains the casino's poker bots and writes the policy the
// table embeds.
//
// go run ./cmd/holdem-train -iterations 5000000 -out internal/games/holdem/policy.gob
//
// It is not part of Pete. It runs when the engine's rules change, takes half an
// hour, and produces a file. Nothing at runtime imports it.
//
// The bots are trained heads-up, at every stack depth the table deals — that
// range is the point, because poker at twenty big blinds and poker at a hundred
// are different games and a bot that only knows one of them folds into the other.
// A six-handed table then reuses the same policy, which is an approximation, and
// a documented one.
package main
import (
"flag"
"fmt"
"log"
"os"
"runtime"
"time"
"pete/internal/games/holdem"
)
func main() {
iterations := flag.Int("iterations", 5_000_000, "hands to train on")
workers := flag.Int("workers", runtime.NumCPU(), "parallel workers")
out := flag.String("out", "internal/games/holdem/policy.gob", "where to write the policy")
stakes := flag.String("stakes", "low", "which table's blinds to train at")
minBB := flag.Int64("min-bb", 20, "shallowest stack, in big blinds")
maxBB := flag.Int64("max-bb", 100, "deepest stack, in big blinds")
seed := flag.Uint64("seed", 20260714, "seed, so a run can be repeated")
flag.Parse()
tier, err := holdem.TierBySlug(*stakes)
if err != nil {
log.Fatalf("no such table %q", *stakes)
}
fmt.Printf("training %s hands on %d workers — %s blinds, %d%d BB deep\n",
commas(*iterations), *workers, tier.Name, *minBB, *maxBB)
started := time.Now()
last := started
policy := holdem.Train(*iterations, *workers, tier, *minBB, *maxBB, *seed, func(done int) {
if time.Since(last) < 20*time.Second {
return
}
last = time.Now()
frac := float64(done) / float64(*iterations)
if frac <= 0 {
return
}
left := time.Duration(float64(time.Since(started)) * (1 - frac) / frac)
fmt.Printf(" %s / %s hands (%.0f%%), about %s to go\n",
commas(done), commas(*iterations), frac*100, left.Round(time.Second))
})
f, err := os.Create(*out)
if err != nil {
log.Fatal(err)
}
defer f.Close()
if err := holdem.Save(f, policy); err != nil {
log.Fatal(err)
}
info, _ := f.Stat()
fmt.Printf("\ndone in %s: %s nodes, %.1f MB → %s\n",
time.Since(started).Round(time.Second), commas(policy.Meta.Nodes),
float64(info.Size())/(1<<20), *out)
}
func commas(n int) string {
s := fmt.Sprint(n)
if len(s) <= 3 {
return s
}
var out []byte
for i, c := range []byte(s) {
if i > 0 && (len(s)-i)%3 == 0 {
out = append(out, ',')
}
out = append(out, c)
}
return string(out)
}

View File

@@ -58,6 +58,12 @@ client_secret = "${PETE_OIDC_CLIENT_SECRET}"
redirect_url = "https://news.parodia.dev/auth/callback"
# HMAC key that signs the session cookie. Generate with: openssl rand -hex 32
session_secret = "${PETE_SESSION_SECRET}"
# Share the session across sibling hosts, so signing in on news.parodia.dev also
# signs you in on games.parodia.dev. This widens the cookie to every host under
# the domain, so leave it empty to keep the session host-only. Each host that
# starts a login also needs its own redirect URI registered in Authentik
# (<host>/auth/callback) — the login round-trip returns to the host it began on.
cookie_domain = ""
# Optional Web Push digests. When enabled, signed-in users can opt in (from the
# feed-settings panel) to a periodic "N new stories" notification, delivered via
@@ -96,6 +102,16 @@ label = "Amy (US, female)"
id = "en_US-ryan-high"
label = "Ryan (US, male, HQ)"
# The casino (games.parodia.dev). Signed-in only — there is real money in it — so
# it needs [web.auth] above, and web.auth.cookie_domain if the games host is a
# different subdomain from the news one. Chips are 1:1 with gogobee euros and
# cross the border through the escrow endpoints, which gogobee polls; matrix_server
# is how a player is named to that ledger (@<authentik username>:<matrix_server>).
[web.games]
enabled = false
host = "games.parodia.dev"
matrix_server = "parodia.dev"
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
# There is no automatic classification — Pete posts each story to its configured channel.
# Optional: language = "en" drops feed items whose per-item <language> tag is

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

@@ -17,9 +17,40 @@ type Config struct {
Posting PostingConfig `toml:"posting"`
Storage StorageConfig `toml:"storage"`
Web WebConfig `toml:"web"`
Adventure AdventureConfig `toml:"adventure"`
Sources []SourceConfig `toml:"sources"`
}
// AdventureConfig wires the gogobee adventure-news seam: gogobee POSTs
// game-event facts to Pete's ingest endpoint, Pete templates them into stories
// on the /adventure section and posts PRIORITY beats live to Matrix. Disabled by
// default — the endpoint 404s and no adventure channel appears until enabled.
type AdventureConfig struct {
Enabled bool `toml:"enabled"`
// IngestToken is the shared bearer secret gogobee presents on
// POST /api/ingest/adventure. Required when enabled. Use ${ENV_VAR}.
IngestToken string `toml:"ingest_token"`
// Channel is the Matrix channel name (a key in [matrix.channels]) that
// PRIORITY adventure beats post to live. If it isn't a configured Matrix
// channel, adventure runs website-only (stories still appear on /adventure).
Channel string `toml:"channel"`
// DigestHour is the UTC hour [0,23] the daily bulletin digest posts. Default
// 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from
// "unset" and doesn't get silently rewritten to the default.
DigestHour *int `toml:"digest_hour"`
}
// DigestHourOrDefault is the UTC hour the daily digest posts, resolving the
// unset case. Safe on a zero-value AdventureConfig.
func (a AdventureConfig) DigestHourOrDefault() int {
if a.DigestHour == nil {
return defaultDigestHour
}
return *a.DigestHour
}
const defaultDigestHour = 17
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
type WebConfig struct {
Enabled bool `toml:"enabled"`
@@ -29,6 +60,7 @@ type WebConfig struct {
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
Games GamesConfig `toml:"games"` // optional casino (games.parodia.dev)
// AdminSubs is the allowlist of OIDC subjects allowed to view the
// owner-facing source-health dashboard at /status. Empty means the page is
// inaccessible to everyone (returns 404). Requires auth to be enabled.
@@ -77,6 +109,23 @@ type VoiceConfig struct {
Label string `toml:"label"` // menu label, e.g. "Ryan — US, male"
}
// GamesConfig wires the casino. It is signed-in only — there is money in it —
// so it does nothing without web.auth, and it needs the Matrix server name
// because that is how a player's identity reaches gogobee's euro ledger: an
// Authentik username is the Matrix localpart, and the Matrix id is the account.
type GamesConfig struct {
Enabled bool `toml:"enabled"`
// Host is the public hostname the casino answers on, e.g.
// "games.parodia.dev". Requests arriving on it are served the casino at "/";
// everywhere else the same pages live under /games. Empty means no host
// branching, which is the normal state of affairs in local development.
Host string `toml:"host"`
// MatrixServer is the server name half of a player's Matrix id
// ("parodia.dev" -> @reala:parodia.dev). Without it, no player can be
// identified in the economy and the casino stays shut.
MatrixServer string `toml:"matrix_server"`
}
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
// When enabled, signed-in users get their preferences stored server-side keyed
// by the OIDC subject; anonymous visitors keep using browser localStorage.
@@ -87,6 +136,12 @@ type AuthConfig struct {
ClientSecret string `toml:"client_secret"`
RedirectURL string `toml:"redirect_url"` // e.g. https://news.parodia.dev/auth/callback
SessionSecret string `toml:"session_secret"` // HMAC key for signing the session cookie
// CookieDomain widens the session cookie beyond the host that set it, so a
// sign-in on news.parodia.dev is also a sign-in on games.parodia.dev. Set it
// to ".parodia.dev" to share the session across every parodia.dev host —
// which is every host, including the landing site, so it is opt-in rather
// than the default. Empty keeps the cookie host-only.
CookieDomain string `toml:"cookie_domain"`
}
type MatrixConfig struct {
@@ -228,6 +283,21 @@ func (c *Config) validate() error {
}
}
if c.Adventure.Enabled {
if c.Adventure.IngestToken == "" {
return fmt.Errorf("adventure.ingest_token is required when adventure is enabled (the bearer secret gogobee presents)")
}
if h := c.Adventure.DigestHourOrDefault(); h < 0 || h > 23 {
return fmt.Errorf("adventure.digest_hour must be 0-23")
}
if c.Adventure.Channel != "" {
if _, ok := c.Matrix.Channels[c.Adventure.Channel]; !ok {
slog.Warn("adventure.channel is not a configured Matrix channel — adventure runs website-only",
"channel", c.Adventure.Channel)
}
}
}
for i, s := range c.Sources {
if s.Name == "" {
return fmt.Errorf("sources[%d].name is required", i)

View File

@@ -0,0 +1,346 @@
// Package blackjack is a pure blackjack engine.
//
// It knows nothing about HTTP, sockets, timers, euros or players' names. You
// hand it a state and a move, it hands you back a new state and the list of
// things that just happened. Everything else — who is sitting there, what their
// chips are, when their clock runs out — belongs to the shell in internal/games/table.
//
// That seam is the one thing gogobee's blackjack never had: there, the engine
// *was* the message sender, so an "error" meant a Matrix send had failed rather
// than that a player had tried something illegal. Here an error means exactly
// one thing: the move was not legal in this state.
//
// The state is a plain value. It serializes, so a hand survives a redeploy, and
// it replays, so a disputed hand can be dealt again from its seed.
package blackjack
import (
"errors"
"math"
"math/rand/v2"
"pete/internal/games/cards"
)
// Errors an illegal move can produce. Callers can match on these to tell a
// player "not now" rather than "something broke".
var (
ErrHandOver = errors.New("blackjack: the hand is already over")
ErrNotYourTurn = errors.New("blackjack: it is not the player's turn to act")
ErrUnknownMove = errors.New("blackjack: unknown move")
ErrCantDouble = errors.New("blackjack: double is only allowed on the opening two cards")
ErrDeckExhausted = errors.New("blackjack: the shoe is empty")
ErrBadBet = errors.New("blackjack: bet must be positive")
)
// Phase is whose turn it is.
type Phase string
const (
PhasePlayer Phase = "player" // the player is acting
PhaseDealer Phase = "dealer" // transient: the dealer is drawing out
PhaseDone Phase = "done" // settled, Outcome and Payout are final
)
// Outcome is how a finished hand finished, from the player's point of view.
type Outcome string
const (
OutcomeNone Outcome = ""
OutcomeBlackjack Outcome = "blackjack" // natural 21, paid 3:2
OutcomeWin Outcome = "win"
OutcomeLose Outcome = "lose"
OutcomePush Outcome = "push" // tie, stake returned
OutcomeBust Outcome = "bust" // player went over 21
OutcomeDealerBust Outcome = "dealer_bust"
)
// Won reports whether this outcome pays the player more than their stake back.
func (o Outcome) Won() bool {
return o == OutcomeWin || o == OutcomeBlackjack || o == OutcomeDealerBust
}
// Rules are the table's terms. They're part of the state rather than a global,
// so a hand always settles under the rules it was dealt under — even if the
// house changes them mid-session.
type Rules struct {
Decks int `json:"decks"` // shoe size
BlackjackPays float64 `json:"blackjack_pays"` // 1.5 = the honest 3:2
DealerHitsSoft17 bool `json:"dealer_hits_soft17"` // gogobee's dealer does
RakePct float64 `json:"rake_pct"` // house cut, taken from winnings only
}
// DefaultRules match the blackjack gogobee has been dealing in Matrix for years:
// six decks, 3:2 on a natural, dealer hits soft 17. The rake is the one new term
// — see Settle for exactly what it touches.
func DefaultRules() Rules {
return Rules{Decks: 6, BlackjackPays: 1.5, DealerHitsSoft17: true, RakePct: 0.05}
}
// State is one hand of heads-up blackjack: one player, one dealer. Splitting
// isn't in v1, so there's exactly one player hand.
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"`
Phase Phase `json:"phase"`
Outcome Outcome `json:"outcome"`
// 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.
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.
type Event struct {
Kind string `json:"kind"` // "deal" | "player_card" | "dealer_card" | "reveal" | "settle"
Card *cards.Card `json:"card,omitempty"`
Text string `json:"text,omitempty"`
}
// Move is a player action.
type Move string
const (
Hit Move = "hit"
Stand Move = "stand"
Double Move = "double"
)
// HandValue totals a hand, counting each ace as 11 until that would bust, then
// demoting them one at a time. soft reports whether an ace is still counting as
// 11 — which is what makes "soft 17" a different thing from 17.
func HandValue(hand []cards.Card) (total int, soft bool) {
aces := 0
for _, c := range hand {
switch {
case c.Rank == cards.Ace:
aces++
total += 11
case c.Rank >= 10:
total += 10
default:
total += int(c.Rank)
}
}
for total > 21 && aces > 0 {
total -= 10 // demote an ace from 11 to 1
aces--
}
return total, aces > 0
}
// IsBlackjack reports a natural: 21 on the opening two cards. A 21 assembled
// from three cards is not one, and does not get paid 3:2.
func IsBlackjack(hand []cards.Card) bool {
if len(hand) != 2 {
return false
}
v, _ := HandValue(hand)
return v == 21
}
// New deals a fresh hand: two to the player, two to the dealer. If either side
// has a natural the hand is already over and the returned State is settled — a
// player with blackjack never gets asked whether they'd like to hit.
func New(bet int64, r Rules, rng *rand.Rand) (State, []Event, error) {
if bet <= 0 {
return State{}, nil, ErrBadBet
}
if r.Decks < 1 {
r.Decks = 1
}
deck := cards.NewDeck(r.Decks)
deck.Shuffle(rng)
s := State{Rules: r, Deck: deck, 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 {
return State{}, nil, err
}
if err := s.draw(&s.Dealer, "dealer_card", &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) {
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 {
c, ok := s.Deck.Draw()
if !ok {
return ErrDeckExhausted
}
*hand = append(*hand, c)
card := c
*evs = append(*evs, Event{Kind: kind, Card: &card})
return nil
}
// ApplyMove is the whole engine: a legal move in, a new state and the events it
// produced out. An error means the move was illegal and the state is unchanged.
//
// s is taken by value, so the caller's state is only replaced on success.
func ApplyMove(s State, m Move) (State, []Event, error) {
if s.Phase == PhaseDone {
return s, nil, ErrHandOver
}
// A copied State still shares its slices' backing arrays with the original.
// Two moves applied from the same starting state would then append cards over
// each other. Clone first: the caller's state is genuinely untouched, and a
// state can be replayed as many times as we like.
s = s.clone()
if s.Phase != PhasePlayer {
return s, nil, ErrNotYourTurn
}
if m == Double && len(s.Player) != 2 {
// 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
}
evs := []Event{}
if m == Double {
s.Bet *= 2
s.Doubled = true
}
if m == Hit || m == Double {
if err := s.draw(&s.Player, "player_card", &evs); err != nil {
return s, nil, err
}
if v, _ := HandValue(s.Player); v > 21 {
s.settle(&evs) // bust; the dealer never has to play
return s, evs, nil
}
if m == Hit {
return s, evs, nil // still the player's turn
}
}
// Stand, or a double that survived its card: the dealer draws out.
s.Phase = PhaseDealer
s.dealerPlay(&evs)
return s, evs, nil
}
// dealerPlay draws the dealer out to the house rule, then settles. The dealer
// has no choices to make — that's the game — so this needs no move.
func (s *State) dealerPlay(evs *[]Event) {
*evs = append(*evs, Event{Kind: "reveal"}) // the hole card turns over
for {
v, soft := HandValue(s.Dealer)
hitSoft17 := s.Rules.DealerHitsSoft17 && v == 17 && soft
if v >= 17 && !hitSoft17 {
break
}
if err := s.draw(&s.Dealer, "dealer_card", 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.
//
// 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.
func (s *State) settle(evs *[]Event) {
playerVal, _ := HandValue(s.Player)
dealerVal, _ := HandValue(s.Dealer)
playerBJ := IsBlackjack(s.Player)
dealerBJ := IsBlackjack(s.Dealer)
// profit is what the player wins on top of their stake. Negative means the
// stake is gone.
var profit int64
switch {
case playerVal > 21:
s.Outcome = OutcomeBust
profit = -s.Bet
case playerBJ && dealerBJ:
s.Outcome = OutcomePush
case playerBJ:
s.Outcome = OutcomeBlackjack
profit = int64(math.Floor(float64(s.Bet) * s.Rules.BlackjackPays))
case dealerBJ:
s.Outcome = OutcomeLose
profit = -s.Bet
case dealerVal > 21:
s.Outcome = OutcomeDealerBust
profit = s.Bet
case playerVal > dealerVal:
s.Outcome = OutcomeWin
profit = s.Bet
case playerVal == dealerVal:
s.Outcome = OutcomePush
default:
s.Outcome = OutcomeLose
profit = -s.Bet
}
if profit > 0 {
s.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct))
if s.Rake < 0 {
s.Rake = 0
}
profit -= s.Rake
}
if profit < 0 {
s.Payout = 0 // stake is lost; nothing comes back
} else {
s.Payout = s.Bet + profit
}
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.
func (s State) Net() int64 {
if s.Phase != PhaseDone {
return 0
}
return s.Payout - s.Bet
}
// 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
}
// 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...)
return s
}

View File

@@ -0,0 +1,415 @@
package blackjack
import (
"encoding/json"
"testing"
"pete/internal/games/cards"
)
// hand builds a hand from "A♠"-ish shorthand: rank letters/numbers only.
func hand(ranks ...cards.Rank) []cards.Card {
h := make([]cards.Card, len(ranks))
for i, r := range ranks {
h[i] = cards.Card{Rank: r, Suit: cards.Spades}
}
return h
}
func TestHandValue(t *testing.T) {
tests := []struct {
name string
hand []cards.Card
want int
soft bool
}{
{"two aces are 12, not 22", hand(cards.Ace, cards.Ace), 12, true},
{"ace plus king is a soft 21", hand(cards.Ace, cards.King), 21, true},
{"faces are all ten", hand(cards.Jack, cards.Queen), 20, false},
{"ace demotes to save the hand", hand(cards.Ace, 9, 5), 15, false},
{"three aces and an eight", hand(cards.Ace, cards.Ace, cards.Ace, 8), 21, true},
{"soft 17 is an ace and a six", hand(cards.Ace, 6), 17, true},
{"hard 17 has no ace", hand(cards.King, 7), 17, false},
{"a bust stays busted", hand(cards.King, cards.Queen, 5), 25, false},
{"empty hand", nil, 0, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, soft := HandValue(tc.hand)
if got != tc.want || soft != tc.soft {
t.Fatalf("HandValue = (%d, soft=%v), want (%d, soft=%v)", got, soft, tc.want, tc.soft)
}
})
}
}
func TestIsBlackjack_OnlyOnTwoCards(t *testing.T) {
if !IsBlackjack(hand(cards.Ace, cards.King)) {
t.Fatal("A+K is a natural")
}
// 21 built from three cards is not a natural and must not be paid 3:2.
if IsBlackjack(hand(7, 7, 7)) {
t.Fatal("7+7+7 is 21 but not a blackjack")
}
if IsBlackjack(hand(cards.Ace)) {
t.Fatal("one card is not a blackjack")
}
}
// settleWith forces a finished hand and reads back the money, bypassing the
// deal so the payout math can be checked case by case.
func settleWith(t *testing.T, r Rules, bet int64, player, dealer []cards.Card) State {
t.Helper()
s := State{Rules: r, Bet: bet, Player: player, Dealer: dealer}
evs := []Event{}
s.settle(&evs)
if s.Phase != PhaseDone {
t.Fatal("settle left the hand unfinished")
}
return s
}
func TestSettle_PayoutsAndRake(t *testing.T) {
r := DefaultRules() // 3:2, 5% rake
tests := []struct {
name string
player []cards.Card
dealer []cards.Card
wantOutcome Outcome
wantPayout int64 // chips returned to the stack
wantRake int64
}{
{
// 100 stake, 100 profit, 5 raked → 195 back, net +95.
name: "a plain win is raked on the profit only",
player: hand(cards.King, 9), dealer: hand(cards.King, 8),
wantOutcome: OutcomeWin, wantPayout: 195, wantRake: 5,
},
{
// 3:2 on 100 is 150 profit, 7 raked (floor of 7.5) → 243 back.
name: "a natural pays 3:2 less rake",
player: hand(cards.Ace, cards.King), dealer: hand(cards.King, 8),
wantOutcome: OutcomeBlackjack, wantPayout: 243, wantRake: 7,
},
{
name: "a push returns the stake untouched — the house takes nothing",
player: hand(cards.King, 9), dealer: hand(cards.Queen, 9),
wantOutcome: OutcomePush, wantPayout: 100, wantRake: 0,
},
{
name: "two naturals push",
player: hand(cards.Ace, cards.King), dealer: hand(cards.Ace, cards.Queen),
wantOutcome: OutcomePush, wantPayout: 100, wantRake: 0,
},
{
name: "a loss pays nothing and is not charged a rake",
player: hand(cards.King, 8), dealer: hand(cards.King, 9),
wantOutcome: OutcomeLose, wantPayout: 0, wantRake: 0,
},
{
name: "a bust pays nothing even if the dealer would have busted too",
player: hand(cards.King, 8, 9), dealer: hand(cards.King, 6, 9),
wantOutcome: OutcomeBust, wantPayout: 0, wantRake: 0,
},
{
name: "dealer blackjack beats the player's twenty",
player: hand(cards.King, cards.Queen), dealer: hand(cards.Ace, cards.Jack),
wantOutcome: OutcomeLose, wantPayout: 0, wantRake: 0,
},
{
name: "dealer bust pays even money less rake",
player: hand(cards.King, 5), dealer: hand(cards.King, 6, 9),
wantOutcome: OutcomeDealerBust, wantPayout: 195, wantRake: 5,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := settleWith(t, r, 100, tc.player, tc.dealer)
if s.Outcome != tc.wantOutcome {
t.Errorf("outcome = %q, want %q", s.Outcome, tc.wantOutcome)
}
if s.Payout != tc.wantPayout {
t.Errorf("payout = %d, want %d", s.Payout, tc.wantPayout)
}
if s.Rake != tc.wantRake {
t.Errorf("rake = %d, want %d", s.Rake, tc.wantRake)
}
// The invariant the ledger depends on: every chip the player staked
// either comes back, goes to the house as rake, or is lost to the table.
if s.Payout < 0 || s.Rake < 0 {
t.Errorf("negative chips: payout=%d rake=%d", s.Payout, s.Rake)
}
})
}
}
func TestSettle_RakeNeverTouchesTheStake(t *testing.T) {
// A 100% rake is absurd, but it must still never claw back a player's own
// stake: the worst a rake can do is take all the winnings.
r := Rules{Decks: 6, BlackjackPays: 1.5, RakePct: 1.0}
s := settleWith(t, r, 100, hand(cards.King, 9), hand(cards.King, 8))
if s.Payout != 100 {
t.Fatalf("payout = %d, want the stake back (100)", s.Payout)
}
if s.Net() != 0 {
t.Fatalf("net = %d, want 0", s.Net())
}
}
func TestNew_DealsFourCardsAndAskThePlayer(t *testing.T) {
rng := cards.NewRNG(1, 2)
s, evs, err := New(50, DefaultRules(), rng)
if err != nil {
t.Fatal(err)
}
if len(s.Player) != 2 || len(s.Dealer) != 2 {
t.Fatalf("dealt %d/%d cards, want 2/2", len(s.Player), len(s.Dealer))
}
if len(s.Deck) != 6*52-4 {
t.Fatalf("shoe has %d cards left, want %d", len(s.Deck), 6*52-4)
}
if len(evs) == 0 || evs[0].Kind != "deal" {
t.Fatal("no deal event")
}
// Unless somebody was dealt a natural, it's the player's move.
if !IsBlackjack(s.Player) && !IsBlackjack(s.Dealer) && s.Phase != PhasePlayer {
t.Fatalf("phase = %q, want %q", s.Phase, PhasePlayer)
}
}
func TestNew_RejectsNonPositiveBet(t *testing.T) {
for _, bet := range []int64{0, -100} {
if _, _, err := New(bet, DefaultRules(), cards.NewRNG(1, 2)); err == nil {
t.Fatalf("bet %d was accepted", bet)
}
}
}
func TestNew_NaturalSettlesImmediately(t *testing.T) {
// Search seeds for a deal that gives the player a natural, then assert the
// hand is already over — a player holding blackjack is never asked to hit.
for seed := uint64(1); seed < 200; seed++ {
s, _, err := New(100, DefaultRules(), cards.NewRNG(seed, seed))
if err != nil {
t.Fatal(err)
}
if IsBlackjack(s.Player) {
if s.Phase != PhaseDone {
t.Fatalf("seed %d: player has a natural but phase = %q", seed, s.Phase)
}
if _, _, err := ApplyMove(s, Hit); err != ErrHandOver {
t.Fatalf("seed %d: hitting a settled natural gave %v, want ErrHandOver", seed, err)
}
return
}
}
t.Skip("no natural dealt in 200 seeds")
}
func TestApplyMove_HitUntilBustSettles(t *testing.T) {
s, _, err := New(100, DefaultRules(), cards.NewRNG(7, 7))
if err != nil {
t.Fatal(err)
}
if s.Phase == PhaseDone {
t.Skip("dealt a natural; not the hand under test")
}
for i := 0; i < 12 && s.Phase == PhasePlayer; i++ {
s, _, err = ApplyMove(s, Hit)
if err != nil {
t.Fatal(err)
}
}
if s.Phase != PhaseDone {
t.Fatal("hitting a dozen times never ended the hand")
}
if v, _ := HandValue(s.Player); v <= 21 {
t.Fatalf("player stopped at %d without busting — the loop should have gone over", v)
}
if s.Outcome != OutcomeBust || s.Payout != 0 {
t.Fatalf("outcome=%q payout=%d, want bust/0", s.Outcome, s.Payout)
}
// A busted player must not have made the dealer draw.
if len(s.Dealer) != 2 {
t.Fatalf("dealer drew %d cards against a busted player", len(s.Dealer)-2)
}
}
func TestApplyMove_StandRunsTheDealerOut(t *testing.T) {
s, _, err := New(100, DefaultRules(), cards.NewRNG(3, 9))
if err != nil {
t.Fatal(err)
}
if s.Phase == PhaseDone {
t.Skip("dealt a natural")
}
s, evs, err := ApplyMove(s, Stand)
if err != nil {
t.Fatal(err)
}
if s.Phase != PhaseDone {
t.Fatalf("phase = %q after stand, want done", s.Phase)
}
v, soft := HandValue(s.Dealer)
if v < 17 {
t.Fatalf("dealer stood on %d, must draw below 17", v)
}
if v == 17 && soft {
t.Fatal("dealer stood on soft 17; the house rule says hit")
}
var reveal bool
for _, e := range evs {
if e.Kind == "reveal" {
reveal = true
}
}
if !reveal {
t.Fatal("dealer played without a reveal event")
}
}
func TestApplyMove_DoubleTakesOneCardThenStands(t *testing.T) {
s, _, err := New(100, DefaultRules(), cards.NewRNG(11, 4))
if err != nil {
t.Fatal(err)
}
if s.Phase == PhaseDone {
t.Skip("dealt a natural")
}
if !s.CanDouble() {
t.Fatal("double should be legal on the opening two cards")
}
s, _, err = ApplyMove(s, Double)
if err != nil {
t.Fatal(err)
}
if !s.Doubled || s.Bet != 200 {
t.Fatalf("bet = %d doubled = %v, want 200/true", s.Bet, s.Doubled)
}
if len(s.Player) != 3 {
t.Fatalf("player has %d cards after a double, want exactly 3", len(s.Player))
}
if s.Phase != PhaseDone {
t.Fatal("a double must end the player's turn")
}
}
func TestApplyMove_DoubleIsIllegalAfterHitting(t *testing.T) {
s, _, err := New(100, DefaultRules(), cards.NewRNG(5, 5))
if err != nil {
t.Fatal(err)
}
if s.Phase == PhaseDone {
t.Skip("dealt a natural")
}
s, _, err = ApplyMove(s, Hit)
if err != nil {
t.Fatal(err)
}
if s.Phase != PhasePlayer {
t.Skip("busted on the hit; not the hand under test")
}
before := s.Bet
after, _, err := ApplyMove(s, Double)
if err != ErrCantDouble {
t.Fatalf("double after a hit gave %v, want ErrCantDouble", err)
}
if after.Bet != before {
t.Fatalf("a rejected double still moved the bet: %d -> %d", before, after.Bet)
}
if s.CanDouble() {
t.Fatal("CanDouble says yes on a three-card hand")
}
}
func TestApplyMove_RejectsGarbage(t *testing.T) {
s, _, err := New(100, DefaultRules(), cards.NewRNG(2, 8))
if err != nil {
t.Fatal(err)
}
if _, _, err := ApplyMove(s, Move("surrender")); err != ErrUnknownMove {
t.Fatalf("got %v, want ErrUnknownMove", err)
}
}
// The engine's state has to survive a redeploy: no timers, no pointers, no
// unexported fields that JSON would quietly drop.
func TestState_RoundTripsThroughJSON(t *testing.T) {
s, _, err := New(100, DefaultRules(), cards.NewRNG(13, 21))
if err != nil {
t.Fatal(err)
}
if s.Phase == PhaseDone {
t.Skip("dealt a natural")
}
blob, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
var back State
if err := json.Unmarshal(blob, &back); err != nil {
t.Fatal(err)
}
// Play both forward identically; a state that survives the trip settles the same.
live, _, err := ApplyMove(s, Stand)
if err != nil {
t.Fatal(err)
}
revived, _, err := ApplyMove(back, Stand)
if err != nil {
t.Fatal(err)
}
if live.Outcome != revived.Outcome || live.Payout != revived.Payout {
t.Fatalf("revived hand settled differently: %q/%d vs %q/%d",
revived.Outcome, revived.Payout, live.Outcome, live.Payout)
}
}
// Same seed, same shoe — this is what lets a disputed hand be re-dealt.
func TestNew_IsReproducibleFromItsSeed(t *testing.T) {
a, _, err := New(100, DefaultRules(), cards.NewRNG(42, 42))
if err != nil {
t.Fatal(err)
}
b, _, err := New(100, DefaultRules(), cards.NewRNG(42, 42))
if err != nil {
t.Fatal(err)
}
if cards.Hand(a.Player) != cards.Hand(b.Player) || 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))
}
}
// A State handed to ApplyMove twice must produce two independent hands. If the
// engine let derived states share a backing array, the second deal would scribble
// over the first one's cards — and a player could watch a card change under them.
func TestApplyMove_DerivedStatesDoNotShareCards(t *testing.T) {
s, _, err := New(100, DefaultRules(), cards.NewRNG(23, 5))
if err != nil {
t.Fatal(err)
}
if s.Phase == PhaseDone {
t.Skip("dealt a natural")
}
before := cards.Hand(s.Player)
a, _, err := ApplyMove(s, Hit)
if err != nil {
t.Fatal(err)
}
aHand := cards.Hand(a.Player)
if _, _, err := ApplyMove(s, Hit); err != nil { // same start, applied again
t.Fatal(err)
}
if got := cards.Hand(a.Player); got != aHand {
t.Fatalf("the first hand changed under us: %q became %q", aHand, got)
}
if got := cards.Hand(s.Player); got != before {
t.Fatalf("ApplyMove mutated the state it was given: %q became %q", before, got)
}
}

View File

@@ -0,0 +1,126 @@
// Package cards holds the deck primitives every card game on Pete shares.
//
// gogobee never had this: blackjack carried its own deck, UNO carried another,
// and hold'em leaned on a third-party one. Three shuffles, three bugs to fix
// three times. The games ported over here consolidate onto this instead.
//
// Two rules hold throughout:
//
// The RNG is threaded, never global. Every shuffle takes an explicit *rand.Rand,
// so a hand is reproducible from its seed — which is what makes the engines
// testable, and what lets us re-deal a disputed hand and show the player exactly
// what the shoe did.
//
// A Deck is a plain value. No pointers into it, no timers hanging off it, so a
// game in progress serializes to JSON and survives a redeploy.
package cards
import "math/rand/v2"
// Suit is one of the four French suits.
type Suit uint8
const (
Spades Suit = iota
Hearts
Diamonds
Clubs
)
// Rank runs Ace(1) through King(13). Ace is low here; games that want it high
// (blackjack's soft 11, hold'em's wheel) say so themselves.
type Rank uint8
const (
Ace Rank = 1
Jack Rank = 11
Queen Rank = 12
King Rank = 13
)
var (
suitGlyphs = [4]string{"♠", "♥", "♦", "♣"}
rankNames = [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
)
// Card is one playing card. The short JSON keys keep a serialized shoe small —
// a six-deck blackjack state is 312 of these.
type Card struct {
Rank Rank `json:"r"`
Suit Suit `json:"s"`
}
// String renders the card the way a table shows it: "A♠", "10♥".
func (c Card) String() string {
if c.Rank < Ace || c.Rank > King || c.Suit > Clubs {
return "??"
}
return rankNames[c.Rank] + suitGlyphs[c.Suit]
}
// Red reports whether the card is a red suit — the one thing every renderer
// needs and nobody should re-derive.
func (c Card) Red() bool { return c.Suit == Hearts || c.Suit == Diamonds }
// Deck is an ordered pile of cards. The next card to come off is at index 0.
type Deck []Card
// NewDeck builds n standard 52-card decks in fixed order. Shuffle before use:
// an unshuffled deck is a bug at a table, but it's exactly what a test wants.
func NewDeck(n int) Deck {
if n < 1 {
n = 1
}
d := make(Deck, 0, 52*n)
for i := 0; i < n; i++ {
for s := Spades; s <= Clubs; s++ {
for r := Ace; r <= King; r++ {
d = append(d, Card{Rank: r, Suit: s})
}
}
}
return d
}
// Shuffle permutes the deck in place using the supplied RNG. Passing a seeded
// *rand.Rand gives the same shuffle every time, which is the whole point.
func (d Deck) Shuffle(rng *rand.Rand) {
rng.Shuffle(len(d), func(i, j int) { d[i], d[j] = d[j], d[i] })
}
// Draw takes the top card. ok is false when the deck is spent; the caller
// decides whether that means reshuffle or fold, because the two games that hit
// it disagree.
func (d *Deck) Draw() (c Card, ok bool) {
if len(*d) == 0 {
return Card{}, false
}
c = (*d)[0]
*d = (*d)[1:]
return c, true
}
// Hand renders a run of cards for display: "A♠ 10♥".
func Hand(cs []Card) string {
s := ""
for i, c := range cs {
if i > 0 {
s += " "
}
s += c.String()
}
return s
}
// NewRNG seeds a generator from two uint64s. Games store the seed alongside the
// hand so a finished hand can be replayed exactly as it was dealt.
func NewRNG(seed1, seed2 uint64) *rand.Rand {
return rand.New(rand.NewPCG(seed1, seed2))
}
func (s Suit) String() string {
if s > Clubs {
return "?"
}
return suitGlyphs[s]
}

View File

@@ -0,0 +1,102 @@
package cards
import "testing"
func TestNewDeck_IsAFullShoe(t *testing.T) {
d := NewDeck(6)
if len(d) != 312 {
t.Fatalf("six decks hold %d cards, want 312", len(d))
}
seen := map[Card]int{}
for _, c := range d {
seen[c]++
}
if len(seen) != 52 {
t.Fatalf("%d distinct cards, want 52", len(seen))
}
for c, n := range seen {
if n != 6 {
t.Fatalf("%s appears %d times in a six-deck shoe, want 6", c, n)
}
}
}
func TestNewDeck_ClampsToAtLeastOne(t *testing.T) {
if len(NewDeck(0)) != 52 {
t.Fatal("a zero-deck shoe should still hold one deck")
}
}
func TestShuffle_SameSeedSameOrder(t *testing.T) {
a, b := NewDeck(1), NewDeck(1)
a.Shuffle(NewRNG(99, 1))
b.Shuffle(NewRNG(99, 1))
for i := range a {
if a[i] != b[i] {
t.Fatalf("same seed diverged at %d: %s vs %s", i, a[i], b[i])
}
}
// And a different seed must not give the same order, or the RNG isn't wired up.
c := NewDeck(1)
c.Shuffle(NewRNG(100, 1))
same := true
for i := range a {
if a[i] != c[i] {
same = false
break
}
}
if same {
t.Fatal("a different seed produced an identical shuffle")
}
}
func TestShuffle_KeepsEveryCard(t *testing.T) {
d := NewDeck(1)
d.Shuffle(NewRNG(4, 4))
seen := map[Card]bool{}
for _, c := range d {
seen[c] = true
}
if len(d) != 52 || len(seen) != 52 {
t.Fatalf("shuffle lost cards: %d cards, %d distinct", len(d), len(seen))
}
}
func TestDraw_TakesFromTheTopAndRunsOut(t *testing.T) {
d := NewDeck(1)
top := d[0]
c, ok := d.Draw()
if !ok || c != top {
t.Fatalf("drew %s (ok=%v), want the top card %s", c, ok, top)
}
if len(d) != 51 {
t.Fatalf("deck has %d cards after one draw, want 51", len(d))
}
for len(d) > 0 {
d.Draw()
}
if _, ok := d.Draw(); ok {
t.Fatal("an empty deck kept dealing")
}
}
func TestCard_String(t *testing.T) {
tests := []struct {
card Card
want string
}{
{Card{Ace, Spades}, "A♠"},
{Card{10, Hearts}, "10♥"},
{Card{King, Clubs}, "K♣"},
{Card{Rank: 99, Suit: Spades}, "??"},
}
for _, tc := range tests {
if got := tc.card.String(); got != tc.want {
t.Errorf("String() = %q, want %q", got, tc.want)
}
}
if !(Card{Ace, Hearts}).Red() || (Card{Ace, Spades}).Red() {
t.Error("Red() disagrees about which suits are red")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,292 @@
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 *your* winnings is money the house
// actually made, and it is the only part the felt should quote you. The
// bots' chips are not real — the only real money at this table is yours —
// so raking a pot a bot won costs you nothing, and a counter that climbed
// while you folded would be telling you it had.
for _, w := range winners {
if w.seat == You {
s.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})
}

View File

@@ -0,0 +1,869 @@
// Package holdem is a pure Texas Hold'em engine, played for chips against bots.
//
// Same seam as every other table in the casino: ApplyMove(state, move) (state,
// events, error), where an error means the move was illegal and nothing else.
// No HTTP, no timers, no sockets. The state is a plain value, so a hand survives
// a redeploy and replays from its seed.
//
// Three things make hold'em different from the five tables already on the felt.
//
// It is a cash game, not a stake. Every other game here takes a bet, plays once
// and pays a multiple. Poker isn't that: you buy chips onto the table, you play
// as many hands as you like, and you leave with whatever is in front of you. So
// the session is the unit, not the hand — the live row lives across hands, the
// buy-in is the only chip movement at the start, and the stack going home is the
// only one at the end. In between the money is entirely inside this engine.
//
// The bots move inside ApplyMove, as they do in UNO. One call plays the player's
// action and every bot action behind it, deals whatever streets that completes,
// and hands the lot back as a script of events. Poker is where you would reach
// for a socket, and this is what not reaching for one costs.
//
// The bots are the trained ones. gogobee spent a long time running CFR against
// this game and the policy it converged on is the best asset in either repo; it
// is embedded here whole (see cfr.go). What that means for the player: the house
// edge at this table is not a rule, it is an opponent. There is no 3:2 and no
// multiple. If you beat the bots you win, and the only thing the house takes is
// the rake on the pots you win.
package holdem
import (
"errors"
"math/rand/v2"
"pete/internal/games/cards"
)
// Errors an illegal move can produce. An error means nothing happened.
var (
ErrOver = errors.New("holdem: you've left the table")
ErrNotYourTurn = errors.New("holdem: it isn't your turn")
ErrHandLive = errors.New("holdem: finish the hand first")
ErrNoHand = errors.New("holdem: no hand in progress")
ErrCantCheck = errors.New("holdem: there's a bet to you")
ErrNothingToCall = errors.New("holdem: nothing to call")
ErrTooSmall = errors.New("holdem: that's under the minimum raise")
ErrTooBig = errors.New("holdem: you don't have that many chips")
ErrNoChips = errors.New("holdem: you have no chips left")
ErrUnknownMove = errors.New("holdem: unknown move")
ErrUnknownTier = errors.New("holdem: no such table")
ErrBadBuyIn = errors.New("holdem: that isn't a legal buy-in")
ErrTableFull = errors.New("holdem: too many seats")
)
// You are always seat zero. The bots are the seats after you.
const You = 0
// rakeCapBB caps the rake on any one pot at three big blinds, which is what a
// cardroom does. Without a cap, five percent of a big pot is a lot of money to
// take off a player for winning it.
const rakeCapBB = 3
// Street is how far the board has come.
type Street uint8
const (
PreFlop Street = iota
Flop
Turn
River
Showdown
)
var streetNames = [5]string{"preflop", "flop", "turn", "river", "showdown"}
func (s Street) String() string {
if int(s) >= len(streetNames) {
return "?"
}
return streetNames[s]
}
// SeatState is where a player stands in the hand being played.
type SeatState uint8
const (
Active SeatState = iota // still has chips and a say
Folded // out of this hand
AllIn // in the hand, but has nothing left to bet
Out // not dealt in at all
)
// Seat is one player at the table — you, or one of the bots.
//
// Hole is on the server and stays there. The view layer sends your two cards to
// you and sends nobody else's to anybody, right up until a showdown turns them
// over. A bot's cards are most of the information in this game; a browser that
// held them would make counting cards a matter of reading the network tab.
type Seat struct {
Name string `json:"name"`
Bot bool `json:"bot"`
Stack int64 `json:"stack"`
Hole [2]cards.Card `json:"hole"`
Bet int64 `json:"bet"` // put in on this street
Total int64 `json:"total"` // put in across this hand
Won int64 `json:"won"` // taken out of the pot this hand
State SeatState `json:"state"`
Acted bool `json:"acted"` // has chosen to do something this street
}
// Pot is a pot and the seats that may win it. A hand with no all-in has exactly
// one; every all-in at a distinct level adds another.
type Pot struct {
Amount int64 `json:"amount"`
Eligible []int `json:"eligible"`
}
// Tier is a table you can sit at. The dial is the stakes, and the stakes are
// what make a chip mean something: at 25/50 a careless call costs more than a
// whole session at 1/2.
//
// The buy-in range is the standard 20 to 100 big blinds. Sitting down short is
// a real strategy (fewer decisions, less to lose) and sitting down deep is the
// other one, so the range is a choice and not a formality.
type Tier struct {
Slug string `json:"slug"`
Name string `json:"name"`
SB int64 `json:"sb"`
BB int64 `json:"bb"`
MinBuy int64 `json:"min_buy"`
MaxBuy int64 `json:"max_buy"`
RakePct float64 `json:"rake_pct"`
Blurb string `json:"blurb"`
}
// Tiers are the three tables. The rake is the casino's five percent everywhere,
// capped at three big blinds a pot, and taken only from a pot that saw a flop.
//
// RakePct is a *fraction*, 0.05, because that is what it is everywhere else in
// the casino — blackjack's DefaultRules says 0.05 and New() takes its word for it.
// It was 5 here for an afternoon, meaning percent, and since New overwrites the
// tier's value with the one it is handed, every rake worked out to five percent of
// a hundredth of the pot, which integer division rounded to nothing. The house took
// nothing at all and no test noticed, because every test set the tier up by hand.
var Tiers = []Tier{
{Slug: "micro", Name: "The Kitchen Table", SB: 1, BB: 2, MinBuy: 40, MaxBuy: 200, RakePct: 0.05,
Blurb: "1/2 blinds. Cheap enough to learn what the bots do to you."},
{Slug: "low", Name: "The Back Room", SB: 5, BB: 10, MinBuy: 200, MaxBuy: 1000, RakePct: 0.05,
Blurb: "5/10. A bluff here costs real chips, which is the only reason a bluff works."},
{Slug: "high", Name: "The High Roller", SB: 25, BB: 50, MinBuy: 1000, MaxBuy: 5000, RakePct: 0.05,
Blurb: "25/50. Three streets of this and you know whether you can play."},
}
// TierBySlug finds a table 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
}
// MaxBots is five, which with you makes a six-handed table — the size most
// online poker is actually played at, and as many opponents as the felt can
// show without the cards getting too small to read.
const MaxBots = 5
// Phase is what the table is waiting for.
type Phase string
const (
PhaseBetting Phase = "betting" // a hand is live and it's your turn
PhaseHandOver Phase = "handover" // the hand is paid; deal, top up, or leave
PhaseDone Phase = "done" // you're up from the table; the stack goes home
)
// State is the whole table. It never leaves the server: the deck is in here,
// and so is every bot's hand.
type State struct {
Tier Tier `json:"tier"`
Seats []Seat `json:"seats"`
Button int `json:"button"`
HandNo int `json:"hand_no"`
Deck cards.Deck `json:"deck"`
Community []cards.Card `json:"community"`
Street Street `json:"street"`
Flopped bool `json:"flopped"` // this hand saw a flop, so its pot is rakeable
Pot int64 `json:"pot"`
Side []Pot `json:"side,omitempty"`
Bet int64 `json:"bet"` // the bet to match on this street
MinRaise int64 `json:"min_raise"` // the smallest legal raise over it
Aggressor int `json:"aggressor"`
ToAct int `json:"to_act"`
// History is the action so far on this street, as the f/c/r/R/a characters
// the CFR policy was trained to read. It is the bots' memory and nothing else.
History string `json:"history"`
Phase Phase `json:"phase"`
// The money that crosses the border. BoughtIn is every chip staked onto this
// table; Payout is the stack that goes home, and is only set once you're up.
BoughtIn int64 `json:"bought_in"`
Payout int64 `json:"payout"`
// Two rakes, and they are different numbers.
//
// Rake is every chip the house has lifted off this table. It exists so the
// chips balance: a pot that is raked is a pot that pays out less than it holds,
// and the difference has to be somewhere.
//
// Paid is the part of that which came out of a pot *you* won — the only part
// that is real money, and the only part worth quoting you. Rake a pot a bot
// wins and you have paid nothing; a counter that climbed anyway while you sat
// folding would be lying to you.
Rake int64 `json:"rake"`
Paid int64 `json:"paid"`
// The seed rides in the state for the same reason it does in UNO: the bots
// choose and the deck is reshuffled every hand, so the engine needs randomness
// mid-session — and there is no generator alive between two HTTP requests to
// hand it. Each step derives its own from the seed and the step count, so the
// session still replays exactly as it fell.
Seed1 uint64 `json:"seed1"`
Seed2 uint64 `json:"seed2"`
Step uint64 `json:"step"`
}
// Event is one beat of the script the felt plays back. Seat is -1 when the beat
// belongs to the table rather than a player.
type Event struct {
Kind string `json:"kind"`
Seat int `json:"seat"`
Cards []cards.Card `json:"cards,omitempty"`
Amount int64 `json:"amount,omitempty"`
Total int64 `json:"total,omitempty"`
Text string `json:"text,omitempty"`
}
// The moves a player can make. The betting five, plus the three that are about
// the session rather than the hand.
const (
Fold = "fold"
Check = "check"
Call = "call"
Raise = "raise"
Shove = "allin" // the move; AllIn is the seat state it puts you in
Deal = "deal" // next hand
TopUp = "topup" // put more chips on the table, between hands
Leave = "leave" // get up; the stack goes back to your stack
)
// Move is what the browser sends. To is the total a raise raises *to*, and
// Amount is chips added in a top-up.
type Move struct {
Kind string `json:"move"`
To int64 `json:"to,omitempty"`
Amount int64 `json:"amount,omitempty"`
}
// botNames are the regulars. Six of them so a full table never has two.
var botNames = []string{"Dice", "Marjorie", "Ox", "Sunny", "Pinch", "The Reverend"}
// New sits you down. The buy-in is chips the caller has already taken off the
// player's stack; this engine only ever gives them back through Leave.
//
// No hand is dealt yet — the table opens on PhaseHandOver, which is the state a
// table between hands is in, and the first Deal move starts the first hand.
func New(t Tier, bots int, buyIn int64, rakePct float64, seed1, seed2 uint64) (State, []Event, error) {
if bots < 1 || bots > MaxBots {
return State{}, nil, ErrTableFull
}
if buyIn < t.MinBuy || buyIn > t.MaxBuy {
return State{}, nil, ErrBadBuyIn
}
t.RakePct = rakePct
s := State{
Tier: t,
Button: 0,
Phase: PhaseHandOver,
BoughtIn: buyIn,
Seed1: seed1,
Seed2: seed2,
}
s.Seats = append(s.Seats, Seat{Name: "You", Stack: buyIn})
for i := 0; i < bots; i++ {
s.Seats = append(s.Seats, Seat{Name: botNames[i], Bot: true, Stack: t.MaxBuy})
}
// The button starts to your right, so the first hand deals you the small blind
// heads-up and the button on a full table — either way you are in the action
// from the first card rather than folding your way in.
s.Button = len(s.Seats) - 1
evs := []Event{{Kind: "sit", Seat: You, Amount: buyIn, Text: t.Name}}
return s, evs, nil
}
// ApplyMove is the whole engine. It plays your move, then every bot behind you,
// deals whatever streets that finishes, and stops either when it is your turn
// again or when the hand is over.
func ApplyMove(s State, m Move) (State, []Event, error) {
if s.Phase == PhaseDone {
return s, nil, ErrOver
}
rng := s.next()
evs := []Event{}
switch m.Kind {
case Deal:
if s.Phase != PhaseHandOver {
return s, nil, ErrHandLive
}
s.deal(&evs, rng)
s.advance(&evs, rng, true)
case TopUp:
if s.Phase != PhaseHandOver {
return s, nil, ErrHandLive
}
if m.Amount <= 0 || s.Seats[You].Stack+m.Amount > s.Tier.MaxBuy {
return s, nil, ErrBadBuyIn
}
s.Seats[You].Stack += m.Amount
s.BoughtIn += m.Amount
evs = append(evs, Event{Kind: "topup", Seat: You, Amount: m.Amount})
case Leave:
if s.Phase != PhaseHandOver {
return s, nil, ErrHandLive
}
s.Phase = PhaseDone
s.Payout = s.Seats[You].Stack
evs = append(evs, Event{Kind: "leave", Seat: You, Amount: s.Payout})
case Fold, Check, Call, Raise, Shove:
if s.Phase != PhaseBetting {
return s, nil, ErrNoHand
}
if s.ToAct != You {
return s, nil, ErrNotYourTurn
}
if err := s.act(You, m, &evs); err != nil {
return s, nil, err // nothing happened; the caller keeps the old state
}
s.ToAct = s.nextCanAct(You)
s.advance(&evs, rng, true)
default:
return s, nil, ErrUnknownMove
}
return s, evs, nil
}
// step plays a move for whoever is to act — bot or player — and advances only as
// far as the next decision, whoever's it is. Nobody's turn is taken for them.
//
// This is the seam the trainer plays through, and it exists so that the trainer
// is playing *this* game: the same betting rules, the same street completion, the
// same side pots, the same money. The alternative is a second, simplified model
// of poker written for the trainer alone — which is what gogobee had, and it is
// why its policy encoded a game nobody was dealing.
func step(s State, m Move) (State, []Event, error) {
if s.Phase != PhaseBetting {
return s, nil, ErrNoHand
}
rng := s.next()
evs := []Event{}
seat := s.ToAct
if err := s.act(seat, m, &evs); err != nil {
return s, nil, err
}
s.ToAct = s.nextCanAct(seat)
s.advance(&evs, rng, false)
return s, evs, nil
}
// open deals one heads-up hand at the given stacks, stopping at the first
// decision. For the trainer: a table with no history and no button rotation.
func open(t Tier, stack0, stack1 int64, seed1, seed2 uint64) (State, error) {
if stack0 <= 0 || stack1 <= 0 {
return State{}, ErrBadBuyIn
}
s := State{
Tier: t,
Phase: PhaseHandOver,
Seats: []Seat{
{Name: "0", Stack: stack0},
{Name: "1", Stack: stack1, Bot: true},
},
Button: 1, // deal() moves it, so seat 0 takes the button and the small blind
Seed1: seed1,
Seed2: seed2,
}
rng := s.next()
evs := []Event{}
s.deal(&evs, rng)
s.advance(&evs, rng, false)
return s, nil
}
// clone deep-copies the table. CFR walks a tree of what-ifs, and a shallow copy
// would have every branch writing into the same deck.
func (s State) clone() State {
out := s
out.Seats = append([]Seat(nil), s.Seats...)
out.Deck = append(cards.Deck(nil), s.Deck...)
out.Community = append([]cards.Card(nil), s.Community...)
out.Side = make([]Pot, len(s.Side))
for i, p := range s.Side {
out.Side[i] = Pot{Amount: p.Amount, Eligible: append([]int(nil), p.Eligible...)}
}
return out
}
// act applies one seat's action, whoever it belongs to.
func (s *State) act(seat int, m Move, evs *[]Event) error {
switch m.Kind {
case Fold:
s.fold(seat, evs)
return nil
case Check:
return s.check(seat, evs)
case Call:
return s.call(seat, evs)
case Raise:
return s.raise(seat, m.To, evs)
case Shove:
return s.allin(seat, evs)
}
return ErrUnknownMove
}
// advance runs the table forward until you have a decision to make, or until
// there is nothing left to decide.
//
// This is the loop the whole design turns on. Every other engine here returns
// after one move because there is nobody else at the table; this one keeps going
// — bot, bot, flop, bot, turn — and only stops when the answer has to come from
// the player. Which is why one HTTP request can be a whole hand: shove all-in
// and the board runs out and the pot is paid inside a single call.
//
// With bots false it stops at every decision instead of playing the bots' for
// them. That is the trainer's way in: it wants to choose both seats' moves.
func (s *State) advance(evs *[]Event, rng *rand.Rand, bots bool) {
for {
// Everyone else folded. Nobody shows; the last one standing takes it.
if s.liveCount() <= 1 {
s.takeit(evs)
return
}
// Nobody left with a decision to make: the rest of the board is a formality,
// so deal it and turn the cards over.
//
// The subtle half is the lone player who still has chips. They only have a
// decision if there is a bet to them — call it or fold. If there isn't, they
// have nobody left to bet *into*, because everyone else is already all-in,
// and poker does not let you put chips in a pot nobody can contest.
switch s.canActCount() {
case 0:
s.runout(evs)
return
case 1:
lone := s.onlyActor()
if s.Owed(lone) == 0 {
s.runout(evs)
return
}
s.ToAct = lone
}
if s.streetDone(s.ToAct) {
if s.Street == River {
s.showdown(evs)
return
}
s.street(evs)
continue
}
if s.ToAct == You || !bots {
return // a decision that isn't ours to make
}
s.botActs(s.ToAct, evs, rng)
s.ToAct = s.nextCanAct(s.ToAct)
}
}
// deal starts a hand: rebuy the broke bots, move the button, shuffle, post the
// blinds, and put two cards in front of everybody.
func (s *State) deal(evs *[]Event, rng *rand.Rand) {
s.HandNo++
for i := range s.Seats {
p := &s.Seats[i]
// A bot that has been ground down to nothing reloads. It has to: a table
// where you have taken everybody's chips is a table with no game left in it,
// and their chips were never real anyway — the only real money at this table
// is yours, and the only thing the house takes is the rake.
if p.Bot && p.Stack < s.Tier.BB {
add := s.Tier.MaxBuy - p.Stack
p.Stack = s.Tier.MaxBuy
*evs = append(*evs, Event{Kind: "rebuy", Seat: i, Amount: add, Total: p.Stack})
}
p.Bet, p.Total, p.Won, p.Acted = 0, 0, 0, false
p.Hole = [2]cards.Card{}
p.State = Active
if p.Stack <= 0 {
p.State = Out
}
}
s.Community = nil
s.Side = nil
s.Pot = 0
s.Street = PreFlop
s.Flopped = false
s.History = ""
s.Phase = PhaseBetting
s.Deck = cards.NewDeck(1)
s.Deck.Shuffle(rng)
s.Button = s.nextIn(s.Button)
*evs = append(*evs, Event{Kind: "hand", Seat: s.Button, Amount: int64(s.HandNo)})
bb := s.blinds(evs)
// Two cards each, one at a time round the table, as they are actually dealt.
for round := 0; round < 2; round++ {
for i := 0; i < len(s.Seats); i++ {
seat := (s.Button + 1 + i) % len(s.Seats)
p := &s.Seats[seat]
if p.State == Out {
continue
}
c, _ := s.Deck.Draw()
p.Hole[round] = c
}
}
// Only your cards go into the script. The bots' are in the state, on this side
// of the wire, and the only thing that ever turns them over is a showdown.
*evs = append(*evs, Event{Kind: "hole", Seat: You,
Cards: []cards.Card{s.Seats[You].Hole[0], s.Seats[You].Hole[1]}})
s.ToAct = s.firstPreFlop(bb)
}
// street burns one and deals the next board card or three.
func (s *State) street(evs *[]Event) {
s.collect()
s.resetBets()
s.Deck.Draw() // the burn card, as printed in the rules and as dealt in a casino
switch s.Street {
case PreFlop:
s.Street = Flop
s.Flopped = true
for i := 0; i < 3; i++ {
c, _ := s.Deck.Draw()
s.Community = append(s.Community, c)
}
case Flop:
s.Street = Turn
c, _ := s.Deck.Draw()
s.Community = append(s.Community, c)
case Turn:
s.Street = River
c, _ := s.Deck.Draw()
s.Community = append(s.Community, c)
}
// Total, not Pot: by the time a board runs out behind an all-in the money has
// already been cut into side pots, and s.Pot is zero.
*evs = append(*evs, Event{Kind: s.Street.String(), Seat: -1,
Cards: s.Community[len(s.Community)-cardsOn(s.Street):], Amount: s.Total()})
s.ToAct = s.firstPostFlop()
s.Aggressor = s.ToAct // nobody has bet yet, so the option ends where it starts
}
func cardsOn(st Street) int {
if st == Flop {
return 3
}
return 1
}
// runout deals the rest of the board with no more betting, because there is no
// longer anybody able to bet. The side pots are built first: once the chips stop
// moving, who can win what is already decided.
func (s *State) runout(evs *[]Event) {
allIn := false
for i := range s.Seats {
if s.Seats[i].State == AllIn {
allIn = true
break
}
}
if allIn {
// A shove nobody could cover comes back first — while it is still a bet in
// front of a seat, and before the pots are cut around it.
s.uncalled(evs)
}
s.collect()
if allIn {
s.sidePots()
}
for s.Street < River {
s.street(evs)
}
s.showdown(evs)
}
// endHand pays out and parks the table between hands.
func (s *State) endHand(evs *[]Event) {
s.Pot = 0
s.Side = nil
s.Phase = PhaseHandOver
*evs = append(*evs, Event{Kind: "end", Seat: -1, Amount: s.Seats[You].Stack})
// Busting is the end of the session, not the end of a hand. There is nothing
// to deal you and nothing to give back, so the table closes and you sit down
// again — which is a buy-in, and a buy-in is a decision worth making on purpose.
if s.Seats[You].Stack <= 0 {
s.Phase = PhaseDone
s.Payout = 0
*evs = append(*evs, Event{Kind: "bust", Seat: You})
}
}
// ---- the small stuff -------------------------------------------------------
// next derives this step's generator and advances the step count.
func (s *State) next() *rand.Rand {
s.Step++
return cards.NewRNG(s.Seed1, s.Seed2^s.Step)
}
// collect sweeps the street's bets into the pot.
func (s *State) collect() {
for i := range s.Seats {
s.Pot += s.Seats[i].Bet
s.Seats[i].Bet = 0
}
}
// resetBets opens a new street: nothing to call, and nobody has spoken.
func (s *State) resetBets() {
for i := range s.Seats {
s.Seats[i].Bet = 0
s.Seats[i].Acted = false
}
s.Bet = 0
s.MinRaise = s.Tier.BB
s.History = ""
}
// inPlay is the pot plus everything bet on this street — what a bot is actually
// deciding against, and what a pot-sized raise is a size of.
func (s State) inPlay() int64 {
total := s.Pot
for i := range s.Seats {
total += s.Seats[i].Bet
}
return total
}
// Pot returns the money on the table, however it is currently sliced.
func (s State) Total() int64 {
total := s.inPlay()
for _, p := range s.Side {
total += p.Amount
}
return total
}
// liveCount is the seats still in the hand, whether or not they can bet.
func (s State) liveCount() int {
n := 0
for i := range s.Seats {
if st := s.Seats[i].State; st == Active || st == AllIn {
n++
}
}
return n
}
// canActCount is the seats that still have chips and a decision.
func (s State) canActCount() int {
n := 0
for i := range s.Seats {
if s.Seats[i].State == Active {
n++
}
}
return n
}
// dealt is the seats in this hand at all.
func (s State) dealt() int {
n := 0
for i := range s.Seats {
if s.Seats[i].State != Out {
n++
}
}
return n
}
// nextIn is the next seat that was dealt in — used to move the button, which
// moves past a seat that is sitting out rather than landing on it.
func (s State) nextIn(from int) int {
n := len(s.Seats)
for i := 1; i <= n; i++ {
next := (from + i) % n
if st := s.Seats[next].State; st == Active || st == AllIn {
return next
}
}
return from
}
// onlyActor is the one seat that can still act. Call it when canActCount is 1.
func (s State) onlyActor() int {
for i := range s.Seats {
if s.Seats[i].State == Active {
return i
}
}
return s.ToAct
}
// anyAllIn reports whether anybody is in the hand with no chips left, which is
// the only thing that makes side pots necessary.
func (s State) anyAllIn() bool {
for i := range s.Seats {
if s.Seats[i].State == AllIn {
return true
}
}
return false
}
// canBet reports whether there is anybody left to bet *into*. With one player
// still holding chips and the rest all-in, a raise is chips nobody can call, so
// no raise is offered — to the player or to a bot.
func (s State) canBet() bool { return s.canActCount() > 1 }
// nextCanAct is the next seat with a decision to make.
func (s State) nextCanAct(from int) int {
n := len(s.Seats)
for i := 1; i <= n; i++ {
next := (from + i) % n
if s.Seats[next].State == Active {
return next
}
}
return from
}
// Owed is what the seat must put in to call.
func (s State) Owed(seat int) int64 {
owed := s.Bet - s.Seats[seat].Bet
if owed < 0 {
return 0
}
if owed > s.Seats[seat].Stack {
return s.Seats[seat].Stack
}
return owed
}
// MinRaiseTo is the smallest total a raise may raise to, clamped to a shove when
// the seat cannot cover a full one.
func (s State) MinRaiseTo(seat int) int64 {
to := s.Bet + s.MinRaise
if most := s.Seats[seat].Bet + s.Seats[seat].Stack; to > most {
return most
}
return to
}
// MaxRaiseTo is everything the seat has.
func (s State) MaxRaiseTo(seat int) int64 {
return s.Seats[seat].Bet + s.Seats[seat].Stack
}
// CanRaise reports whether the seat may raise: they need chips behind the call,
// and somebody left to bet into. The felt asks so it never offers a button the
// table would refuse.
func (s State) CanRaise(seat int) bool {
return s.mask(seat)[actRaiseHalf]
}
// InPosition reports whether the seat acts last after the flop, which is the
// only thing about position the trained bots actually know.
//
// The postflop order runs from the seat left of the button all the way round to
// the button itself, so the player in position is simply the last one still in
// the hand — the button, or whoever is nearest to it once the button has folded.
// The policy was trained heads-up, where this is exactly the button; applying it
// to a six-handed table is an approximation, and this is where the approximation
// lives.
func (s State) InPosition(seat int) bool {
last := -1
for i := 1; i <= len(s.Seats); i++ {
at := (s.Button + i) % len(s.Seats)
if st := s.Seats[at].State; st == Active || st == AllIn {
last = at
}
}
return seat == last
}
// Position is the seat's label at this table — BTN, SB, BB, and so on. It is for
// the felt to print. The bots do not use it: see InPosition, and the note on
// infoSet about what happens when you confuse the two.
func (s State) Position(seat int) string {
n := s.dealt()
if n < 2 {
return ""
}
if seat == s.Button {
return "BTN"
}
if n == 2 {
return "BB" // heads-up, the other seat is always the big blind
}
sb := s.nextIn(s.Button)
bb := s.nextIn(sb)
switch seat {
case sb:
return "SB"
case bb:
return "BB"
}
utg := s.nextIn(bb)
if seat == utg {
return "UTG"
}
// Everyone between UTG and the button is somewhere in the middle; the seat
// closest to the button is the cutoff.
dist, cur := 0, utg
for i := 0; i < n; i++ {
cur = s.nextIn(cur)
dist++
if cur == seat {
break
}
}
if remaining := n - 4; remaining > 0 && dist >= remaining {
return "CO"
}
return "MP"
}

View File

@@ -0,0 +1,728 @@
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, 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 = ApplyMove(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 = ApplyMove(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
}
// 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 := ApplyMove(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, _, _ = ApplyMove(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, _, _ = ApplyMove(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 := ApplyMove(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, 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)
}
}
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, _, _ = ApplyMove(s, Move{Kind: Deal})
if s.Phase != PhaseBetting {
t.Skip("the hand ended before the player could act")
}
if _, _, err := ApplyMove(s, Move{Kind: Leave}); err != ErrHandLive {
t.Errorf("leaving mid-hand gave %v, want ErrHandLive", err)
}
if _, _, err := ApplyMove(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, _, _ = ApplyMove(s, Move{Kind: Deal})
s = playOut(t, s)
stack := s.Seats[You].Stack
s, _, err := ApplyMove(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 := ApplyMove(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 := ApplyMove(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 := ApplyMove(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, 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 player is allowed to know ------------------------------------
func TestTheScriptNeverCarriesABotsCards(t *testing.T) {
rng := rand.New(rand.NewPCG(4, 4))
s := table(t, Tiers[0], 3, 200)
for hand := 0; hand < 20 && s.Phase != PhaseDone; hand++ {
s, evs, err := ApplyMove(s, Move{Kind: Deal})
if err != nil {
t.Fatal(err)
}
noBotCards(t, s, evs)
for s.Phase == PhaseBetting {
var next []Event
s, next, err = ApplyMove(s, randomMove(s, rng))
if err != nil {
t.Fatal(err)
}
noBotCards(t, s, next)
}
}
}
// A bot's cards may appear in exactly one kind of event: the showdown that turns
// them face up, which is the moment they stop being secret.
func noBotCards(t *testing.T, s State, evs []Event) {
t.Helper()
for _, e := range evs {
if len(e.Cards) == 0 || e.Seat < 0 || e.Kind == "show" {
continue
}
if e.Seat != You && s.Seats[e.Seat].Bot {
t.Fatalf("a %q event carries seat %d's cards (%v) — that's a bot's hand",
e.Kind, e.Seat, e.Cards)
}
}
}
// ---- 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, 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 = ApplyMove(s, Move{Kind: Deal})
if err != nil {
t.Fatal(err)
}
for s.Phase == PhaseBetting {
s, _, err = ApplyMove(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, 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 = ApplyMove(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
}

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

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

@@ -0,0 +1,132 @@
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
}
// 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
}
// botNames deals the bots their names. Flavour, and load-bearing flavour: "Kiwi
// played a +4" is a table, "Bot 2 played a +4" is a test fixture.
func botNames(n int, rng *rand.Rand) []string {
pool := append([]string(nil), botPool...)
rng.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
if n > len(pool) {
n = len(pool)
}
return pool[:n]
}
var botPool = []string{
"Kiwi", "Mochi", "Bramble", "Pixel", "Gus", "Nori", "Waffle", "Marzipan",
"Tuck", "Bebop", "Olive", "Rascal", "Peaches", "Dot", "Sable", "Clementine",
}

827
internal/games/uno/uno.go Normal file
View File

@@ -0,0 +1,827 @@
// Package uno is a pure UNO engine, played for chips against bots.
//
// Same seam as the other four tables: ApplyMove(state, move) (state, events,
// error), where an error means the move was illegal and nothing else. No HTTP,
// no timers, no sockets, no player names off the wire. The state is a plain
// value, so a game survives a redeploy and replays from its seed.
//
// Two things make UNO different from the tables already on the felt.
//
// The bots move inside ApplyMove. A turn-based game against opponents is
// normally where you reach for a socket, and the plan says solo UNO must not:
// so one call from the browser plays the player's move *and* every bot turn that
// follows it, and hands back the whole run as events. The table animates them in
// order. The browser is never waiting on the server to think of something.
//
// The RNG is in the state, not an argument. The bots make choices and a spent
// deck gets reshuffled, so the engine needs randomness mid-game — but a reducer
// that takes an rng is a reducer whose caller has to keep one alive across
// requests, and there isn't one: every move is a fresh process for all it knows.
// So the seed rides in the state (which never leaves the server; the deck is in
// there too) and each step derives its own generator from seed and step count.
// Value in, value out, and the game still replays exactly as it was dealt.
package uno
import (
"errors"
"math"
"math/rand/v2"
)
// Errors an illegal move can produce.
var (
ErrGameOver = errors.New("uno: the game is already over")
ErrNotYourTurn = errors.New("uno: it isn't your turn")
ErrNoSuchCard = errors.New("uno: you don't have that card")
ErrCantPlay = errors.New("uno: that card can't go on this one")
ErrNeedColor = errors.New("uno: pick a colour for the wild")
ErrCantPass = errors.New("uno: you can only pass on a card you just drew")
ErrMustPlayNow = errors.New("uno: play the card you drew, or pass")
ErrUnknownMove = errors.New("uno: unknown move")
ErrBadBet = errors.New("uno: bet must be positive")
ErrUnknownTier = errors.New("uno: no such tier")
)
// You are always seat zero. The bots are the seats after you.
const You = 0
// HandSize is the deal. Seven each, as printed on the box.
const HandSize = 7
// Color is a card's colour. Wild has none until it's played.
//
// Wild is deliberately the zero value. A wild played with no colour named is the
// one move in this game that must never be allowed to mean something, and a
// browser that leaves `color` out of the JSON sends a zero — so the zero has to
// be "no colour", not red. It was red for about an hour, and a wild with the
// field missing quietly went down as a red one.
type Color uint8
const (
Wild Color = iota
Red
Blue
Yellow
Green
)
var colorNames = [5]string{"wild", "red", "blue", "yellow", "green"}
func (c Color) String() string {
if c > Green {
return "?"
}
return colorNames[c]
}
// Playable reports whether a colour is one a wild may name — Wild itself isn't.
func (c Color) Playable() bool { return c >= Red && c <= Green }
// Value is what's printed on the face.
type Value uint8
const (
Zero Value = iota
One
Two
Three
Four
Five
Six
Seven
Eight
Nine
Skip
Reverse
DrawTwo
WildCard
WildDrawFour
)
var valueNames = [15]string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
"skip", "reverse", "+2", "wild", "+4"}
func (v Value) String() string {
if v > WildDrawFour {
return "?"
}
return valueNames[v]
}
// Action reports whether a card does something beyond being a number.
func (v Value) Action() bool { return v >= Skip }
// Card is one card. Short JSON keys: a hand of these crosses the wire on every
// poll, and a state holds all 108.
type Card struct {
Color Color `json:"c"`
Value Value `json:"v"`
}
// IsWild reports whether the card has no colour of its own.
func (c Card) IsWild() bool { return c.Value == WildCard || c.Value == WildDrawFour }
// CanPlayOn is the whole rule of UNO: match the colour in play, or match the
// face, or be a wild. Note it takes the colour *in play* rather than the top
// card's own colour — after a wild those are different, and the one that counts
// is the colour that was named.
func (c Card) CanPlayOn(top Card, topColor Color) bool {
if c.IsWild() {
return true
}
return c.Color == topColor || c.Value == top.Value
}
// NewDeck builds the 108: one zero and two each of 1-9, skip, reverse and +2 in
// every colour, plus four wilds and four wild draw fours. Unshuffled — Deal
// shuffles, and a test wants the fixed order.
func NewDeck() []Card {
d := make([]Card, 0, 108)
for _, col := range []Color{Red, Blue, Yellow, Green} {
d = append(d, Card{col, Zero})
for v := One; v <= DrawTwo; v++ {
d = append(d, Card{col, v}, Card{col, v})
}
}
for i := 0; i < 4; i++ {
d = append(d, Card{Wild, WildCard}, Card{Wild, WildDrawFour})
}
return d
}
// Tier is a table, and the table size *is* the difficulty. More bots is a longer
// shot — three of them going out before you is three ways to lose — so it pays
// more. This is the tier dial every other game here has, pointed at the one knob
// UNO actually has.
type Tier struct {
Slug string `json:"slug"`
Name string `json:"name"`
Bots int `json:"bots"`
Base float64 `json:"base"` // what going out first pays, before the rake
Blurb string `json:"blurb"`
}
// Tiers are the three tables.
//
// The multiples are not guesses. A player who simply plays the first legal card
// they hold — which is a real strategy, and a bad one — goes out first 43% of
// the time heads up, 32% at three seats and 27% at four. These pay a little
// under what that costs, so bad play loses slowly and good play (holding the
// wilds, dumping the colour you're long in, counting what a bot picked up) is
// worth roughly the house's edge. That is the game being about something.
var Tiers = []Tier{
{Slug: "duel", Name: "Duel", Bots: 1, Base: 2.2,
Blurb: "One bot, head to head. A reverse is a skip with two at the table."},
{Slug: "table", Name: "Table", Bots: 2, Base: 2.9,
Blurb: "Two bots. Twice the +4s pointed at you."},
{Slug: "full", Name: "Full House", Bots: 3, Base: 3.6,
Blurb: "Three bots, and any of them going out first takes your stake."},
}
// 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 (
PhasePlay Phase = "play" // your turn, play or draw
PhaseDrawn Phase = "drawn" // you drew a card you can play: play it or pass
PhaseDone Phase = "done"
)
// Outcome is how it ended.
type Outcome string
const (
OutcomeNone Outcome = ""
OutcomeWon Outcome = "won" // you went out first
OutcomeLost Outcome = "lost" // a bot did
OutcomeStuck Outcome = "stuck" // nobody can move and there are no cards left
)
// Won reports whether this outcome pays.
func (o Outcome) Won() bool { return o == OutcomeWon }
// State is one game. The bots' hands and the deck are in here, which is exactly
// why this value never crosses the wire — the browser gets counts instead.
type State struct {
Tier Tier `json:"tier"`
Hands [][]Card `json:"hands"` // seat 0 is you; the rest are bots
Bots []string `json:"bots"` // their names, one per bot seat (seat i is Bots[i-1])
Deck []Card `json:"deck"`
Discard []Card `json:"discard"` // the top card is the last one
Color Color `json:"color"` // the colour in play, which a wild renames
Turn int `json:"turn"`
Dir int `json:"dir"` // +1 clockwise, -1 after a reverse
Seed1 uint64 `json:"seed1"`
Seed2 uint64 `json:"seed2"`
Step uint64 `json:"step"` // how many moves have been applied; the rng's other half
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 bots' turns arrive as a run of
// these on the back of the player's own move, and the felt plays them in order.
type Event struct {
Kind string `json:"kind"` // see below
Seat int `json:"seat"` // who it happened to
Card *Card `json:"card,omitempty"` // the card played, or the one *you* drew
Color Color `json:"color,omitempty"` // the colour now in play, on a wild
N int `json:"n,omitempty"` // how many cards were drawn
Left int `json:"left"` // cards left in that seat's hand afterwards
Text string `json:"text,omitempty"`
}
// The kinds an Event comes in.
//
// deal the hands are dealt and the first card turned over
// play a card goes on the pile
// wild the colour was named (rides with the play it belongs to)
// draw cards come off the deck. A bot's are face down: Card is nil.
// forced the same, but not by choice — a +2 or a +4 landed on them
// pass the turn moves on with nothing played
// skip a seat loses its turn
// reverse the direction flips
// uno a hand is down to one card
// reshuffle the discard goes back under
// settle it's over
const (
EvDeal = "deal"
EvPlay = "play"
EvDraw = "draw"
EvForced = "forced"
EvPass = "pass"
EvSkip = "skip"
EvReverse = "reverse"
EvUno = "uno"
EvReshuffle = "reshuffle"
EvSettle = "settle"
)
// Move is what the player sends: play this card, take one off the deck, or —
// having taken one you can play — decline to play it.
type Move struct {
Kind string `json:"kind"` // "play" | "draw" | "pass"
Index int `json:"index"` // which card of your hand, for a play
Color Color `json:"color"` // the colour you name, for a wild
}
// Move kinds.
const (
MovePlay = "play"
MoveDraw = "draw"
MovePass = "pass"
)
// New deals a game: a shuffled deck, seven each, and a card turned over.
//
// The turned card is dealt until it's a number. The official rules have the
// first player eat a +2 that lands there, and turn a wild into a colour vote —
// both of which are a game that opens by doing something to you before you have
// touched it. A number card up top is the same game, minus the paperwork.
func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Event, error) {
if bet <= 0 {
return State{}, nil, ErrBadBet
}
if t.Bots < 1 {
return State{}, nil, ErrUnknownTier
}
rng := stepRNG(seed1, seed2, 0)
deck := NewDeck()
rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] })
s := State{
Tier: t, Deck: deck, Dir: 1, Turn: You,
Seed1: seed1, Seed2: seed2,
RakePct: rakePct, Bet: bet, Phase: PhasePlay,
Bots: botNames(t.Bots, rng),
}
seats := t.Bots + 1
s.Hands = make([][]Card, seats)
for i := range s.Hands {
s.Hands[i] = make([]Card, 0, HandSize)
}
for c := 0; c < HandSize; c++ {
for seat := 0; seat < seats; seat++ {
card, _ := s.pop()
s.Hands[seat] = append(s.Hands[seat], card)
}
}
// Turn cards over until one of them is a plain number.
for {
card, ok := s.pop()
if !ok {
return State{}, nil, errors.New("uno: deck ran out on the deal") // 108 cards; unreachable
}
if card.Value.Action() {
s.Discard = append(s.Discard, card) // it stays buried, out of play
continue
}
s.Discard = append(s.Discard, card)
s.Color = card.Color
break
}
return s, []Event{{Kind: EvDeal, Card: s.topPtr(), Color: s.Color}}, nil
}
// ApplyMove is the engine. Your move goes in; your move, and every bot turn it
// hands off to, comes back 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
}
if s.Turn != You {
// Can't happen through this door — ApplyMove always runs the bots out
// before it returns — but a state restored from a row that predates a bug
// shouldn't wedge the player, it should say what's wrong.
return s, nil, ErrNotYourTurn
}
next := s.clone()
next.Step++
rng := stepRNG(next.Seed1, next.Seed2, next.Step)
var evs []Event
var err error
switch m.Kind {
case MovePlay:
evs, err = next.playerPlays(m, rng)
case MoveDraw:
evs, err = next.playerDraws(rng)
case MovePass:
evs, err = next.playerPasses()
default:
return s, nil, ErrUnknownMove
}
if err != nil {
return s, nil, err // the caller's state, untouched
}
// The bots take their turns on the back of yours, and the whole run comes
// back as one script. This is the reason solo UNO needs no socket.
next.runBots(&evs, rng)
// And if that left a table nobody can move at, it ends here rather than
// handing back a turn that has nothing in it. See stalled().
if next.Phase != PhaseDone && next.stalled() {
next.stuck(&evs)
}
return next, evs, nil
}
// playerPlays puts one of your cards on the pile.
func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
hand := s.Hands[You]
if m.Index < 0 || m.Index >= len(hand) {
return nil, ErrNoSuchCard
}
// Having drawn a playable card, the only card you may play is that one. Being
// allowed to draw and *then* play something else would make drawing a free
// look at the deck with no cost attached.
if s.Phase == PhaseDrawn && m.Index != len(hand)-1 {
return nil, ErrMustPlayNow
}
card := hand[m.Index]
if !card.CanPlayOn(s.top(), s.Color) {
return nil, ErrCantPlay
}
if card.IsWild() && !m.Color.Playable() {
return nil, ErrNeedColor
}
s.Hands[You] = append(hand[:m.Index:m.Index], hand[m.Index+1:]...)
var evs []Event
s.discard(You, card, m.Color, &evs)
s.after(You, card, &evs, rng)
return evs, nil
}
// playerDraws takes one off the deck. If it can be played you get the choice —
// that's PhaseDrawn, and it's the only place the turn pauses mid-move. If it
// can't, the turn passes on the spot: there is nothing to decide.
func (s *State) playerDraws(rng *rand.Rand) ([]Event, error) {
if s.Phase == PhaseDrawn {
return nil, ErrMustPlayNow // you already drew; play it or pass
}
var evs []Event
drawn := s.deal(You, 1, false, &evs, rng)
if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) {
s.Phase = PhaseDrawn
return evs, nil
}
evs = append(evs, Event{Kind: EvPass, Seat: You})
s.advance(1)
return evs, nil
}
// playerPasses declines the card you just drew.
func (s *State) playerPasses() ([]Event, error) {
if s.Phase != PhaseDrawn {
return nil, ErrCantPass
}
s.Phase = PhasePlay
s.advance(1)
return []Event{{Kind: EvPass, Seat: You}}, nil
}
// runBots plays every bot turn between you and your next one. It stops the
// moment the game is over, the turn comes back round, or the table dies under
// it — a stalled table would otherwise pass the turn round and round forever
// without ever reaching you.
func (s *State) runBots(evs *[]Event, rng *rand.Rand) {
for s.Phase != PhaseDone && s.Turn != You && !s.stalled() {
s.botTurn(s.Turn, evs, rng)
}
}
// botTurn plays one bot's turn.
func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) {
card, idx := botPick(s.Hands[seat], s.top(), s.Color, s.minOpponent(seat), rng)
if idx < 0 {
// Nothing playable: draw one, and play it if it happens to go.
drawn := s.deal(seat, 1, false, evs, rng)
if len(drawn) != 1 || !drawn[0].CanPlayOn(s.top(), s.Color) {
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
s.advance(1)
return
}
card, idx = drawn[0], len(s.Hands[seat])-1
}
hand := s.Hands[seat]
s.Hands[seat] = append(hand[:idx:idx], hand[idx+1:]...)
color := card.Color
if card.IsWild() {
color = botColor(s.Hands[seat], rng)
}
s.discard(seat, card, color, evs)
s.after(seat, card, evs, rng)
}
// stalled reports whether the table is dead: nothing left to draw anywhere, and
// not one seat holding a card that goes on the pile.
//
// This is the condition, tested directly. It used to be guessed at by counting
// how many bots had passed in a row, which could not work: runBots hands the
// turn back the moment it comes round to you, so the count never got as high as
// the number of seats, and your own empty-handed pass was never in it. The guard
// never fired once. A game that can't end is worse than one that ends badly —
// and worse than either, a live game you can't finish is chips you can't cash
// out, because the cage won't let you leave a hand half-played.
func (s State) stalled() bool {
if len(s.Deck) > 0 || len(s.Discard) > 1 {
return false // there is a card to draw, or a discard to make one out of
}
for _, hand := range s.Hands {
for _, c := range hand {
if c.CanPlayOn(s.top(), s.Color) {
return false
}
}
}
return true
}
// discard puts a card on the pile and names the colour now in play.
//
// A wild is stamped with the colour it was played as, so the pile shows what was
// called rather than a black card and a note beside it. That stamp is undone if
// the card ever comes back out — see reshuffle, which would otherwise bleed four
// extra reds into the deck.
func (s *State) discard(seat int, card Card, color Color, evs *[]Event) {
if card.IsWild() {
s.Color = color
card.Color = color
} else {
s.Color = card.Color
}
s.Discard = append(s.Discard, card)
e := Event{Kind: EvPlay, Seat: seat, Card: &card, Color: s.Color, Left: len(s.Hands[seat])}
*evs = append(*evs, e)
if len(s.Hands[seat]) == 1 {
*evs = append(*evs, Event{Kind: EvUno, Seat: seat})
}
}
// after resolves what the card just played does, and moves the turn on. It is
// the one place the rules of skip, reverse and the draw cards live.
func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) {
if len(s.Hands[seat]) == 0 {
s.settle(seat, evs)
return
}
s.Phase = PhasePlay
switch card.Value {
case Skip:
victim := s.seatAt(1)
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim})
s.advance(2)
case Reverse:
// Two at the table and a reverse has nobody to hand the turn back to, so it
// is a skip — which, with two players, means you go again.
if len(s.Hands) == 2 {
*evs = append(*evs, Event{Kind: EvSkip, Seat: s.seatAt(1)})
s.advance(2)
return
}
s.Dir = -s.Dir
*evs = append(*evs, Event{Kind: EvReverse, Seat: seat})
s.advance(1)
case DrawTwo:
s.punish(s.seatAt(1), 2, evs, rng)
case WildDrawFour:
s.punish(s.seatAt(1), 4, evs, rng)
default:
s.advance(1)
}
}
// punish makes the next seat eat a draw card and lose its turn. No stacking: a
// +2 played onto a +2 is a house rule, and the one this table plays is the one
// on the box.
func (s *State) punish(victim, n int, evs *[]Event, rng *rand.Rand) {
s.deal(victim, n, true, evs, rng)
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim})
s.advance(2)
}
// deal gives a seat n cards, reshuffling the discard back under the deck if it
// runs dry. The cards it hands back are the ones actually drawn — which can be
// fewer than asked for, when there is nothing left anywhere to draw.
//
// A bot's cards go face down: the event carries the count, never the card. The
// only hand whose faces cross the wire is yours.
func (s *State) deal(seat, n int, forced bool, evs *[]Event, rng *rand.Rand) []Card {
got := make([]Card, 0, n)
for i := 0; i < n; i++ {
if len(s.Deck) == 0 && !s.reshuffle(evs, rng) {
break
}
c, ok := s.pop()
if !ok {
break
}
s.Hands[seat] = append(s.Hands[seat], c)
got = append(got, c)
}
if len(got) == 0 {
return got
}
kind := EvDraw
if forced {
kind = EvForced
}
e := Event{Kind: kind, Seat: seat, N: len(got), Left: len(s.Hands[seat])}
if seat == You && len(got) == 1 {
c := got[0]
e.Card = &c // your own card, and only yours, comes face up
}
*evs = append(*evs, e)
return got
}
// reshuffle turns the discard back into a deck, keeping the card in play on top
// of the pile. It reports whether there was anything to reshuffle.
func (s *State) reshuffle(evs *[]Event, rng *rand.Rand) bool {
if len(s.Discard) < 2 {
return false // nothing under the top card: the table is out of cards
}
top := s.Discard[len(s.Discard)-1]
rest := append([]Card(nil), s.Discard[:len(s.Discard)-1]...)
rng.Shuffle(len(rest), func(i, j int) { rest[i], rest[j] = rest[j], rest[i] })
// A wild goes back in as a wild. It was played as a colour, and leaving that
// colour stamped on it would quietly bleed four extra reds into the deck.
for i := range rest {
if rest[i].Value == WildCard || rest[i].Value == WildDrawFour {
rest[i].Color = Wild
}
}
s.Deck = rest
s.Discard = []Card{top}
*evs = append(*evs, Event{Kind: EvReshuffle, N: len(rest)})
return true
}
// settle ends the game. Going out first pays the tier; anyone else going out
// takes the stake. The rake, as everywhere in this casino, comes out of the
// winnings and never out of the stake.
func (s *State) settle(winner int, evs *[]Event) {
s.Phase = PhaseDone
if winner == You {
s.Outcome = OutcomeWon
s.Payout = s.Pays()
s.Rake = s.rakeNow()
} else {
s.Outcome = OutcomeLost
s.Payout = 0
}
*evs = append(*evs, Event{Kind: EvSettle, Seat: winner, Text: string(s.Outcome)})
}
// stuck ends a game nobody can move in: the deck is spent, the discard is one
// card deep, and every seat has passed. The shortest hand takes it — and a tie
// is not a win, because a win here has to be somebody actually going out.
func (s *State) stuck(evs *[]Event) {
best, tied := 0, false
for seat := range s.Hands {
switch {
case len(s.Hands[seat]) < len(s.Hands[best]):
best, tied = seat, false
case seat != best && len(s.Hands[seat]) == len(s.Hands[best]):
tied = true
}
}
s.Phase = PhaseDone
if best == You && !tied {
s.Outcome = OutcomeWon
s.Payout = s.Pays()
s.Rake = s.rakeNow()
} else {
s.Outcome = OutcomeStuck
s.Payout = 0
}
*evs = append(*evs, Event{Kind: EvSettle, Seat: best, Text: string(s.Outcome)})
}
// Pays is what going out *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 because the felt quotes this number while the game is still running,
// and settle() is the only other thing that works it out. Hangman learned this
// the hard way: two sums drift, and the table ends up advertising a payout it
// doesn't honour. So settle calls this rather than doing it again.
func (s State) Pays() int64 {
total := int64(math.Floor(float64(s.Bet) * s.Tier.Base))
if total < s.Bet {
total = s.Bet
}
profit := total - s.Bet
if profit > 0 {
profit -= s.rakeOn(profit)
}
return s.Bet + profit
}
// rakeNow is the other half of what Pays works out: the house's cut of a win
// taken right now.
func (s State) rakeNow() int64 {
total := int64(math.Floor(float64(s.Bet) * s.Tier.Base))
if total <= s.Bet {
return 0
}
return s.rakeOn(total - s.Bet)
}
func (s State) rakeOn(profit int64) int64 {
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
}
// Playable reports which cards of your hand can legally go on the pile. The
// browser lights these up: being shown what you can play is the game teaching
// you, and the server still decides every move regardless.
//
// While you're sitting on a card you just drew, that card is the only one you
// may play — so it is the only one that lights up.
func (s State) Playable() []int {
if s.Phase == PhaseDone || s.Turn != You {
return nil
}
hand := s.Hands[You]
if s.Phase == PhaseDrawn {
if len(hand) > 0 && hand[len(hand)-1].CanPlayOn(s.top(), s.Color) {
return []int{len(hand) - 1}
}
return nil
}
var out []int
for i, c := range hand {
if c.CanPlayOn(s.top(), s.Color) {
out = append(out, i)
}
}
return out
}
// Counts is how many cards each seat holds — what the browser gets instead of
// the bots' hands.
func (s State) Counts() []int {
out := make([]int, len(s.Hands))
for i := range s.Hands {
out[i] = len(s.Hands[i])
}
return out
}
// Top is the card in play.
func (s State) Top() Card { return s.top() }
// Left is how many cards are still in the deck.
func (s State) Left() int { return len(s.Deck) }
// ---- the plumbing ---------------------------------------------------------
func (s State) top() Card {
if len(s.Discard) == 0 {
return Card{}
}
return s.Discard[len(s.Discard)-1]
}
func (s State) topPtr() *Card {
c := s.top()
return &c
}
// pop takes the next card off the deck.
func (s *State) pop() (Card, bool) {
if len(s.Deck) == 0 {
return Card{}, false
}
c := s.Deck[0]
s.Deck = s.Deck[1:]
return c, true
}
// seatAt is the seat n places round from the one whose turn it is.
func (s State) seatAt(n int) int {
seats := len(s.Hands)
return ((s.Turn+s.Dir*n)%seats + seats) % seats
}
// advance moves the turn on n places.
func (s *State) advance(n int) { s.Turn = s.seatAt(n) }
// minOpponent is the smallest hand at the table that isn't this seat's — how
// close the bot is to being beaten, which is the only thing it plays around.
func (s State) minOpponent(seat int) int {
min := -1
for i := range s.Hands {
if i == seat {
continue
}
if min < 0 || len(s.Hands[i]) < min {
min = len(s.Hands[i])
}
}
return min
}
// clone deep-copies everything a move can touch, so a derived state shares no
// backing array with the one it came from.
func (s State) clone() State {
hands := make([][]Card, len(s.Hands))
for i, h := range s.Hands {
hands[i] = append([]Card(nil), h...)
}
s.Hands = hands
s.Deck = append([]Card(nil), s.Deck...)
s.Discard = append([]Card(nil), s.Discard...)
s.Bots = append([]string(nil), s.Bots...)
return s
}
// stepRNG is the generator for one step of the game. The seed is the game's;
// the step number is what stops every move from replaying the same numbers.
// Mixing with the golden ratio's odd 64-bit constant keeps consecutive steps
// from producing streams that share a bit pattern.
func stepRNG(seed1, seed2, step uint64) *rand.Rand {
return rand.New(rand.NewPCG(seed1, seed2^(step*0x9E3779B97F4A7C15)))
}

View File

@@ -0,0 +1,787 @@
package uno
import (
"encoding/json"
"math/rand/v2"
"testing"
)
const rake = 0.05
func duel() Tier { t, _ := TierBySlug("duel"); return t }
func full() Tier { t, _ := TierBySlug("full"); return t }
func table() Tier { t, _ := TierBySlug("table"); return t }
// deal starts a game, failing the test if it can't.
func deal(t *testing.T, tier Tier, bet int64, seed uint64) State {
t.Helper()
s, evs, err := New(bet, tier, rake, seed, 0xC0FFEE)
if err != nil {
t.Fatalf("New: %v", err)
}
if len(evs) != 1 || evs[0].Kind != EvDeal {
t.Fatalf("New should deal exactly one event, got %+v", evs)
}
return s
}
// 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 {
// A wild is stamped with the colour it was played as while it sits on the
// pile, so it counts as the wild it really is.
if c.Value == WildCard || c.Value == WildDrawFour {
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(), 100, 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)
}
}
if len(s.Bots) != 3 {
t.Fatalf("want three bot names, got %v", s.Bots)
}
if s.Turn != You {
t.Errorf("you play first, turn is %d", s.Turn)
}
if got := total(census(s)); got != 108 {
t.Fatalf("the deal lost cards: %d of 108", got)
}
}
// The card turned over to start is never an action card — see New.
func TestOpeningCardIsANumber(t *testing.T) {
for seed := uint64(0); seed < 300; seed++ {
s := deal(t, table(), 50, 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 state by hand, so a rule can be tested without hunting a seed
// that happens to deal it.
//
// The deck is the rest of the deck: every card not in a hand and not the one in
// play. So a rigged game still holds 108 cards, and the census invariant means
// something in these tests too.
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() {
key := c
if left[key] > 0 {
left[key]--
deck = append(deck, c)
}
}
return State{
Tier: full(), Hands: hands, Discard: []Card{top}, Color: color,
Deck: deck, Dir: 1, Turn: You, Phase: PhasePlay,
Bet: 100, RakePct: rake, Seed1: 1, Seed2: 2,
}
}
func TestPlayMustMatch(t *testing.T) {
s := rig([][]Card{{{Blue, Three}}, {{Red, Five}}}, Card{Red, Nine}, Red)
if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}); err != ErrCantPlay {
t.Fatalf("a blue 3 on a red 9 should be refused, got %v", err)
}
}
func TestPlayMatchesFaceOrColor(t *testing.T) {
// Same face, different colour: legal.
s := rig([][]Card{{{Blue, Nine}, {Red, Two}}, {{Green, Five}}}, Card{Red, Nine}, Red)
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
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, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor {
t.Fatalf("a wild with no colour should be refused, got %v", err)
}
if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Wild}); err != ErrNeedColor {
t.Fatalf("naming 'wild' is not naming a colour, got %v", err)
}
}
func TestWildNamesTheColor(t *testing.T) {
s := rig([][]Card{{{Wild, WildCard}, {Green, One}}, {{Green, Five}}}, Card{Red, Nine}, Red)
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Green})
if err != nil {
t.Fatalf("play wild: %v", err)
}
// The bot moved after us, so the colour in play is whatever it left behind —
// what we can check is that the wild itself went down as green.
top := next.Discard
if len(top) < 2 {
t.Fatalf("expected the wild and the bot's card on the pile: %v", top)
}
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) {
// Two seats, so the +2 lands on the bot and the turn comes straight back.
s := rig([][]Card{{{Red, DrawTwo}, {Red, One}}, {{Blue, Five}, {Blue, Six}}}, Card{Red, Nine}, Red)
s.Tier = duel()
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
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, 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) {
// Every bot holds a red card, so each of them can play the moment the turn
// reaches it — which is what makes the *order* they play in observable.
s := rig([][]Card{
{{Red, Reverse}, {Red, One}},
{{Red, Five}, {Blue, Six}},
{{Red, Six}, {Green, Six}},
{{Red, Seven}, {Yellow, Six}},
}, Card{Red, Nine}, Red)
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
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)
}
// The table now runs anticlockwise: seat 3 plays, then 2, then 1.
var order []int
for _, e := range evs {
if e.Kind == EvPlay && e.Seat != You {
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) {
// Both bots hold a playable red, so the only reason either of them doesn't
// play is that it wasn't asked to.
s := rig([][]Card{
{{Red, Skip}, {Red, One}},
{{Red, Five}, {Blue, Six}},
{{Red, Six}, {Green, Six}},
}, Card{Red, Nine}, Red)
s.Tier = table()
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
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, 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(); len(got) != 1 || got[0] != 1 {
t.Errorf("the drawn card, and only it, is playable: %v", got)
}
// You may not play the *other* card instead — drawing would otherwise be a
// free look with no cost.
if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow {
t.Fatalf("only the drawn card may be played, got %v", err)
}
if _, _, err := ApplyMove(next, Move{Kind: MoveDraw}); err != ErrMustPlayNow {
t.Fatalf("you can't draw twice, got %v", err)
}
after, _, err := ApplyMove(next, 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, 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, Move{Kind: MovePass}); err != ErrCantPass {
t.Fatalf("you can't pass a turn you haven't drawn on, got %v", err)
}
}
// dead is a table nobody can move at: the deck is spent, the discard is one card
// deep so there is nothing to reshuffle out of, and not a seat holds a card that
// goes on the pile. Every seat can only pass, forever.
func dead(hands [][]Card) State {
s := rig(hands, Card{Red, Nine}, Red)
s.Deck = nil
return s
}
// The game has to end here. It used to not: the stuck guard counted how many
// bots had passed in a row and asked for more of them than there are seats, so
// it never fired once, and a dead table just handed the turn round and round.
// That is a game the player cannot finish — and a game they cannot finish is
// chips they cannot cash out, because the cage won't let them leave a live hand.
func TestDeadTableEnds(t *testing.T) {
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}})
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.Phase != PhaseDone {
t.Fatalf("nobody can move and there is nothing to draw: the game is over, not %q", next.Phase)
}
if next.Outcome != OutcomeStuck {
t.Errorf("a tie on the shortest hand is nobody going out: %q", next.Outcome)
}
if next.Payout != 0 {
t.Errorf("a stuck game pays nothing, not %d", next.Payout)
}
if !hasKind(evs, EvSettle) {
t.Errorf("the table has to be told it's over: %+v", evs)
}
}
// And the shortest hand takes it, which is the one way a stuck table still pays.
func TestDeadTablePaysTheShortestHand(t *testing.T) {
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}, {Green, Six}}})
next, _, err := ApplyMove(s, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.Outcome != OutcomeWon {
t.Fatalf("one card against two is a win: %q", next.Outcome)
}
if next.Payout != s.Pays() {
t.Errorf("payout %d, quoted %d — the felt has to honour what it advertised", next.Payout, s.Pays())
}
}
func TestReshuffleRebuildsTheDeck(t *testing.T) {
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
// An empty deck, and a discard with something under the top card to become one.
// The buried wild went down as green; it has to come back as a wild.
s.Deck = nil
s.Discard = []Card{{Green, WildCard}, {Red, Two}, {Red, Nine}}
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
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 money ------------------------------------------------------------
// The rule every game in this casino has had to be taught: the number the felt
// quotes and the number the settle lands on are one function, not two.
func TestQuoteIsThePayout(t *testing.T) {
for _, tier := range Tiers {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
s.Tier = tier
s.Hands = make([][]Card, tier.Bots+1)
s.Hands[You] = []Card{{Red, Three}}
for i := 1; i <= tier.Bots; i++ {
s.Hands[i] = []Card{{Green, Five}, {Green, Six}}
}
quoted := s.Pays()
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) // your last card
if err != nil {
t.Fatalf("%s: go out: %v", tier.Slug, err)
}
if next.Outcome != OutcomeWon {
t.Fatalf("%s: playing your last card wins, got %q", tier.Slug, next.Outcome)
}
if next.Payout != quoted {
t.Errorf("%s: the felt quoted %d, the house paid %d", tier.Slug, quoted, next.Payout)
}
if next.Net() != quoted-s.Bet {
t.Errorf("%s: net is %d, want %d", tier.Slug, next.Net(), quoted-s.Bet)
}
}
}
// The rake comes out of the winnings, never the stake.
func TestRakeIsOnWinningsOnly(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
s.Tier = duel() // 2.2x on 100: 220 back, 120 of it profit, 6 of that to the house
s.Bet = 100
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("go out: %v", err)
}
if next.Payout != 214 {
t.Errorf("payout %d, want 214 (100 stake + 120 winnings - 6 rake)", next.Payout)
}
if next.Rake != 6 {
t.Errorf("rake %d, want 6", next.Rake)
}
if next.Net() != 114 {
t.Errorf("net %d, want 114", next.Net())
}
}
func TestLosingPaysNothingAndIsNotCharged(t *testing.T) {
// The bot holds one card that plays on the pile, so it goes out the moment the
// turn reaches it.
s := rig([][]Card{{{Red, Three}, {Red, Four}}, {{Red, Five}}}, Card{Red, Nine}, Red)
s.Tier = duel()
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play: %v", err)
}
if next.Outcome != OutcomeLost {
t.Fatalf("the bot went out, so you lost: %q", next.Outcome)
}
if next.Payout != 0 || next.Rake != 0 {
t.Errorf("a loss pays nothing and is charged nothing: payout %d rake %d", next.Payout, next.Rake)
}
if next.Net() != -s.Bet {
t.Errorf("a loss costs the stake and no more: %d", next.Net())
}
last := evs[len(evs)-1]
if last.Kind != EvSettle || last.Seat != 1 {
t.Errorf("the settle should name the winner: %+v", last)
}
}
func TestNoMoveAfterItIsOver(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
s.Tier = duel()
done, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("go out: %v", err)
}
if _, _, err := ApplyMove(done, Move{Kind: MoveDraw}); err != ErrGameOver {
t.Fatalf("a finished game takes no more moves, got %v", err)
}
}
func TestBadBet(t *testing.T) {
if _, _, err := New(0, duel(), rake, 1, 2); err != ErrBadBet {
t.Fatalf("want ErrBadBet, got %v", err)
}
}
// ---- the whole game -------------------------------------------------------
// playOut plays a game to its end with a simple strategy: play the first legal
// card, otherwise draw, otherwise pass. It asserts the invariants at every step.
func playOut(t *testing.T, s State, maxTurns int) State {
t.Helper()
for turn := 0; s.Phase != PhaseDone; turn++ {
if turn > maxTurns {
t.Fatalf("the game never ended in %d turns", maxTurns)
}
if s.Turn != You {
t.Fatalf("ApplyMove left the turn with seat %d — the bots should always run out", s.Turn)
}
var m Move
if p := s.Playable(); len(p) > 0 {
m = Move{Kind: MovePlay, Index: p[0]}
if s.Hands[You][p[0]].IsWild() {
m.Color = Green
}
} else if s.Phase == PhaseDrawn {
m = Move{Kind: MovePass}
} else {
m = Move{Kind: MoveDraw}
}
next, evs, err := ApplyMove(s, 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)
}
}
// No event ever names a bot's card. That is the hole card of this game, and
// it is most of the deck.
for _, e := range evs {
if (e.Kind == EvDraw || e.Kind == EvForced) && e.Seat != You && e.Card != nil {
t.Fatalf("turn %d: a bot's drawn card crossed the wire: %+v", turn, e)
}
}
s = next
}
return s
}
// 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 games, played out, with the invariants checked at every step. This
// is the test that would have caught a deck that leaks cards through the
// reshuffle, a turn the bots don't hand back, or a game that can't end.
func TestGamesPlayOut(t *testing.T) {
wins, losses, stuck := 0, 0, 0
for seed := uint64(0); seed < 100; seed++ {
tier := Tiers[seed%3]
end := playOut(t, deal(t, tier, 100, seed), 500)
switch end.Outcome {
case OutcomeWon:
wins++
if end.Payout != end.Pays() {
t.Fatalf("seed %d: paid %d, quoted %d", seed, end.Payout, end.Pays())
}
case OutcomeLost:
losses++
case OutcomeStuck:
stuck++
default:
t.Fatalf("seed %d ended as %q", seed, end.Outcome)
}
if len(end.Hands[end.winnerSeat()]) != 0 && end.Outcome != OutcomeStuck {
t.Fatalf("seed %d: the winner is still holding cards", seed)
}
}
// Playing the first legal card is a poor strategy against bots that hold their
// +4s back, so this is not a fairness assertion — it's a check that both
// outcomes actually happen. A table that never pays is a bug in the bots.
if wins == 0 || losses == 0 {
t.Fatalf("100 games gave %d wins, %d losses, %d stuck — one side never happens", wins, losses, stuck)
}
t.Logf("100 games: %d won, %d lost, %d stuck", wins, losses, stuck)
}
// winnerSeat is the seat the settle event named — only used by the tests.
func (s State) winnerSeat() int {
best := 0
for i := range s.Hands {
if len(s.Hands[i]) < len(s.Hands[best]) {
best = i
}
}
return best
}
// The same seed deals the same game and the bots make the same choices — which
// is what lets a disputed game be replayed exactly as it fell.
func TestReplaysFromTheSeed(t *testing.T) {
a := playOut(t, deal(t, full(), 250, 42), 500)
b := playOut(t, deal(t, full(), 250, 42), 500)
ja, _ := json.Marshal(a)
jb, _ := json.Marshal(b)
if string(ja) != string(jb) {
t.Fatal("the same seed played the same way gave two different games")
}
if a.Outcome == "" {
t.Fatal("the replay didn't finish")
}
}
// A game in progress survives a redeploy: it is a plain value, so it round-trips
// through the JSON it is stored as.
func TestStateSurvivesStorage(t *testing.T) {
s := deal(t, table(), 100, 9)
s, _, err := ApplyMove(s, 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")
}
// And it plays on from there.
playOut(t, back, 500)
}
// A move the engine refuses leaves the caller's state exactly as it was — no
// card half-played, no turn half-passed.
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, 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 bot game, or the
// bot is a lookup table you can memorise.
func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) {
same := 0
for seed := uint64(0); seed < 20; seed++ {
a := playOut(t, deal(t, duel(), 100, seed), 500)
b := playOut(t, deal(t, duel(), 100, seed+1000), 500)
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")
}
}
// botPick holds its +4 back while it's comfortable, and reaches for it when
// somebody is about to go out.
func TestBotSavesTheDrawFour(t *testing.T) {
hand := []Card{{Wild, WildDrawFour}, {Red, Five}}
top, color := Card{Red, Nine}, Red
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)
}
// A hand of nothing but wilds still has to name something.
for i := 0; i < 20; i++ {
if got := botColor([]Card{{Wild, WildCard}}, rng); !got.Playable() {
t.Fatalf("botColor named %v, which is not a colour", got)
}
}
}
func TestBotHasNothingToPlay(t *testing.T) {
if _, idx := botPick([]Card{{Blue, Three}}, Card{Red, Nine}, Red, 3, rand.New(rand.NewPCG(1, 1))); idx != -1 {
t.Errorf("a hand with nothing legal should report -1, got %d", idx)
}
}
func hasKind(evs []Event, kind string) bool {
for _, e := range evs {
if e.Kind == kind {
return true
}
}
return false
}

View File

@@ -37,7 +37,9 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
if s.Lede != "" {
plainParts = append(plainParts, s.Lede)
}
plainParts = append(plainParts, formatSourceTag(s.Source, s.Platforms, false))
if tag := formatSourceTag(s.Source, s.Platforms, false); tag != "" {
plainParts = append(plainParts, tag)
}
plain = strings.Join(plainParts, "\n")
// HTML body
@@ -55,25 +57,32 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
if s.Lede != "" {
htmlParts = append(htmlParts, html.EscapeString(s.Lede))
}
htmlParts = append(htmlParts, formatSourceTag(s.Source, s.Platforms, true))
if tag := formatSourceTag(s.Source, s.Platforms, true); tag != "" {
htmlParts = append(htmlParts, tag)
}
htmlBody = strings.Join(htmlParts, "<br/>")
return plain, htmlBody
}
// formatSourceTag builds the source + platform tags line.
// formatSourceTag builds the source + platform tags line. An empty source is
// omitted rather than tagged: Pete's own reporting has no outlet to credit, and
// an empty tag would read as him signing his own name.
func formatSourceTag(source string, platforms []string, isHTML bool) string {
var parts []string
if source != "" {
parts = append(parts, strings.ToLower(source))
}
parts = append(parts, platforms...)
if len(parts) == 0 {
return ""
}
for i, p := range parts {
if isHTML {
parts := []string{fmt.Sprintf("<code>%s</code>", html.EscapeString(strings.ToLower(source)))}
for _, p := range platforms {
parts = append(parts, fmt.Sprintf("<code>%s</code>", html.EscapeString(p)))
parts[i] = fmt.Sprintf("<code>%s</code>", html.EscapeString(p))
} else {
parts[i] = fmt.Sprintf("`%s`", p)
}
return strings.Join(parts, " \u00b7 ")
}
parts := []string{fmt.Sprintf("`%s`", strings.ToLower(source))}
for _, p := range platforms {
parts = append(parts, fmt.Sprintf("`%s`", p))
}
return strings.Join(parts, " \u00b7 ")
}

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

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

601
internal/storage/games.go Normal file
View File

@@ -0,0 +1,601 @@
package storage
import (
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"time"
)
// The chip ledger and the euro/chip border.
//
// Chips are euros that have crossed into the casino. They are 1:1 with euros and
// they are not a second wallet: every chip that exists came from a euro gogobee
// debited, and every chip destroyed becomes a euro gogobee credits back. Pete
// never writes a euro balance. The border is crossed only by a game_escrow row,
// whose guid is the idempotency key gogobee hands to DebitIdem/CreditIdem — so a
// claim whose acknowledgement is lost on the wire can be retried without the
// player paying for it twice.
//
// The whole reason for the border is latency. gogobee has no inbound API and is
// not getting one, so it polls; a bet that round-tripped through a poll loop
// would take seconds to be dealt. Instead the poll loop runs twice per *session*
// — buy in, cash out — and every hand in between plays against chips held here,
// at full speed, with no economy call in the hot path.
// MaxChipsOnTable caps how many chips a player can hold at once. A buy-in that
// would push them over is refused before it ever reaches gogobee.
//
// This is the inflation brake. A web casino runs orders of magnitude more hands
// per hour than a Matrix-paced one ever did, so whatever the house edge is, it
// compounds far faster in both directions. The cap bounds the worst case for a
// single sitting; the rake (see the blackjack engine) bleeds the rest back out.
const MaxChipsOnTable int64 = 10_000
// EscrowStaleAfter is how long a claimed-but-unsettled escrow row waits before
// the poll endpoint offers it again. gogobee can die between claiming a row and
// pushing its result; without a re-offer, the player's money sits in limbo
// forever. Re-claiming is safe precisely because the guid makes it idempotent.
const EscrowStaleAfter = 90 * time.Second
// SessionIdleAfter is when the reaper decides a player has walked away and cashes
// their chips back to euros on their behalf. Chips in an abandoned session are
// euros in limbo, and limbo is not a state a player's money should be in.
const SessionIdleAfter = 30 * time.Minute
// Escrow kinds and states. These strings cross the wire to gogobee, so they are
// part of the contract — see §4 of pete_games_plan.md.
const (
KindBuyIn = "buyin"
KindCashOut = "cashout"
EscrowRequested = "requested" // the player asked; gogobee hasn't seen it yet
EscrowClaimed = "claimed" // gogobee has it and is moving the euros
EscrowFunded = "funded" // buy-in landed; the chips are spendable
EscrowRejected = "rejected" // buy-in refused; no chips, no euros moved
EscrowSettled = "settled" // cash-out landed; chips destroyed, euros credited
)
var (
ErrInsufficientChips = errors.New("games: not enough chips")
ErrOverTableCap = errors.New("games: that would put more than the cap on the table")
ErrBadAmount = errors.New("games: amount must be positive")
ErrNoSuchEscrow = errors.New("games: no such escrow row")
)
// Escrow is one crossing of the euro/chip border.
type Escrow struct {
GUID string `json:"guid"`
MatrixUser string `json:"matrix_user"`
Kind string `json:"kind"`
Amount int64 `json:"amount"`
State string `json:"state,omitempty"`
Reason string `json:"reason,omitempty"`
BalanceAfter float64 `json:"balance_after,omitempty"`
CreatedAt int64 `json:"created_at,omitempty"`
}
// ChipStack is what a player has on the table right now.
type ChipStack struct {
Chips int64 // spendable
// Pending is chips asked for but not yet funded — a buy-in gogobee hasn't
// claimed or settled. Shown as "buying chips…", never spendable.
Pending int64
EuroBalance float64 // advisory, from the last gogobee push; may be minutes stale
LastPlayed int64
}
// newGUID mints an escrow id. It's the idempotency key for a real money move, so
// it comes from crypto/rand rather than anything a caller could collide with.
func newGUID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("games: mint guid: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// Chips reports a player's stack. A player who has never played has no row and
// reads as an empty stack rather than an error.
func Chips(user string) (ChipStack, error) {
var st ChipStack
var euro sql.NullFloat64
err := Get().QueryRow(
`SELECT chips, euro_balance, last_played FROM game_chips WHERE matrix_user = ?`, user,
).Scan(&st.Chips, &euro, &st.LastPlayed)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return ChipStack{}, fmt.Errorf("games: read chips: %w", err)
}
st.EuroBalance = euro.Float64
if err := Get().QueryRow(
`SELECT COALESCE(SUM(amount), 0) FROM game_escrow
WHERE matrix_user = ? AND kind = ? AND state IN (?, ?)`,
user, KindBuyIn, EscrowRequested, EscrowClaimed,
).Scan(&st.Pending); err != nil {
return ChipStack{}, fmt.Errorf("games: read pending buy-ins: %w", err)
}
return st, nil
}
// RequestBuyIn opens a buy-in: the player wants `amount` euros turned into chips.
// No chips exist yet — they appear only when gogobee confirms it took the euros.
// The table cap is enforced here, against chips already held *plus* buy-ins still
// in flight, so a player can't clear the cap by firing several at once.
func RequestBuyIn(user string, amount int64) (Escrow, error) {
if amount <= 0 {
return Escrow{}, ErrBadAmount
}
st, err := Chips(user)
if err != nil {
return Escrow{}, err
}
if st.Chips+st.Pending+amount > MaxChipsOnTable {
return Escrow{}, ErrOverTableCap
}
guid, err := newGUID()
if err != nil {
return Escrow{}, err
}
now := nowUnix()
if _, err := Get().Exec(
`INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
guid, user, KindBuyIn, amount, EscrowRequested, now, now,
); err != nil {
return Escrow{}, fmt.Errorf("games: request buy-in: %w", err)
}
return Escrow{GUID: guid, MatrixUser: user, Kind: KindBuyIn, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil
}
// RequestCashOut opens a cash-out: chips are destroyed *now*, and the matching
// euros arrive when gogobee claims the row.
//
// Destroying them up front is what keeps the invariant true. If the chips lingered
// until gogobee confirmed, a player could bet them while the cash-out was in
// flight and the same euro would exist on both sides of the border. If the credit
// somehow fails, RefundCashOut puts the chips back.
func RequestCashOut(user string, amount int64) (Escrow, error) {
if amount <= 0 {
return Escrow{}, ErrBadAmount
}
guid, err := newGUID()
if err != nil {
return Escrow{}, err
}
now := nowUnix()
tx, err := Get().Begin()
if err != nil {
return Escrow{}, fmt.Errorf("games: begin cash-out: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
// Conditional update: the chips leave only if they're actually there.
res, err := tx.Exec(
`UPDATE game_chips SET chips = chips - ?, updated_at = ?
WHERE matrix_user = ? AND chips >= ?`,
amount, now, user, amount,
)
if err != nil {
return Escrow{}, fmt.Errorf("games: debit chips: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return Escrow{}, ErrInsufficientChips
}
if _, err := tx.Exec(
`INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
guid, user, KindCashOut, amount, EscrowRequested, now, now,
); err != nil {
return Escrow{}, fmt.Errorf("games: request cash-out: %w", err)
}
if err := tx.Commit(); err != nil {
return Escrow{}, fmt.Errorf("games: commit cash-out: %w", err)
}
return Escrow{GUID: guid, MatrixUser: user, Kind: KindCashOut, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil
}
// PendingEscrow is what gogobee's poll loop reads: everything waiting to be moved.
//
// It returns rows nobody has claimed, *and* rows claimed long enough ago that we
// have to assume gogobee died holding them. Re-offering a claimed row is safe
// because the guid is idempotent end to end: if gogobee already moved the euros,
// the retry is a no-op that reports the same answer.
func PendingEscrow(limit int) ([]Escrow, error) {
if limit <= 0 {
limit = 100
}
stale := nowUnix() - int64(EscrowStaleAfter.Seconds())
rows, err := Get().Query(
`SELECT guid, matrix_user, kind, amount, state, created_at
FROM game_escrow
WHERE state = ?
OR (state = ? AND COALESCE(claimed_at, 0) < ?)
ORDER BY created_at
LIMIT ?`,
EscrowRequested, EscrowClaimed, stale, limit,
)
if err != nil {
return nil, fmt.Errorf("games: pending escrow: %w", err)
}
defer rows.Close()
var out []Escrow
for rows.Next() {
var e Escrow
if err := rows.Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("games: scan escrow: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// ClaimEscrow marks a row as taken by gogobee. Claiming is idempotent and is not
// a lock: a row already claimed can be claimed again (that's how a stale re-offer
// works), but a row already *finished* cannot be, which is what stops a settled
// cash-out from being paid a second time.
func ClaimEscrow(guid string) (Escrow, error) {
now := nowUnix()
res, err := Get().Exec(
`UPDATE game_escrow SET state = ?, claimed_at = ?, updated_at = ?
WHERE guid = ? AND state IN (?, ?)`,
EscrowClaimed, now, now, guid, EscrowRequested, EscrowClaimed,
)
if err != nil {
return Escrow{}, fmt.Errorf("games: claim escrow: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
// Either it doesn't exist or it's already finished. Tell the caller which.
e, err := EscrowByGUID(guid)
if err != nil {
return Escrow{}, err
}
return e, nil
}
return EscrowByGUID(guid)
}
// EscrowByGUID reads one row.
func EscrowByGUID(guid string) (Escrow, error) {
var e Escrow
var reason sql.NullString
var bal sql.NullFloat64
err := Get().QueryRow(
`SELECT guid, matrix_user, kind, amount, state, reason, balance_after, created_at
FROM game_escrow WHERE guid = ?`, guid,
).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &reason, &bal, &e.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Escrow{}, ErrNoSuchEscrow
}
if err != nil {
return Escrow{}, fmt.Errorf("games: read escrow: %w", err)
}
e.Reason, e.BalanceAfter = reason.String, bal.Float64
return e, nil
}
// SettleEscrow applies gogobee's verdict on a claimed row, and is the only place
// chips are created or finally destroyed.
//
// buy-in, ok -> chips appear (funded)
// buy-in, !ok -> nothing happens, nothing moved (rejected)
// cash-out, ok -> chips stay destroyed, euros paid (settled)
// cash-out, !ok -> chips come back (funded — the player never lost them)
//
// It is idempotent: gogobee's push queue retries, so the same verdict can arrive
// more than once and only the first one may move chips. A row that has already
// reached a terminal state is a no-op, not an error.
func SettleEscrow(guid string, ok bool, reason string, balanceAfter float64) (Escrow, error) {
now := nowUnix()
tx, err := Get().Begin()
if err != nil {
return Escrow{}, fmt.Errorf("games: begin settle: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
var e Escrow
var st string
if err := tx.QueryRow(
`SELECT guid, matrix_user, kind, amount, state FROM game_escrow WHERE guid = ?`, guid,
).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &st); errors.Is(err, sql.ErrNoRows) {
return Escrow{}, ErrNoSuchEscrow
} else if err != nil {
return Escrow{}, fmt.Errorf("games: settle lookup: %w", err)
}
// Terminal already — a retried push. Report what we decided the first time.
if st == EscrowFunded || st == EscrowRejected || st == EscrowSettled {
if err := tx.Commit(); err != nil {
return Escrow{}, fmt.Errorf("games: commit settle: %w", err)
}
return EscrowByGUID(guid)
}
final := EscrowFunded
switch {
case e.Kind == KindBuyIn && ok:
if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil {
return Escrow{}, err
}
case e.Kind == KindBuyIn && !ok:
final = EscrowRejected // gogobee took nothing, so we create nothing
case e.Kind == KindCashOut && ok:
final = EscrowSettled // the chips were destroyed when the row was opened
case e.Kind == KindCashOut && !ok:
// gogobee couldn't pay. The chips were already destroyed on our side, so
// give them back rather than vanishing the player's money.
if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil {
return Escrow{}, err
}
}
if _, err := tx.Exec(
`UPDATE game_escrow SET state = ?, reason = ?, balance_after = ?, updated_at = ?
WHERE guid = ?`,
final, reason, balanceAfter, now, guid,
); err != nil {
return Escrow{}, fmt.Errorf("games: settle update: %w", err)
}
// The euro balance gogobee just reported is the freshest one we'll get.
// Advisory only — we display it, we never decide anything with it.
if _, err := tx.Exec(
`UPDATE game_chips SET euro_balance = ?, updated_at = ? WHERE matrix_user = ?`,
balanceAfter, now, e.MatrixUser,
); err != nil {
return Escrow{}, fmt.Errorf("games: cache euro balance: %w", err)
}
if err := tx.Commit(); err != nil {
return Escrow{}, fmt.Errorf("games: commit settle: %w", err)
}
return EscrowByGUID(guid)
}
// addChips credits a stack inside an open transaction, creating the row if the
// player has never held chips before.
func addChips(tx *sql.Tx, user string, amount int64, now int64) error {
if _, err := tx.Exec(
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(matrix_user) DO UPDATE SET chips = chips + excluded.chips, updated_at = excluded.updated_at`,
user, amount, now, now,
); err != nil {
return fmt.Errorf("games: credit chips: %w", err)
}
return nil
}
// Stake takes chips off a player's stack to put them at risk on a hand. It is the
// conditional-update kind of debit: the chips leave in the same statement that
// checks they're there, so two hands opened at once can't spend the same chip.
func Stake(user string, amount int64) error {
if amount <= 0 {
return ErrBadAmount
}
now := nowUnix()
res, err := Get().Exec(
`UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ?
WHERE matrix_user = ? AND chips >= ?`,
amount, now, now, user, amount,
)
if err != nil {
return fmt.Errorf("games: stake: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrInsufficientChips
}
return nil
}
// Award returns chips to a player when a hand settles: stake plus winnings, net
// of rake, exactly as the engine computed it. A losing hand awards nothing and
// should not call this.
func Award(user string, amount int64) error {
if amount <= 0 {
return nil
}
now := nowUnix()
if _, err := Get().Exec(
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(matrix_user) DO UPDATE SET
chips = chips + excluded.chips, last_played = excluded.last_played, updated_at = excluded.updated_at`,
user, amount, now, now,
); err != nil {
return fmt.Errorf("games: award chips: %w", err)
}
return nil
}
// Hand is one settled hand, as the audit log keeps it.
type Hand struct {
MatrixUser string
Game string
Bet int64
Payout int64
Rake int64
Outcome string
Seed1 uint64
Seed2 uint64
}
// RecordHand writes a finished hand to the audit trail. The seeds are the point:
// with them, any hand in the log can be dealt again exactly as it fell, which is
// how a dispute gets answered with a fact instead of an apology.
func RecordHand(h Hand) error {
if _, err := Get().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(),
); err != nil {
return fmt.Errorf("games: record hand: %w", err)
}
return nil
}
// IdleStacks lists players holding chips who stopped playing a while ago. The
// reaper cashes these out on their behalf: chips in an abandoned session are
// euros in limbo, and they should be back in the player's balance where they can
// see them.
func IdleStacks(idleFor time.Duration) ([]ChipStack, []string, error) {
cutoff := nowUnix() - int64(idleFor.Seconds())
rows, err := Get().Query(
`SELECT matrix_user, chips, last_played FROM game_chips
WHERE chips > 0 AND last_played > 0 AND last_played < ?`, cutoff,
)
if err != nil {
return nil, nil, fmt.Errorf("games: idle stacks: %w", err)
}
defer rows.Close()
var stacks []ChipStack
var users []string
for rows.Next() {
var st ChipStack
var user string
if err := rows.Scan(&user, &st.Chips, &st.LastPlayed); err != nil {
return nil, nil, fmt.Errorf("games: scan idle stack: %w", err)
}
stacks = append(stacks, st)
users = append(users, user)
}
return stacks, users, rows.Err()
}
// ReapIdleSessions cashes out everyone who walked away, and reports how many it
// sent home. Safe to run on a timer: a player who comes back simply buys in again,
// and a cash-out that's already in flight can't be opened twice because the chips
// are gone from the stack the moment the first one is.
func ReapIdleSessions(idleFor time.Duration) (int, error) {
stacks, users, err := IdleStacks(idleFor)
if err != nil {
return 0, err
}
reaped := 0
for i, user := range users {
if _, err := RequestCashOut(user, stacks[i].Chips); err != nil {
// One player's stack failing to reap shouldn't strand everyone else's.
if !errors.Is(err, ErrInsufficientChips) {
return reaped, fmt.Errorf("games: reap %s: %w", user, err)
}
continue
}
reaped++
}
return reaped, nil
}
// Touch marks a player as active, so the reaper leaves them alone. Called on any
// deliberate action at a table — not on a page load, or an open tab would keep a
// walked-away player's chips hostage forever.
func Touch(user string) {
exec("games: touch session",
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
nowUnix(), nowUnix(), user)
}
// ---- the hand in progress -------------------------------------------------
var (
// ErrNoLiveHand means the player isn't in a hand right now.
ErrNoLiveHand = errors.New("games: no hand in progress")
// ErrHandInProgress means they already are, and may not be dealt another.
ErrHandInProgress = errors.New("games: already in a hand")
)
// LiveHand is a hand a player is in the middle of. State is the engine's own
// State, serialized whole — the shoe is in there, which is exactly why this row
// never leaves the server.
type LiveHand struct {
Game string
State []byte
Seed1 uint64
Seed2 uint64
}
// StartLiveHand seats a *new* hand, and refuses if the player is already in one.
// The plain INSERT is the point: it is the primary key, not a prior read, that
// decides. Two Deal clicks racing each other would otherwise both see an empty
// felt, both take a stake, and the second would overwrite the first — taking the
// player's chips for a hand that no longer exists anywhere.
func StartLiveHand(user string, h LiveHand) error {
res, err := Get().Exec(
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(matrix_user) DO NOTHING`,
user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), nowUnix(),
)
if err != nil {
return fmt.Errorf("games: start live hand: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrHandInProgress
}
return nil
}
// SaveLiveHand stores the hand a player is in, replacing any earlier one. The
// player's stake has already left their stack by the time this is called, so
// the write is what makes the hand recoverable if Pete restarts mid-deal.
func SaveLiveHand(user string, h LiveHand) error {
now := nowUnix()
if _, err := Get().Exec(
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(matrix_user) DO UPDATE SET
game = excluded.game, state = excluded.state,
seed1 = excluded.seed1, seed2 = excluded.seed2, updated_at = excluded.updated_at`,
user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), now,
); err != nil {
return fmt.Errorf("games: save live hand: %w", err)
}
return nil
}
// LoadLiveHand returns the hand a player is in, or ErrNoLiveHand.
func LoadLiveHand(user string) (LiveHand, error) {
var h LiveHand
var state string
var s1, s2 int64
err := Get().QueryRow(
`SELECT game, state, seed1, seed2 FROM game_live_hands WHERE matrix_user = ?`, user,
).Scan(&h.Game, &state, &s1, &s2)
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)
return h, nil
}
// ClearLiveHand ends a hand. Called when it settles — the audit log in
// game_hands is what survives it.
func ClearLiveHand(user string) error {
if _, err := Get().Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil {
return fmt.Errorf("games: clear live hand: %w", err)
}
return nil
}
// HouseTake is the total rake collected since a given time — the number that
// answers "is this economy inflating".
func HouseTake(since int64) (int64, error) {
var total int64
if err := Get().QueryRow(
`SELECT COALESCE(SUM(rake), 0) FROM game_hands WHERE played_at >= ?`, since,
).Scan(&total); err != nil {
return 0, fmt.Errorf("games: house take: %w", err)
}
return total, nil
}

View File

@@ -0,0 +1,494 @@
package storage
import (
"errors"
"testing"
"time"
)
const player = "@reala:parodia.dev"
// fund runs a buy-in all the way through the happy path, so a test that needs
// chips on the table can just say so.
func fund(t *testing.T, user string, amount int64) {
t.Helper()
e, err := RequestBuyIn(user, amount)
if err != nil {
t.Fatal(err)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil {
t.Fatal(err)
}
}
func chipsOf(t *testing.T, user string) int64 {
t.Helper()
st, err := Chips(user)
if err != nil {
t.Fatal(err)
}
return st.Chips
}
func TestBuyIn_ChipsOnlyExistOnceGogobeeConfirms(t *testing.T) {
setupTestDB(t)
e, err := RequestBuyIn(player, 500)
if err != nil {
t.Fatal(err)
}
if e.State != EscrowRequested {
t.Fatalf("state = %q, want %q", e.State, EscrowRequested)
}
// Requested is not funded. Nothing is spendable yet — gogobee hasn't taken
// the euros, so creating chips here would mint money out of nothing.
st, err := Chips(player)
if err != nil {
t.Fatal(err)
}
if st.Chips != 0 {
t.Fatalf("chips = %d before settlement, want 0", st.Chips)
}
if st.Pending != 500 {
t.Fatalf("pending = %d, want 500", st.Pending)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
t.Fatal(err)
}
st, err = Chips(player)
if err != nil {
t.Fatal(err)
}
if st.Chips != 500 {
t.Fatalf("chips = %d after funding, want 500", st.Chips)
}
if st.Pending != 0 {
t.Fatalf("pending = %d after funding, want 0", st.Pending)
}
if st.EuroBalance != 4500 {
t.Fatalf("advisory euro balance = %v, want 4500", st.EuroBalance)
}
}
func TestBuyIn_RejectedCreatesNoChips(t *testing.T) {
setupTestDB(t)
e, err := RequestBuyIn(player, 500)
if err != nil {
t.Fatal(err)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 12)
if err != nil {
t.Fatal(err)
}
if got.State != EscrowRejected {
t.Fatalf("state = %q, want %q", got.State, EscrowRejected)
}
if c := chipsOf(t, player); c != 0 {
t.Fatalf("chips = %d after a rejected buy-in, want 0", c)
}
}
// The push queue retries. A verdict that lands twice must only move chips once.
func TestSettle_IsIdempotent(t *testing.T) {
setupTestDB(t)
e, err := RequestBuyIn(player, 500)
if err != nil {
t.Fatal(err)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
t.Fatalf("settle %d: %v", i, err)
}
}
if c := chipsOf(t, player); c != 500 {
t.Fatalf("chips = %d after three identical pushes, want 500", c)
}
}
// A late, contradictory push must not overturn a settled row — otherwise a
// delayed "rejected" could confiscate chips the player already won hands with.
func TestSettle_TerminalStateWins(t *testing.T) {
setupTestDB(t)
e, err := RequestBuyIn(player, 500)
if err != nil {
t.Fatal(err)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
t.Fatal(err)
}
got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 0)
if err != nil {
t.Fatal(err)
}
if got.State != EscrowFunded {
t.Fatalf("state = %q, want the original %q", got.State, EscrowFunded)
}
if c := chipsOf(t, player); c != 500 {
t.Fatalf("chips = %d, want 500 — a late rejection took funded chips", c)
}
}
func TestCashOut_ChipsLeaveImmediately(t *testing.T) {
setupTestDB(t)
fund(t, player, 1000)
e, err := RequestCashOut(player, 400)
if err != nil {
t.Fatal(err)
}
// The chips are gone the moment the row opens. If they lingered until gogobee
// confirmed, the player could bet them while the euros were also in flight —
// the same euro on both sides of the border.
if c := chipsOf(t, player); c != 600 {
t.Fatalf("chips = %d immediately after cash-out, want 600", c)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
got, err := SettleEscrow(e.GUID, true, "", 4400)
if err != nil {
t.Fatal(err)
}
if got.State != EscrowSettled {
t.Fatalf("state = %q, want %q", got.State, EscrowSettled)
}
if c := chipsOf(t, player); c != 600 {
t.Fatalf("chips = %d after settlement, want 600", c)
}
}
func TestCashOut_FailedCreditGivesTheChipsBack(t *testing.T) {
setupTestDB(t)
fund(t, player, 1000)
e, err := RequestCashOut(player, 400)
if err != nil {
t.Fatal(err)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
// gogobee couldn't pay. The chips were already destroyed here, so they have to
// come back — the player's money cannot simply evaporate at the border.
if _, err := SettleEscrow(e.GUID, false, "ledger_error", 0); err != nil {
t.Fatal(err)
}
if c := chipsOf(t, player); c != 1000 {
t.Fatalf("chips = %d after a failed cash-out, want the full 1000 back", c)
}
}
func TestCashOut_CannotExceedTheStack(t *testing.T) {
setupTestDB(t)
fund(t, player, 100)
if _, err := RequestCashOut(player, 500); !errors.Is(err, ErrInsufficientChips) {
t.Fatalf("err = %v, want ErrInsufficientChips", err)
}
if c := chipsOf(t, player); c != 100 {
t.Fatalf("chips = %d after a refused cash-out, want 100", c)
}
}
func TestBuyIn_TableCapCountsChipsAndInFlightBuyIns(t *testing.T) {
setupTestDB(t)
if _, err := RequestBuyIn(player, MaxChipsOnTable+1); !errors.Is(err, ErrOverTableCap) {
t.Fatalf("err = %v, want ErrOverTableCap", err)
}
fund(t, player, MaxChipsOnTable-1000)
// A second buy-in that fits is fine.
if _, err := RequestBuyIn(player, 1000); err != nil {
t.Fatal(err)
}
// A third, while the second is still in flight, must not clear the cap by
// racing — pending buy-ins count against it.
if _, err := RequestBuyIn(player, 1); !errors.Is(err, ErrOverTableCap) {
t.Fatalf("err = %v, want ErrOverTableCap — in-flight buy-ins must count", err)
}
}
func TestRequest_RejectsNonPositiveAmounts(t *testing.T) {
setupTestDB(t)
fund(t, player, 100)
for _, amount := range []int64{0, -50} {
if _, err := RequestBuyIn(player, amount); !errors.Is(err, ErrBadAmount) {
t.Errorf("buy-in %d: err = %v, want ErrBadAmount", amount, err)
}
if _, err := RequestCashOut(player, amount); !errors.Is(err, ErrBadAmount) {
t.Errorf("cash-out %d: err = %v, want ErrBadAmount", amount, err)
}
}
if c := chipsOf(t, player); c != 100 {
t.Fatalf("chips = %d, want 100", c)
}
}
func TestStakeAndAward(t *testing.T) {
setupTestDB(t)
fund(t, player, 500)
if err := Stake(player, 100); err != nil {
t.Fatal(err)
}
if c := chipsOf(t, player); c != 400 {
t.Fatalf("chips = %d after staking 100, want 400", c)
}
// A win pays the stake back plus the profit, net of rake.
if err := Award(player, 195); err != nil {
t.Fatal(err)
}
if c := chipsOf(t, player); c != 595 {
t.Fatalf("chips = %d after a 195 payout, want 595", c)
}
// You cannot bet chips you don't have.
if err := Stake(player, 10_000); !errors.Is(err, ErrInsufficientChips) {
t.Fatalf("err = %v, want ErrInsufficientChips", err)
}
if c := chipsOf(t, player); c != 595 {
t.Fatalf("chips = %d after a refused stake, want 595", c)
}
}
func TestStake_UnknownPlayerHasNothingToBet(t *testing.T) {
setupTestDB(t)
if err := Stake("@stranger:parodia.dev", 10); !errors.Is(err, ErrInsufficientChips) {
t.Fatalf("err = %v, want ErrInsufficientChips", err)
}
}
func TestPendingEscrow_OffersUnclaimedAndAbandonedRows(t *testing.T) {
setupTestDB(t)
fresh, err := RequestBuyIn(player, 100)
if err != nil {
t.Fatal(err)
}
abandoned, err := RequestBuyIn("@other:parodia.dev", 200)
if err != nil {
t.Fatal(err)
}
pending, err := PendingEscrow(10)
if err != nil {
t.Fatal(err)
}
if len(pending) != 2 {
t.Fatalf("%d pending rows, want 2", len(pending))
}
// Claim both: neither should be offered again while gogobee is working on them.
for _, e := range []Escrow{fresh, abandoned} {
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
}
pending, err = PendingEscrow(10)
if err != nil {
t.Fatal(err)
}
if len(pending) != 0 {
t.Fatalf("%d rows offered while claimed, want 0", len(pending))
}
// Now pretend gogobee died holding one of them. A claim that never came back
// must be re-offered, or the player's money sits in limbo forever.
stale := nowUnix() - int64(EscrowStaleAfter.Seconds()) - 1
if _, err := Get().Exec(`UPDATE game_escrow SET claimed_at = ? WHERE guid = ?`, stale, abandoned.GUID); err != nil {
t.Fatal(err)
}
pending, err = PendingEscrow(10)
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 || pending[0].GUID != abandoned.GUID {
t.Fatalf("stale claim was not re-offered: got %d rows", len(pending))
}
}
func TestClaim_CannotReopenAFinishedRow(t *testing.T) {
setupTestDB(t)
fund(t, player, 300)
e, err := RequestCashOut(player, 300)
if err != nil {
t.Fatal(err)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil {
t.Fatal(err)
}
// Re-claiming a settled cash-out must not walk it back to claimed, or gogobee
// would pay the same euros out twice.
got, err := ClaimEscrow(e.GUID)
if err != nil {
t.Fatal(err)
}
if got.State != EscrowSettled {
t.Fatalf("state = %q after re-claiming a settled row, want %q", got.State, EscrowSettled)
}
}
func TestEscrow_UnknownGUID(t *testing.T) {
setupTestDB(t)
if _, err := EscrowByGUID("nope"); !errors.Is(err, ErrNoSuchEscrow) {
t.Fatalf("err = %v, want ErrNoSuchEscrow", err)
}
if _, err := SettleEscrow("nope", true, "", 0); !errors.Is(err, ErrNoSuchEscrow) {
t.Fatalf("err = %v, want ErrNoSuchEscrow", err)
}
}
func TestReaper_CashesOutTheWalkedAway(t *testing.T) {
setupTestDB(t)
fund(t, player, 700)
// Still playing: the reaper must leave them alone.
Touch(player)
n, err := ReapIdleSessions(SessionIdleAfter)
if err != nil {
t.Fatal(err)
}
if n != 0 || chipsOf(t, player) != 700 {
t.Fatalf("reaped %d active players (chips now %d)", n, chipsOf(t, player))
}
// Now they've been gone an hour.
old := nowUnix() - int64((2 * time.Hour).Seconds())
if _, err := Get().Exec(`UPDATE game_chips SET last_played = ? WHERE matrix_user = ?`, old, player); err != nil {
t.Fatal(err)
}
n, err = ReapIdleSessions(SessionIdleAfter)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("reaped %d, want 1", n)
}
if c := chipsOf(t, player); c != 0 {
t.Fatalf("chips = %d after the reaper ran, want 0 — they should be euros now", c)
}
// And it must be a real cash-out, waiting for gogobee to pay it out.
pending, err := PendingEscrow(10)
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 || pending[0].Kind != KindCashOut || pending[0].Amount != 700 {
t.Fatalf("reaper did not queue a 700 cash-out: %+v", pending)
}
// Running again finds nothing left to reap.
if n, err := ReapIdleSessions(SessionIdleAfter); err != nil || n != 0 {
t.Fatalf("second sweep reaped %d (err=%v), want 0", n, err)
}
}
func TestRecordHand_AndHouseTake(t *testing.T) {
setupTestDB(t)
hands := []Hand{
{MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 195, Rake: 5, Outcome: "win", Seed1: 42, Seed2: 7},
{MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 0, Rake: 0, Outcome: "bust", Seed1: 43, Seed2: 8},
{MatrixUser: player, Game: "blackjack", Bet: 200, Payout: 486, Rake: 14, Outcome: "blackjack", Seed1: 44, Seed2: 9},
}
for _, h := range hands {
if err := RecordHand(h); err != nil {
t.Fatal(err)
}
}
take, err := HouseTake(0)
if err != nil {
t.Fatal(err)
}
if take != 19 {
t.Fatalf("house take = %d, want 19", take)
}
// The seeds have to survive the round trip, or a disputed hand can't be re-dealt.
var s1, s2 int64
if err := Get().QueryRow(
`SELECT seed1, seed2 FROM game_hands WHERE outcome = 'blackjack'`,
).Scan(&s1, &s2); err != nil {
t.Fatal(err)
}
if s1 != 44 || s2 != 9 {
t.Fatalf("seeds came back as (%d, %d), want (44, 9)", s1, s2)
}
}
// The invariant, end to end: every euro that entered the casino is either a chip
// on the table or a euro on its way home. None are minted, none evaporate.
func TestBorder_ChipsAreConserved(t *testing.T) {
setupTestDB(t)
fund(t, player, 1000)
// Play a losing hand and a winning one.
if err := Stake(player, 100); err != nil {
t.Fatal(err)
} // 900
if err := Stake(player, 100); err != nil {
t.Fatal(err)
} // 800
if err := Award(player, 195); err != nil {
t.Fatal(err)
} // 995 — one loss, one win less rake
if c := chipsOf(t, player); c != 995 {
t.Fatalf("chips = %d, want 995", c)
}
e, err := RequestCashOut(player, 995)
if err != nil {
t.Fatal(err)
}
if _, err := ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
if _, err := SettleEscrow(e.GUID, true, "", 4995); err != nil {
t.Fatal(err)
}
if c := chipsOf(t, player); c != 0 {
t.Fatalf("chips = %d after cashing out everything, want 0", c)
}
// 1000 in, 995 out, 5 lost to the table and the rake. Nothing left stranded.
st, err := Chips(player)
if err != nil {
t.Fatal(err)
}
if st.Pending != 0 {
t.Fatalf("pending = %d, want 0", st.Pending)
}
}

View File

@@ -132,10 +132,10 @@ func MarkClassified(guid, channel, platforms string) {
// GetStoryByGUID returns the full story record for a GUID, or nil if not found.
func GetStoryByGUID(guid string) (*Story, error) {
row := Get().QueryRow(
`SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at
`SELECT guid, headline, lede, COALESCE(content, ''), image_url, article_url, source, platforms, channel, seen_at
FROM stories WHERE guid = ?`, guid)
var s Story
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.Content, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
return nil, err
}
return &s, nil
@@ -252,6 +252,53 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
return &s, nil
}
// UnpostedAdventureSince returns adventure dispatches seen at or after `since`
// that have never been posted to Matrix (no post_log row). PRIORITY beats post
// live and so carry a post_log row; the ones left are exactly the BULLETINs the
// daily digest collects. Oldest first so the digest reads chronologically.
//
// At most `limit` rows come back, but total is how many match in all — the digest
// quotes it to readers, so it must count the window rather than the returned page.
func UnpostedAdventureSince(since int64, limit int) (stories []Story, total int, err error) {
const where = `WHERE classified = 1
AND channel = 'adventure'
AND seen_at >= ?
AND guid NOT IN (SELECT guid FROM post_log)`
if err := Get().QueryRow(`SELECT COUNT(*) FROM stories `+where, since).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := Get().Query(
`SELECT guid, headline, lede, article_url, seen_at
FROM stories `+where+`
ORDER BY seen_at ASC
LIMIT ?`, since, limit)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []Story
for rows.Next() {
var s Story
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ArticleURL, &s.SeenAt); err != nil {
return nil, 0, err
}
out = append(out, s)
}
return out, total, rows.Err()
}
// MarkAdventureDigested records each bulletin guid as posted (in a shared digest
// event) so the next daily digest doesn't re-collect it. Idempotent per guid via
// the post_log OR IGNORE. eventID is the digest's synthetic key (e.g.
// "adv-digest:2026-07-11") shared by every story that went out in that digest.
func MarkAdventureDigested(guids []string, eventID string) {
for _, g := range guids {
InsertPostLog(g, "adventure", eventID, "", false)
}
}
// ListClassifiedByChannel returns up to `limit` classified stories routed to a
// real channel, newest first, with optional offset for pagination. Sentinel
// channels (_discarded, _duplicate) are excluded.

108
internal/storage/roster.go Normal file
View File

@@ -0,0 +1,108 @@
package storage
import (
"database/sql"
"log/slog"
)
// RosterEntry is one adventurer's currently-true state, as of the last snapshot
// gogobee pushed. Not an event — nothing here is a thing that *happened*.
type RosterEntry struct {
Token string `json:"token"`
Name string `json:"name"`
Level int `json:"level"`
ClassRace string `json:"class_race"`
Status string `json:"status"` // "expedition" | "idle"
Zone string `json:"zone,omitempty"`
Region string `json:"region,omitempty"`
Day int `json:"day,omitempty"`
IdleHours int `json:"idle_hours,omitempty"`
SnapshotAt int64 `json:"snapshot_at"`
}
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
//
// Replace — never merge. A player who dropped out of gogobee's snapshot (deleted
// character, or a fresh `!news optout`) must vanish from the board, and an
// upsert would leave them standing there forever. The transaction means a reader
// mid-swap sees the old board or the new one, never a half-empty realm.
func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
tx, err := Get().Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`DELETE FROM adventure_roster`); err != nil {
return err
}
stmt, err := tx.Prepare(`
INSERT INTO adventure_roster
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, e := range entries {
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 {
return err
}
}
// Stamp the snapshot even when it carried zero adventurers — that is a
// legitimate state (quiet realm) and must not read as "gogobee went away".
if _, err := tx.Exec(`
INSERT INTO adventure_roster_meta (id, snapshot_at) VALUES (1, ?)
ON CONFLICT(id) DO UPDATE SET snapshot_at = excluded.snapshot_at`, snapshotAt); err != nil {
return err
}
return tx.Commit()
}
// LoadRoster returns the current board: everyone on an expedition first (most
// recently departed at the top of that group), then the idle, longest-idle last.
// Also returns the snapshot time so the caller can decide whether the wire has
// gone quiet — see rosterStale.
func LoadRoster() ([]RosterEntry, int64, error) {
rows, err := Get().Query(`
SELECT token, name, level, COALESCE(class_race, ''), status,
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at
FROM adventure_roster
ORDER BY status = 'expedition' DESC, day DESC, idle_hours ASC, level DESC, name ASC`)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []RosterEntry
for rows.Next() {
var e RosterEntry
if err := rows.Scan(&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt); err != nil {
return nil, 0, err
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return out, RosterSnapshotAt(), nil
}
// RosterSnapshotAt reports when the board was last refreshed, 0 if gogobee has
// never pushed one. Read from the meta row, not the entries, so a snapshot that
// legitimately carried nobody still counts as a snapshot.
func RosterSnapshotAt() int64 {
var at sql.NullInt64
err := Get().QueryRow(`SELECT snapshot_at FROM adventure_roster_meta WHERE id = 1`).Scan(&at)
if err == sql.ErrNoRows {
return 0
}
if err != nil {
slog.Error("RosterSnapshotAt query failed", "err", err)
return 0
}
return at.Int64
}

View File

@@ -21,6 +21,37 @@ CREATE TABLE IF NOT EXISTS stories (
published_at INTEGER
);
-- adventure_roster is a *snapshot*, not a log: gogobee POSTs the whole live
-- board and it replaces this table wholesale. Rows are state that is currently
-- true ("Josie is in holymachina"), which is the one thing the story feed can
-- never be — every dispatch there is an accomplishment, and an accomplishment is
-- a clipping the moment it lands.
--
-- token is gogobee's per-player roster token, not a Matrix handle and not a
-- story GUID. Players who ran "!news optout" are omitted from the snapshot
-- upstream and so never appear here at all.
CREATE TABLE IF NOT EXISTS adventure_roster (
token TEXT PRIMARY KEY,
name TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
class_race TEXT,
status TEXT NOT NULL, -- "expedition" | "idle"
zone TEXT,
region TEXT,
day INTEGER NOT NULL DEFAULT 0, -- expedition day, 0 if idle
idle_hours INTEGER NOT NULL DEFAULT 0, -- hours since last player action
snapshot_at INTEGER NOT NULL -- when gogobee took the snapshot
);
-- The snapshot time lives outside the rows because an *empty* board is
-- ambiguous: either nobody is playing, or gogobee has stopped talking to us. A
-- MAX(snapshot_at) over zero rows can't tell those apart, and the page must —
-- one is "quiet realm", the other is "the wire is down, trust nothing here".
CREATE TABLE IF NOT EXISTS adventure_roster_meta (
id INTEGER PRIMARY KEY CHECK (id = 1),
snapshot_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS post_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT NOT NULL,
@@ -128,6 +159,114 @@ CREATE TABLE IF NOT EXISTS story_views (
PRIMARY KEY (story_id, day)
);
-- ---------------------------------------------------------------------------
-- games.parodia.dev
--
-- The invariant the whole casino rests on: a euro is either in gogobee's
-- euro_balances or in Pete's chip escrow, never both. It crosses between them
-- only via a GUID-idempotent claim, and Pete never writes a euro balance —
-- gogobee does, when it claims the escrow row and tells us how it went.
-- ---------------------------------------------------------------------------
-- A player's chips: euros that have crossed into the casino and haven't crossed
-- back yet. 1:1 with euros. Keyed by Matrix user id, because that's the identity
-- gogobee's ledger uses and the one an Authentik username maps onto.
CREATE TABLE IF NOT EXISTS game_chips (
matrix_user TEXT PRIMARY KEY,
chips INTEGER NOT NULL DEFAULT 0,
-- Advisory only, and stale by design: the last euro balance gogobee told us
-- about. Displayed, never trusted. The authoritative check is the debit at
-- claim time, which happens on gogobee's box against gogobee's ledger.
euro_balance REAL,
last_played INTEGER NOT NULL DEFAULT 0, -- unix; the reaper reads this
updated_at INTEGER NOT NULL DEFAULT 0
);
-- One crossing of the euro/chip border, in either direction.
--
-- requested -> claimed -> funded (buy-in: gogobee debited, chips spendable)
-- -> rejected (buy-in: insufficient funds, no chips)
-- requested -> claimed -> settled (cash-out: chips gone, euros credited)
--
-- The guid is the idempotency key end to end: it's what gogobee passes to
-- DebitIdem/CreditIdem, so a claim whose ack is lost on the wire can be retried
-- without the player paying twice.
CREATE TABLE IF NOT EXISTS game_escrow (
guid TEXT PRIMARY KEY,
matrix_user TEXT NOT NULL,
kind TEXT NOT NULL, -- 'buyin' | 'cashout'
amount INTEGER NOT NULL, -- euros == chips
state TEXT NOT NULL, -- see the ladder above
reason TEXT, -- 'insufficient_funds', when rejected
balance_after REAL, -- gogobee's euro balance after the move
created_at INTEGER NOT NULL,
claimed_at INTEGER, -- when gogobee took it; drives the re-poll
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_game_escrow_state ON game_escrow(state, created_at);
CREATE INDEX IF NOT EXISTS idx_game_escrow_user ON game_escrow(matrix_user, created_at DESC);
-- Every hand played, for money. This is the audit trail: seeds so a disputed
-- hand can be re-dealt exactly as it fell, rake so the house's take is
-- accountable, and enough shape to answer "how fast is this economy actually
-- moving" before the answer becomes a problem.
CREATE TABLE IF NOT EXISTS game_hands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
matrix_user TEXT NOT NULL,
game TEXT NOT NULL, -- 'blackjack'
bet INTEGER NOT NULL,
payout INTEGER NOT NULL, -- chips returned, net of rake
rake INTEGER NOT NULL,
outcome TEXT NOT NULL,
seed1 INTEGER NOT NULL, -- the shoe, reproducible
seed2 INTEGER NOT NULL,
played_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_game_hands_user ON game_hands(matrix_user, played_at DESC);
CREATE INDEX IF NOT EXISTS idx_game_hands_played ON game_hands(played_at);
-- The hand a player is in the middle of. One per player: you cannot be dealt a
-- second hand while chips are riding on the first.
--
-- The state column is the engine's State, serialized whole — shoe included. It
-- lives here rather than in memory because Pete redeploys often, and a player
-- whose stake has already been taken must find their cards where they left them
-- rather than a table that has forgotten them. It is also why the deck never
-- goes to the browser: the authoritative shoe is this row, on the server.
CREATE TABLE IF NOT EXISTS game_live_hands (
matrix_user TEXT PRIMARY KEY,
game TEXT NOT NULL, -- 'blackjack'
state TEXT NOT NULL, -- JSON: the engine's State
seed1 INTEGER NOT NULL, -- carried to the audit log when it settles
seed2 INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- 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);

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
}

467
internal/web/adventure.go Normal file
View File

@@ -0,0 +1,467 @@
package web
import (
"crypto/subtle"
"encoding/json"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"pete/internal/storage"
)
// AdvFact is the game-event fact gogobee POSTs to Pete. It mirrors the contract
// in pete_adventure_news_voice.md. Names are character names only (never Matrix
// handles) and Actors is the allow-list of the only names permitted to appear in
// rendered output.
type AdvFact struct {
GUID string `json:"guid"`
EventType string `json:"event_type"`
Tier string `json:"tier"` // "priority" | "bulletin"
Actors []string `json:"actors"`
Subject string `json:"subject"`
Opponent string `json:"opponent"`
Boss string `json:"boss"`
Zone string `json:"zone"`
Region string `json:"region"`
Level int `json:"level"`
Count int `json:"count"`
Outcome string `json:"outcome"`
Stakes string `json:"stakes"`
ClassRace string `json:"class_race"`
Milestone string `json:"milestone"`
OccurredAt int64 `json:"occurred_at"`
NoPush bool `json:"no_push"`
}
// AdvPost is a priority adventure item to post live to Matrix. Kept minimal and
// web-local so the web package needs no dependency on internal/poster.
type AdvPost struct {
GUID string
Headline string
Lede string
ImageURL string
ArticleURL string
Source string
Channel string
}
// PriorityPoster posts a priority adventure item to Matrix immediately. main
// adapts *poster.Queue.PostNow to this; nil in web-only/local modes.
type PriorityPoster func(AdvPost)
const advSource = "Pete"
// advBackfillEvent is the synthetic post_log event id used to retire a no_push
// (cold-start backfill) dispatch against the daily digest. It never went to
// Matrix; the row exists only so the digest skips it.
const advBackfillEvent = "adv-backfill"
// handleAdventureIngest receives a game-event fact from gogobee, templates it
// into a deterministic story, publishes it to the /adventure section, and posts
// PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID.
func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var f AdvFact
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&f); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if f.GUID == "" || f.EventType == "" {
http.Error(w, "guid and event_type are required", http.StatusBadRequest)
return
}
// Fact-guard: any player name we render must be in the actors allow-list.
// gogobee pre-sanitizes, but Pete never trusts the channel — this is the
// last line before a name reaches a public page.
if !factGuard(f) {
slog.Warn("adventure ingest: fact-guard rejected", "guid", f.GUID, "event_type", f.EventType)
http.Error(w, "fact-guard: subject/opponent not in actors", http.StatusBadRequest)
return
}
headline, lede, ok := renderAdventure(f)
if !ok {
http.Error(w, "unknown event_type", http.StatusBadRequest)
return
}
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success.
if storage.IsGUIDSeen(f.GUID) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("duplicate"))
return
}
// A fact with no occurred_at would otherwise be stored at the Unix epoch:
// dated 1970 on the permalink, pinned to the bottom of the section, and
// outside every digest window. Treat "missing" as "now".
occurredAt := f.OccurredAt
if occurredAt <= 0 {
occurredAt = time.Now().Unix()
}
articleURL := s.advPermalink(f.GUID)
imageURL := advArtURL(f.EventType)
if err := storage.InsertStory(&storage.Story{
GUID: f.GUID,
Headline: headline,
Lede: lede,
ImageURL: imageURL,
ArticleURL: articleURL,
Source: advSource,
Channel: "adventure",
Classified: true,
SeenAt: occurredAt,
PublishedAt: occurredAt,
}); err != nil {
slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err)
http.Error(w, "insert failed", http.StatusInternalServerError)
return
}
slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
// NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only
// the live post isn't enough: the digest collects adventure rows that carry no
// post_log entry, so a backfilled bulletin would still be swept into the next
// roundup — the back-catalogue dump NoPush exists to prevent. Retire the guid
// against the digest up front instead.
if f.NoPush {
storage.MarkAdventureDigested([]string{f.GUID}, advBackfillEvent)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
return
}
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
// digest. Website section always gets the row above regardless of tier.
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
// media), and the link's og:image carries the preview instead.
// No Source: the source tag exists to credit an outlet Pete is relaying
// (`ars technica`). On his own reporting it renders as him signing his
// own name under his own message.
s.advPost(AdvPost{
GUID: f.GUID,
Headline: headline,
Lede: lede,
ArticleURL: articleURL,
Channel: s.adv.Channel,
})
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
// advEventMeta maps an event_type to a short display label and emoji used by the
// permalink page (and, later, OG art selection). Unknown types fall back to a
// neutral dispatch label so a new gogobee event never renders blank.
func advEventMeta(eventType string) (label, emoji string) {
switch eventType {
case "siege_start", "siege_win", "siege_loss":
return "The Siege", "🏰"
case "boss_first", "boss_kill":
return "Boss down", "🐉"
case "zone_first":
return "First clear", "🗺️"
case "zone_clear":
return "Zone cleared", "🗺️"
case "death":
return "In memoriam", "🪦"
case "arrival":
return "New arrival", "👋"
case "standings", "rival_result":
return "The rival board", "⚔️"
case "pete_duel_loss", "pete_duel_win":
return "Pete's duels", "🤝"
case "milestone":
return "Milestone", "🏅"
case "retreat":
return "Pulled out", "🎒"
case "departure":
return "Wandered off", "🚪"
case "mischief_contract":
return "Coin on their head", "😈"
case "mischief_survived":
return "They walked away", "🛡️"
case "mischief_downed":
return "The contract landed", "💀"
case "mischief_fizzled":
return "Nobody home", "🚪"
}
return "Dispatch", "📣"
}
// advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by
// handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses
// the external-image thumbnailer.
func advArtURL(eventType string) string {
return "/adventure/art/" + eventType + ".svg"
}
// handleAdventureArt renders the themed emblem for an event type — an adventure
// gradient with the event's emoji and label. Deterministic and dependency-free
// (no external asset), so every dispatch card has visual identity instead of the
// blank placeholder that made the section look broken next to RSS cards.
func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
eventType := strings.TrimSuffix(r.PathValue("type"), ".svg")
label, emoji := advEventMeta(eventType)
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = fmt.Fprintf(w, advArtSVG, template.HTMLEscapeString(emoji), template.HTMLEscapeString(strings.ToUpper(label)))
}
// advArtSVG is the emblem template: %s = emoji, %s = label. 1200×630 (the OG
// card ratio) so the same image works as a link-preview image.
const advArtSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7c5ce8"/>
<stop offset="1" stop-color="#5836b8"/>
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#g)"/>
<text x="600" y="300" font-size="260" text-anchor="middle" dominant-baseline="central">%s</text>
<text x="600" y="500" font-size="64" font-family="Fredoka, Nunito, system-ui, sans-serif" font-weight="700" fill="#ffffff" text-anchor="middle" letter-spacing="6" opacity="0.92">%s</text>
</svg>`
// advStoryPage is the per-story permalink view. It reuses the shared layout so a
// dispatch reads like the rest of the site, with an adventure-themed hero.
type advStoryPage struct {
pageData
EventLabel string
Emoji string
Headline string
Body string
Region string
When string
Permalink string
}
// handleAdventureStory serves the server-rendered permalink for one dispatch
// (the article_url every ingested story points at). Public and cacheable; 404s
// when the section is disabled or the guid is unknown. Character names in the
// stored headline/body already passed the ingest fact-guard, so nothing
// player-controlled reaches here unchecked.
func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
guid := r.PathValue("guid")
st, err := storage.GetStoryByGUID(guid)
if err != nil || st == nil || st.Channel != "adventure" {
http.NotFound(w, r)
return
}
s.track(r, "adventure")
// event_type is encoded in the guid prefix (e.g. "death:<hash>:<ts>"); fall
// back to the neutral dispatch meta when it isn't a known type.
eventType, _, _ := strings.Cut(guid, ":")
label, emoji := advEventMeta(eventType)
body := st.Content
if strings.TrimSpace(body) == "" {
body = st.Lede // template-only dispatches carry the write-up in the lede
}
base := s.base(r)
base.Active = "adventure"
base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
if abs := strings.TrimRight(s.cfg.BaseURL, "/"); abs != "" {
base.OGImage = abs + advArtURL(eventType) // emblem for link unfurls
}
s.render(w, "story", advStoryPage{
pageData: base,
EventLabel: label,
Emoji: emoji,
Headline: st.Headline,
Body: body,
Region: "", // reserved: region isn't stored on the row yet
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
Permalink: s.advPermalink(guid),
})
}
// bearerOK checks the Authorization: Bearer header against the configured ingest
// token in constant time.
func (s *Server) bearerOK(r *http.Request) bool {
const prefix = "Bearer "
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, prefix) || s.adv.IngestToken == "" {
return false
}
got := strings.TrimPrefix(h, prefix)
return subtle.ConstantTimeCompare([]byte(got), []byte(s.adv.IngestToken)) == 1
}
// siteURL makes a root-relative path absolute against BaseURL. Links that go out
// to Matrix need the absolute form to survive safeHref; when BaseURL isn't
// configured the relative form is all we have, and it's still fine on-site.
func (s *Server) siteURL(path string) string {
return strings.TrimRight(s.cfg.BaseURL, "/") + path
}
// advPermalink builds the per-story Pete permalink used as article_url (the card
// link + Matrix link). The guid is path-escaped: it's ingest-supplied, and a
// stray "/" or "?" would otherwise produce a link that routes somewhere else.
func (s *Server) advPermalink(guid string) string {
return s.siteURL("/adventure/" + url.PathEscape(guid))
}
// factGuard verifies every player-name field we might render is present in the
// actors allow-list. Boss/zone/region/milestone are game-authored content, not
// player-controlled, so they are not guarded.
func factGuard(f AdvFact) bool {
allow := make(map[string]bool, len(f.Actors))
for _, a := range f.Actors {
if a != "" {
allow[a] = true
}
}
if f.Subject != "" && !allow[f.Subject] {
return false
}
if f.Opponent != "" && !allow[f.Opponent] {
return false
}
return true
}
// renderAdventure returns the deterministic headline + lede for a fact. Copied
// verbatim from the voice spec (pete_adventure_news_voice.md). Template-only —
// no LLM — so output is safe and reproducible. ok is false for an unknown type.
func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
atLevel := ""
if f.Level > 0 {
atLevel = fmt.Sprintf(", at level %d", f.Level)
}
switch f.EventType {
case "siege_start":
return fmt.Sprintf("Breaking: %s is marching on the town.", f.Boss),
fmt.Sprintf("Folks, this is the big one — %s has camped outside the gates and the whole community's needed to turn it back. You've got %s. Let's rally.", f.Boss, f.Stakes), true
case "siege_win":
return fmt.Sprintf("The town holds! %s turned back.", f.Boss),
fmt.Sprintf("What a turnout — %d defenders stood shoulder to shoulder and sent %s packing. Spoils are going out now. Proud of you all.", f.Count, f.Boss), true
case "siege_loss":
return fmt.Sprintf("Heavy news: %s broke through.", f.Boss),
fmt.Sprintf("We gave it everything, but %s got past the gates and took its tribute. We'll be ready next time — heads up, everyone.", f.Boss), true
case "boss_first":
return fmt.Sprintf("First ever: %s brings down %s.", f.Subject, f.Boss),
fmt.Sprintf("History in %s today — %s is the first anyone's seen clear %s. Nobody had done it before. Hats off.", f.Region, f.Subject, f.Boss), true
case "boss_kill":
return fmt.Sprintf("%s takes down %s again.", f.Subject, f.Boss),
fmt.Sprintf("Another clean run in %s today. Routine for %s by now — but still worth a nod.", f.Zone, f.Subject), true
case "zone_first", "zone_clear":
// gogobee splits the realm's first-ever clear (zone_first, priority) from a
// later repeat (zone_clear, bulletin); they share a lede but differ in
// headline. Fall back to the tier for a legacy zone_first that predates the
// split.
if f.EventType == "zone_first" || f.Tier == "priority" {
headline = fmt.Sprintf("%s cleared for the very first time.", f.Zone)
} else {
headline = fmt.Sprintf("%s clears %s.", f.Subject, f.Zone)
}
inRegion := ""
if f.Region != "" {
inRegion = " in " + f.Region
}
return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true
case "death":
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
case "retreat":
// An expedition that came apart without killing anyone. Until gogobee
// started sending these, the feed had no way to say "it went badly and
// everyone lived" — so it never said it, and the classes that retreat
// often simply never appeared. Warm, not a failure notice: everyone came
// home, and that is the part Pete leads with.
howFar := "barely a day in"
if f.Count > 1 {
howFar = fmt.Sprintf("%d days in", f.Count)
}
return fmt.Sprintf("%s backed out of %s.", f.Subject, f.Zone),
fmt.Sprintf("%s turned around %s — %s got the better of them this time%s, and they made the call to walk out rather than push it. Everybody came home breathing, which is the bit that counts. That dungeon'll still be there next week.",
f.Subject, howFar, f.Zone, atLevel), true
case "departure":
// A bored adventurer let themselves out. Nobody sent them — they got
// restless waiting on a player who wasn't coming, took the cheap supplies
// they could afford, and went. Pete plays it straight and a little fond;
// the joke tells itself, and the player it's about may well be reading.
return fmt.Sprintf("%s got bored and left without waiting.", f.Subject),
fmt.Sprintf("No orders, no escort, no fuss — %s packed the cheapest kit on the shelf and set off into %s%s. Nobody told them to. Nobody talked them out of it either. We'll let you know how it goes.", f.Subject, f.Zone, atLevel), true
case "mischief_contract":
// Somebody paid to have a monster sent after an adventurer who is out in a
// dungeon right now. Anonymous unless the buyer paid extra to sign it, and
// the anonymity is the story: Pete reports the money, not the name he
// doesn't have. Opponent carries the buyer only when it's public.
if f.Opponent != "" {
return fmt.Sprintf("%s has put %s on %s's head.", f.Opponent, f.Stakes, f.Subject),
fmt.Sprintf("No secret about it — %s paid for a %s to go find %s out in whatever hole they're currently down, and signed the thing. It's out there looking right now. If %s comes back breathing, they keep a cut of that money.",
f.Opponent, strings.ToLower(f.Boss), f.Subject, f.Subject), true
}
return fmt.Sprintf("Someone's put %s on %s's head.", f.Stakes, f.Subject),
fmt.Sprintf("Word came in quiet: %s has been paid for a %s to go looking for %s, and whoever paid it isn't saying so. It's already out there. Survive it and the money's theirs — and we all find out who signed the cheque.",
f.Stakes, strings.ToLower(f.Boss), f.Subject), true
case "mischief_survived":
// The unseal. A survival is the only thing that names an anonymous buyer,
// and it is the whole brake on casual griefing — so Pete leads with it.
return fmt.Sprintf("%s walked away from it. It was %s who paid.", f.Subject, f.Opponent),
fmt.Sprintf("%s came for %s, and %s is the one still standing — %s richer for the trouble. The contract's been opened up, and the name inside it is %s. Make of that what you will, folks.",
f.Boss, f.Subject, f.Subject, f.Stakes, f.Opponent), true
case "mischief_downed":
who := "Nobody's saying who paid for it"
if f.Opponent != "" {
who = fmt.Sprintf("%s paid for it, and put their name on it", f.Opponent)
}
return fmt.Sprintf("%s didn't walk away.", f.Subject),
fmt.Sprintf("A %s found %s mid-run%s and put them on the floor. They're being carried home — alive, which is more than the contract asked for, but that expedition's finished. %s.",
f.Boss, f.Subject, atLevel, who), true
case "mischief_fizzled":
return fmt.Sprintf("The monster sent for %s arrived to an empty dungeon.", f.Subject),
fmt.Sprintf("Somebody spent good money to have %s ambushed, and %s had already gone home. It wandered the halls for a bit and left. Most of the fee's been refunded. The rest, the town's keeping.",
f.Subject, f.Subject), true
case "arrival":
return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true
case "standings":
return "The rival board's been shaken up.",
fmt.Sprintf("%s is on the move — here's where the standings sit today.", f.Subject), true
case "rival_result":
return fmt.Sprintf("%s settles the score with %s.", f.Subject, f.Opponent),
fmt.Sprintf("Their duel went %s's way today, and the board reflects it. Good match, you two.", f.Subject), true
case "pete_duel_loss":
if f.Tier == "priority" {
headline = fmt.Sprintf("You got me, %s.", f.Subject)
} else {
headline = fmt.Sprintf("%s got the better of me again.", f.Subject)
}
return headline, fmt.Sprintf("Credit where it's due — %s beat me fair and square. Good duel. I'll want a rematch when you're ready.", f.Subject), true
case "pete_duel_win":
return fmt.Sprintf("Held my ground against %s today.", f.Subject),
fmt.Sprintf("Closer than the record will show, honestly — %s pushed me. Rematch whenever you like.", f.Subject), true
case "milestone":
return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
fmt.Sprintf("One for the books — %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
}
return "", "", false
}

View File

@@ -0,0 +1,154 @@
package web
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"pete/internal/storage"
)
// The BULLETIN digest is the batched counterpart to the live PRIORITY beats.
// Once a day (Adventure.DigestHour, UTC) Pete collects the adventure dispatches
// that were seen since the last digest but never posted live — exactly the
// bulletins — and posts a single warm roundup to the adventure channel. The
// website already carries each one as its own card; the digest is the Matrix-only
// nudge so quiet-but-real activity surfaces without one ping per event.
const (
// digestWindow bounds how far back a digest looks. Wider than a day so a
// missed run (process down over a digest hour) still sweeps up the gap;
// re-collection is prevented by MarkAdventureDigested, not by the window.
digestWindow = 48 * time.Hour
// digestCap bounds a single digest. Far past the observed volume (a dormant
// community, per the voice spec); a runaway just truncates with a log line.
digestCap = 40
// digestPreview is how many headlines the roundup lede lists by name before
// collapsing the rest to "…and N more".
digestPreview = 4
)
// StartAdventureDigest launches the daily bulletin-digest loop. No-op unless the
// section is enabled AND there's a live Matrix poster AND a channel to post to —
// i.e. website-only and local modes never post a digest.
func (s *Server) StartAdventureDigest(ctx context.Context) {
if !s.adv.Enabled || s.advPost == nil || s.adv.Channel == "" {
return
}
go s.runAdventureDigest(ctx)
}
// runAdventureDigest sleeps until the next DigestHour, posts, then repeats every
// 24h. Sleeping to a wall-clock hour (not a fixed ticker from boot) keeps the
// digest at a predictable time of day across restarts.
func (s *Server) runAdventureDigest(ctx context.Context) {
hour := s.adv.DigestHourOrDefault()
slog.Info("web: adventure digest scheduler started", "digest_hour_utc", hour, "channel", s.adv.Channel)
for {
wait := durUntilNextHour(time.Now().UTC(), hour)
select {
case <-ctx.Done():
return
case <-time.After(wait):
s.postDailyDigest(time.Now().UTC())
}
}
}
// durUntilNextHour returns the duration from now to the next occurrence of the
// given UTC hour. If it's exactly the hour now, it targets tomorrow so a restart
// at the hour doesn't double-fire.
func durUntilNextHour(now time.Time, hour int) time.Duration {
next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC)
if !next.After(now) {
next = next.Add(24 * time.Hour)
}
return next.Sub(now)
}
// postDailyDigest collects the window's un-posted bulletins, posts one roundup,
// and marks them digested so they don't recur. Silent when there's nothing new —
// a dormant realm should stay quiet, not ship an empty digest.
func (s *Server) postDailyDigest(now time.Time) {
since := now.Add(-digestWindow).Unix()
bulletins, total, err := storage.UnpostedAdventureSince(since, digestCap)
if err != nil {
slog.Error("adventure digest: query failed", "err", err)
return
}
if len(bulletins) == 0 {
return
}
if total > len(bulletins) {
slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap, "total", total)
}
date := now.Format("2006-01-02")
eventID := "adv-digest:" + date
headline, lede := buildDigest(bulletins, total)
s.advPost(AdvPost{
GUID: eventID,
Headline: headline,
Lede: lede,
ArticleURL: s.digestURL(date),
Channel: s.adv.Channel,
})
guids := make([]string, len(bulletins))
for i, b := range bulletins {
guids[i] = b.GUID
}
storage.MarkAdventureDigested(guids, eventID)
slog.Info("adventure digest: posted", "date", date, "items", len(bulletins))
}
// buildDigest renders the roundup headline + lede in Pete's warm reporter voice.
// total is how many bulletins the window actually holds, which is larger than
// len(bulletins) when the cap truncated the fetch — the counts quoted to readers
// have to describe the realm, not the slice. Headlines come from stored,
// fact-guarded rows, so no player-controlled text is introduced here.
func buildDigest(bulletins []storage.Story, total int) (headline, lede string) {
if total == 1 {
return "Today in the realm: one dispatch.",
fmt.Sprintf("Quiet day out there, but one worth noting — %s Full story on the board.", trimHeadline(bulletins[0].Headline))
}
headline = fmt.Sprintf("Today in the realm: %d dispatches.", total)
shown := bulletins
if len(shown) > digestPreview {
shown = shown[:digestPreview]
}
parts := make([]string, len(shown))
for i, b := range shown {
parts[i] = trimHeadline(b.Headline)
}
list := strings.Join(parts, " ")
more := ""
if total > len(shown) {
more = fmt.Sprintf(" …and %d more.", total-len(shown))
}
return headline, fmt.Sprintf("Here's what came across the wire today. %s%s The full board's on the site.", list, more)
}
// trimHeadline ensures a headline ends with sentence punctuation so the joined
// lede reads as prose rather than a run-on.
func trimHeadline(h string) string {
h = strings.TrimSpace(h)
if h == "" {
return ""
}
if last := h[len(h)-1]; last != '.' && last != '!' && last != '?' {
h += "."
}
return h
}
// digestURL points the roundup at the section, with a per-day query param so the
// canonical-URL dedup treats each day's digest as distinct (it strips only
// tracking params, keeping ?digest=).
func (s *Server) digestURL(date string) string {
return s.siteURL("/adventure?digest=" + date)
}

View File

@@ -0,0 +1,55 @@
package web
import (
"strings"
"testing"
"pete/internal/storage"
)
// The retreat bulletin — gogobee's newest event type.
//
// The failure this guards against is silent and total: handleAdventureIngest
// answers an unrecognized event_type with 400, and gogobee's sender treats any
// non-2xx as a failure, retries eight times over ~two hours, and then PARKS the
// row forever. So a gogobee that emits `retreat` against a Pete that doesn't
// know the word doesn't log an error anyone reads — it just quietly drops every
// retreat the realm ever files. Pete has to learn the word first, and this test
// is what says he has.
func TestAdventureIngest_AcceptsRetreat(t *testing.T) {
const token = "s3cret-token"
s, posted := newAdvServer(t, token)
f := AdvFact{
GUID: "retreat:abc:1000", EventType: "retreat", Tier: "bulletin",
Actors: []string{"Brannigan"}, Subject: "Brannigan",
Zone: "the Underforge", Level: 12, Count: 3, Outcome: "retreated",
OccurredAt: 1000,
}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d body=%s — Pete rejected a retreat, so gogobee will "+
"retry it eight times and park it forever", rw.Code, rw.Body.String())
}
got, err := storage.GetStoryByGUID("retreat:abc:1000")
if err != nil || got == nil {
t.Fatalf("retreat was accepted but not stored: %v", err)
}
// It has to actually say what happened, in Pete's voice, using only the
// supplied facts.
body := got.Headline + " " + got.Lede
for _, want := range []string{"Brannigan", "the Underforge"} {
if !strings.Contains(body, want) {
t.Errorf("rendered retreat is missing %q: %q", want, body)
}
}
if !strings.Contains(got.Lede, "3 days in") {
t.Errorf("the day count never made it into the copy: %q", got.Lede)
}
// Bulletin, not priority: a retreat goes in the daily digest, it does not
// interrupt the room. Pete announcing every failed run live would be a
// firehose — and an unkind one.
if len(*posted) != 0 {
t.Errorf("a bulletin was posted live: %+v", *posted)
}
}

View File

@@ -0,0 +1,371 @@
package web
import (
"bytes"
"encoding/json"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"pete/internal/config"
"pete/internal/storage"
)
// newAdvServer builds a web server with the adventure seam enabled and a
// capturing priority poster, backed by a fresh temp DB.
func newAdvServer(t *testing.T, token string) (*Server, *[]AdvPost) {
t.Helper()
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "adv.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
var posted []AdvPost
adv := config.AdventureConfig{Enabled: true, IngestToken: token, Channel: "adventure"}
// Mirror the production poster's side effect: queue.PostNow records a
// post_log row, which is exactly what marks a beat "posted" and thus
// excludes it from the bulletin digest.
poster := func(p AdvPost) {
posted = append(posted, p)
storage.InsertPostLog(p.GUID, "adventure", p.GUID, "", false)
}
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example"},
nil, true, adv, poster)
if err != nil {
t.Fatal(err)
}
return s, &posted
}
func postFact(t *testing.T, s *Server, token string, f AdvFact) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(f)
req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rw := httptest.NewRecorder()
s.handleAdventureIngest(rw, req)
return rw
}
// TestAdventureIngestEndToEnd covers the seam: a priority death fact is
// bearer-accepted, templated, stored as an adventure story, and posted live.
func TestAdventureIngestEndToEnd(t *testing.T) {
const token = "s3cret-token"
s, posted := newAdvServer(t, token)
f := AdvFact{
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan",
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String())
}
got, err := storage.GetStoryByGUID("death:abc:1000")
if err != nil || got == nil {
t.Fatalf("story not stored: %v", err)
}
if got.Channel != "adventure" || got.Source != advSource {
t.Errorf("channel/source = %q/%q", got.Channel, got.Source)
}
if got.Headline != "We lost Brannigan in the Underforge." {
t.Errorf("headline = %q", got.Headline)
}
if got.ArticleURL != "https://news.example/adventure/death:abc:1000" {
t.Errorf("article_url = %q", got.ArticleURL)
}
if len(*posted) != 1 || (*posted)[0].GUID != f.GUID {
t.Fatalf("priority post not delivered: %+v", *posted)
}
// Idempotent re-delivery: no error, no second post.
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("dup ingest status = %d", rw.Code)
}
if len(*posted) != 1 {
t.Errorf("duplicate fact re-posted: %d posts", len(*posted))
}
}
// TestAdventurePermalink renders the per-story page the article_url points at:
// an ingested dispatch must be fetchable at /adventure/{guid} with its headline
// and body, and an unknown guid must 404.
func TestAdventurePermalink(t *testing.T) {
const token = "t"
s, _ := newAdvServer(t, token)
f := AdvFact{
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan",
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d", rw.Code)
}
req := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
req.SetPathValue("guid", "death:abc:1000")
rw := httptest.NewRecorder()
s.handleAdventureStory(rw, req)
if rw.Code != 200 {
t.Fatalf("permalink status = %d body=%s", rw.Code, rw.Body.String())
}
body := rw.Body.String()
if !bytes.Contains([]byte(body), []byte("We lost Brannigan in the Underforge.")) {
t.Errorf("permalink missing headline; body=%s", body)
}
if !bytes.Contains([]byte(body), []byte("In memoriam")) {
t.Errorf("permalink missing event label; body=%s", body)
}
// Unknown guid 404s.
req2 := httptest.NewRequest("GET", "/adventure/nope:1", nil)
req2.SetPathValue("guid", "nope:1")
rw2 := httptest.NewRecorder()
s.handleAdventureStory(rw2, req2)
if rw2.Code != 404 {
t.Errorf("unknown guid status = %d, want 404", rw2.Code)
}
}
// TestDurUntilNextHour targets the next UTC occurrence of the digest hour and
// rolls to tomorrow when it's already that hour (so a restart can't double-fire).
func TestDurUntilNextHour(t *testing.T) {
// 14:30 UTC, targeting 17:00 → 2h30m today.
now := time.Date(2026, 7, 11, 14, 30, 0, 0, time.UTC)
if got := durUntilNextHour(now, 17); got != 2*time.Hour+30*time.Minute {
t.Errorf("before hour: got %v", got)
}
// 17:00 exactly → tomorrow's 17:00 (24h), not zero.
now = time.Date(2026, 7, 11, 17, 0, 0, 0, time.UTC)
if got := durUntilNextHour(now, 17); got != 24*time.Hour {
t.Errorf("at hour: got %v", got)
}
// 20:00, targeting 17:00 → tomorrow, 21h.
now = time.Date(2026, 7, 11, 20, 0, 0, 0, time.UTC)
if got := durUntilNextHour(now, 17); got != 21*time.Hour {
t.Errorf("after hour: got %v", got)
}
}
// TestAdventureDigest covers the batched path: bulletins (no live post) are
// collected into one roundup, priority beats are excluded (they already posted),
// digested bulletins don't recur, and an empty window stays silent.
func TestAdventureDigest(t *testing.T) {
const token = "t"
s, posted := newAdvServer(t, token)
now := time.Now()
// Two bulletins + one priority (which posts live and must be excluded).
postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
postFact(t, s, token, AdvFact{GUID: "rival:b:2", EventType: "rival_result", Tier: "bulletin",
Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()})
postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
if len(*posted) != 1 {
t.Fatalf("setup: priority posts = %d, want 1", len(*posted))
}
s.postDailyDigest(now.UTC())
if len(*posted) != 2 {
t.Fatalf("digest not posted: total posts = %d, want 2", len(*posted))
}
dg := (*posted)[1]
if !strings.Contains(dg.Headline, "2 dispatches") {
t.Errorf("digest headline = %q", dg.Headline)
}
if !strings.HasPrefix(dg.GUID, "adv-digest:") {
t.Errorf("digest guid = %q", dg.GUID)
}
// Re-running finds nothing new (bulletins marked digested).
s.postDailyDigest(now.UTC())
if len(*posted) != 2 {
t.Errorf("digest re-posted: total = %d, want 2", len(*posted))
}
}
// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
// returns a themed SVG, ingested cards carry its local path, and the permalink
// page is noindex with an og:image.
// TestRenderZoneTaxonomy: a realm-first (zone_first) reads as first-ever; a
// repeat (zone_clear) reads as a personal clear, not a mis-labeled "first".
func TestRenderZoneTaxonomy(t *testing.T) {
first := AdvFact{EventType: "zone_first", Tier: "priority", Subject: "Brannigan",
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
hl, _, ok := renderAdventure(first)
if !ok || !strings.Contains(hl, "very first time") {
t.Errorf("zone_first headline = %q (ok=%v)", hl, ok)
}
repeat := AdvFact{EventType: "zone_clear", Tier: "bulletin", Subject: "Brannigan",
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
hl2, _, ok := renderAdventure(repeat)
if !ok || !strings.Contains(hl2, "Brannigan clears") || strings.Contains(hl2, "first") {
t.Errorf("zone_clear headline = %q (ok=%v)", hl2, ok)
}
// The permalink label distinguishes the two, so a repeat's page isn't stamped
// "First clear".
if lbl, _ := advEventMeta("zone_clear"); lbl == "First clear" {
t.Errorf("zone_clear meta label = %q, want distinct from first clear", lbl)
}
}
func TestAdventureArtAndMeta(t *testing.T) {
const token = "t"
s, _ := newAdvServer(t, token)
// Emblem endpoint returns SVG with the event's emoji.
areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
areq.SetPathValue("type", "death.svg")
arw := httptest.NewRecorder()
s.handleAdventureArt(arw, areq)
if arw.Code != 200 {
t.Fatalf("art status = %d", arw.Code)
}
if ct := arw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/svg+xml") {
t.Errorf("art content-type = %q", ct)
}
if b := arw.Body.String(); !strings.Contains(b, "🪦") || !strings.Contains(b, "<svg") {
t.Errorf("art body missing emblem: %s", b)
}
// Ingest sets the card image to the local emblem path.
f := AdvFact{GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 4, OccurredAt: 1000}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d", rw.Code)
}
got, _ := storage.GetStoryByGUID("death:abc:1000")
if got == nil || got.ImageURL != "/adventure/art/death.svg" {
t.Errorf("story image = %q", got.ImageURL)
}
// Permalink page is noindex with an og:image.
preq := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
preq.SetPathValue("guid", "death:abc:1000")
prw := httptest.NewRecorder()
s.handleAdventureStory(prw, preq)
body := prw.Body.String()
if !strings.Contains(body, `name="robots" content="noindex"`) {
t.Error("permalink not noindex")
}
if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death.svg"`) {
t.Errorf("permalink missing og:image; body=%s", body)
}
}
// TestAdventureIngestBearer rejects missing/wrong tokens.
func TestAdventureIngestBearer(t *testing.T) {
s, _ := newAdvServer(t, "right")
f := AdvFact{GUID: "arrival:x:1", EventType: "arrival", Tier: "bulletin", OccurredAt: 1}
if rw := postFact(t, s, "", f); rw.Code != 401 {
t.Errorf("no token: status = %d, want 401", rw.Code)
}
if rw := postFact(t, s, "wrong", f); rw.Code != 401 {
t.Errorf("wrong token: status = %d, want 401", rw.Code)
}
}
// TestAdventureFactGuard rejects a subject not present in the actors allow-list,
// so a name that slipped the source can't reach a public page.
func TestAdventureFactGuard(t *testing.T) {
const token = "t"
s, posted := newAdvServer(t, token)
f := AdvFact{
GUID: "death:evil:1", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Kif", // Kif not in actors
Zone: "the Underforge", Level: 3, OccurredAt: 1,
}
if rw := postFact(t, s, token, f); rw.Code != 400 {
t.Errorf("fact-guard: status = %d, want 400", rw.Code)
}
if storage.IsGUIDSeen("death:evil:1") {
t.Error("guarded fact was stored")
}
if len(*posted) != 0 {
t.Error("guarded fact was posted")
}
}
// TestAdventureDisabled 404s when the seam is off.
func TestAdventureDisabled(t *testing.T) {
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "off.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}
f := AdvFact{GUID: "x", EventType: "arrival", OccurredAt: 1}
if rw := postFact(t, s, "anything", f); rw.Code != 404 {
t.Errorf("disabled: status = %d, want 404", rw.Code)
}
}
// TestRenderMischief: gogobee's four mischief event types must all render. An
// unknown event_type is a 400 at ingest, which gogobee retries and then parks
// forever — so "Pete deploys first" only helps if Pete actually knows the types.
//
// It also pins the anonymity contract, which is the feature's whole social
// engine: an unsigned contract must not name the buyer, and a survival must.
func TestRenderMischief(t *testing.T) {
anon := AdvFact{EventType: "mischief_contract", Tier: "priority",
Subject: "Josie", Boss: "Elite", Stakes: "€350", Level: 14}
hl, lede, ok := renderAdventure(anon)
if !ok {
t.Fatal("mischief_contract did not render — ingest would 400")
}
if strings.Contains(hl+lede, "Brannigan") {
t.Error("anonymous contract leaked a buyer name")
}
if !strings.Contains(hl, "€350") {
t.Errorf("contract headline lost the stakes: %q", hl)
}
signed := anon
signed.Opponent = "Brannigan"
hl, _, ok = renderAdventure(signed)
if !ok || !strings.Contains(hl, "Brannigan") {
t.Errorf("signed contract should name the buyer: %q (ok=%v)", hl, ok)
}
// The unseal: a survival names the buyer whether or not they signed.
survived := AdvFact{EventType: "mischief_survived", Tier: "priority",
Subject: "Josie", Opponent: "Brannigan", Boss: "Bone Colossus", Stakes: "€228"}
hl, _, ok = renderAdventure(survived)
if !ok || !strings.Contains(hl, "Brannigan") {
t.Errorf("survival must unseal the buyer: %q (ok=%v)", hl, ok)
}
// A downed target with an anonymous buyer stays anonymous — being maimed
// doesn't buy you the name.
downed := AdvFact{EventType: "mischief_downed", Tier: "priority",
Subject: "Josie", Boss: "Bone Colossus", Level: 14}
hl, lede, ok = renderAdventure(downed)
if !ok {
t.Fatal("mischief_downed did not render")
}
if strings.Contains(hl+lede, "Brannigan") {
t.Error("anonymous buyer named on a downed contract")
}
if _, _, ok := renderAdventure(AdvFact{EventType: "mischief_fizzled", Subject: "Josie", Stakes: "€315"}); !ok {
t.Error("mischief_fizzled did not render")
}
for _, et := range []string{"mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled"} {
if lbl, _ := advEventMeta(et); lbl == "Dispatch" {
t.Errorf("%s has no permalink label", et)
}
}
}

View File

@@ -11,6 +11,7 @@ import (
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
@@ -32,6 +33,7 @@ type Authenticator struct {
oauth *oauth2.Config
verifier *oidc.IDTokenVerifier
secret []byte
domain string // cookie Domain; empty means host-only
}
// SessionUser is the identity carried in the signed session cookie.
@@ -39,9 +41,25 @@ type SessionUser struct {
Sub string `json:"sub"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
// Username is the Authentik preferred_username, which MAS imported as the
// Matrix localpart — so it is also who this person is in the game economy.
// Sessions signed before games existed don't carry it; MatrixUser returns
// "" for those and the caller sends them back through sign-in.
Username string `json:"username,omitempty"`
Exp int64 `json:"exp"`
}
// MatrixUser maps the session to a Matrix ID on the given server name, e.g.
// "reala" on "parodia.dev" -> "@reala:parodia.dev". Empty if either half is
// missing, which callers must treat as "not identified in the economy".
func (u *SessionUser) MatrixUser(serverName string) string {
name := strings.ToLower(strings.TrimSpace(u.Username))
if name == "" || serverName == "" {
return ""
}
return "@" + name + ":" + serverName
}
// Display is the friendly name shown in the header (name, else email, else sub).
func (u *SessionUser) Display() string {
if u.Name != "" {
@@ -88,6 +106,7 @@ func newAuthenticator(ctx context.Context, cfg config.AuthConfig) (*Authenticato
},
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
secret: []byte(cfg.SessionSecret),
domain: strings.TrimSpace(cfg.CookieDomain),
}, nil
}
@@ -150,11 +169,23 @@ func (a *Authenticator) userFromRequest(r *http.Request) *SessionUser {
return &u
}
// cookieDomain is the Domain attribute for a given cookie. Only the session
// cookie is widened: the OAuth round-trip cookie stays host-only, because it
// pairs with a redirect back to the host that started the login and has no
// business being readable from anywhere else.
func (a *Authenticator) cookieDomain(name string) string {
if name == sessionCookie {
return a.domain
}
return ""
}
func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
Domain: a.cookieDomain(name),
Expires: time.Now().Add(ttl),
MaxAge: int(ttl.Seconds()),
HttpOnly: true,
@@ -166,10 +197,42 @@ func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl
func (a *Authenticator) clearCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: "/", MaxAge: -1,
Domain: a.cookieDomain(name),
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})
}
// oauthFor returns the OAuth config to use for this request. The configured
// redirect_url names one host (news), but a login that starts on games has to
// come back to games — otherwise the browser is dumped on the news site with
// its "next" path pointing at a page that lives elsewhere. So when the request
// arrives on a host inside the shared cookie domain, keep the redirect on that
// host, reusing the configured URL's scheme and path. Every host used this way
// must be registered as a redirect URI in Authentik.
func (a *Authenticator) oauthFor(r *http.Request) *oauth2.Config {
if a.domain == "" || !hostInDomain(r.Host, a.domain) {
return a.oauth
}
u, err := url.Parse(a.oauth.RedirectURL)
if err != nil || u.Host == r.Host {
return a.oauth
}
cfg := *a.oauth
cfg.RedirectURL = u.Scheme + "://" + r.Host + u.Path
return &cfg
}
// hostInDomain reports whether host sits inside a cookie domain like
// ".parodia.dev" (which also covers the bare "parodia.dev").
func hostInDomain(host, domain string) bool {
if i := strings.IndexByte(host, ':'); i >= 0 {
host = host[:i] // strip port
}
host = strings.ToLower(host)
bare := strings.TrimPrefix(strings.ToLower(domain), ".")
return host == bare || strings.HasSuffix(host, "."+bare)
}
// ---- handlers -------------------------------------------------------------
// handleLogin starts the OIDC authorization-code flow.
@@ -182,7 +245,7 @@ func (a *Authenticator) handleLogin(w http.ResponseWriter, r *http.Request) {
}
payload, _ := json.Marshal(st)
a.setCookie(w, oauthCookie, a.sign(payload), oauthTTL)
http.Redirect(w, r, a.oauth.AuthCodeURL(st.State, oidc.Nonce(st.Nonce)), http.StatusFound)
http.Redirect(w, r, a.oauthFor(r).AuthCodeURL(st.State, oidc.Nonce(st.Nonce)), http.StatusFound)
}
// handleCallback completes the flow: validates state, exchanges the code,
@@ -212,7 +275,7 @@ func (a *Authenticator) handleCallback(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
tok, err := a.oauth.Exchange(ctx, r.URL.Query().Get("code"))
tok, err := a.oauthFor(r).Exchange(ctx, r.URL.Query().Get("code"))
if err != nil {
slog.Error("auth: code exchange failed", "err", err)
http.Error(w, "sign-in failed", http.StatusBadGateway)
@@ -247,7 +310,13 @@ func (a *Authenticator) handleCallback(w http.ResponseWriter, r *http.Request) {
if name == "" {
name = claims.PreferredUsername
}
u := SessionUser{Sub: claims.Sub, Name: name, Email: claims.Email, Exp: time.Now().Add(sessionTTL).Unix()}
u := SessionUser{
Sub: claims.Sub,
Name: name,
Email: claims.Email,
Username: claims.PreferredUsername,
Exp: time.Now().Add(sessionTTL).Unix(),
}
sess, _ := json.Marshal(u)
a.setCookie(w, sessionCookie, a.sign(sess), sessionTTL)
slog.Info("auth: user signed in", "sub", claims.Sub, "name", name)

View File

@@ -1,6 +1,12 @@
package web
import "testing"
import (
"net/http/httptest"
"testing"
"time"
"golang.org/x/oauth2"
)
func TestSignVerifyRoundTrip(t *testing.T) {
a := &Authenticator{secret: []byte("test-secret-key-at-least-16")}
@@ -53,3 +59,95 @@ func TestSafeNext(t *testing.T) {
}
}
}
func TestMatrixUser(t *testing.T) {
cases := []struct {
name string
u SessionUser
want string
}{
{"lowercased", SessionUser{Username: "Reala"}, "@reala:parodia.dev"},
{"trimmed", SessionUser{Username: " reala "}, "@reala:parodia.dev"},
{"old session with no username", SessionUser{Sub: "abc", Name: "Reala"}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.u.MatrixUser("parodia.dev"); got != tc.want {
t.Fatalf("MatrixUser = %q, want %q", got, tc.want)
}
})
}
if got := (&SessionUser{Username: "reala"}).MatrixUser(""); got != "" {
t.Fatalf("no server name should yield no identity, got %q", got)
}
}
func TestHostInDomain(t *testing.T) {
cases := []struct {
host, domain string
want bool
}{
{"games.parodia.dev", ".parodia.dev", true},
{"news.parodia.dev:8080", ".parodia.dev", true},
{"parodia.dev", ".parodia.dev", true},
{"GAMES.PARODIA.DEV", ".parodia.dev", true},
{"evil.com", ".parodia.dev", false},
// The suffix check must not match a domain that merely ends in the
// same letters: notparodia.dev is a different site entirely.
{"notparodia.dev", ".parodia.dev", false},
{"games.parodia.dev.evil.com", ".parodia.dev", false},
}
for _, tc := range cases {
if got := hostInDomain(tc.host, tc.domain); got != tc.want {
t.Errorf("hostInDomain(%q, %q) = %v, want %v", tc.host, tc.domain, got, tc.want)
}
}
}
func TestOAuthForKeepsLoginOnTheHostItStartedOn(t *testing.T) {
base := &oauth2.Config{RedirectURL: "https://news.parodia.dev/auth/callback"}
a := &Authenticator{oauth: base, domain: ".parodia.dev"}
req := httptest.NewRequest("GET", "/auth/login", nil)
req.Host = "games.parodia.dev"
if got := a.oauthFor(req).RedirectURL; got != "https://games.parodia.dev/auth/callback" {
t.Fatalf("games login should come back to games, got %q", got)
}
req.Host = "news.parodia.dev"
if got := a.oauthFor(req).RedirectURL; got != base.RedirectURL {
t.Fatalf("news login should use the configured URL, got %q", got)
}
// A Host we don't own must never be echoed back into a redirect URI.
req.Host = "evil.com"
if got := a.oauthFor(req).RedirectURL; got != base.RedirectURL {
t.Fatalf("foreign host must fall back to the configured URL, got %q", got)
}
// With no cookie domain configured there is nothing to share, so the
// configured redirect stands whatever the Host header says.
off := &Authenticator{oauth: base}
req.Host = "games.parodia.dev"
if got := off.oauthFor(req).RedirectURL; got != base.RedirectURL {
t.Fatalf("host-only mode must not rewrite the redirect, got %q", got)
}
}
func TestSessionCookieIsSharedButOAuthCookieIsNot(t *testing.T) {
a := &Authenticator{secret: []byte("test-secret-key-at-least-16"), domain: ".parodia.dev"}
rec := httptest.NewRecorder()
a.setCookie(rec, sessionCookie, "v", time.Minute)
a.setCookie(rec, oauthCookie, "v", time.Minute)
got := map[string]string{}
for _, c := range rec.Result().Cookies() {
got[c.Name] = c.Domain
}
if got[sessionCookie] != "parodia.dev" {
t.Fatalf("session cookie domain = %q, want it shared across parodia.dev", got[sessionCookie])
}
if got[oauthCookie] != "" {
t.Fatalf("oauth cookie must stay host-only, got domain %q", got[oauthCookie])
}
}

View File

@@ -0,0 +1,111 @@
package web
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"net"
"net/http"
"os"
"testing"
"time"
"pete/internal/games/trivia"
"pete/internal/opentdb"
"pete/internal/storage"
)
// TestDevCasino is not a test. It is the casino, running, on a port, with one
// signed-in player who has chips — so the table can be driven in a real browser,
// which is the only honest way to review an animation.
//
// Skipped unless you ask for it:
//
// PETE_DEV_CASINO=:7788 go test ./internal/web -run TestDevCasino -timeout 0
//
// It prints the session cookie to plant. The routes are wired here rather than
// taken from New(), because New() decides whether the casino exists at the
// moment it builds the mux, and the test rig only signs the player in afterwards.
func TestDevCasino(t *testing.T) {
addr := os.Getenv("PETE_DEV_CASINO")
if addr == "" {
t.Skip("set PETE_DEV_CASINO=:port to run the casino for a browser")
}
s := newCasino(t)
fund(t, 5000)
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)
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
s.casinoRoutes(mux)
ln, err := net.Listen("tcp", addr)
if err != nil {
t.Fatal(err)
}
// Written to a file, not printed: `go test` buffers stdout, and the browser
// driver needs the cookie while the server is still running.
if out := os.Getenv("PETE_DEV_COOKIE_FILE"); out != "" {
if err := os.WriteFile(out, []byte(cookie), 0o600); err != nil {
t.Fatal(err)
}
}
fmt.Printf("\nCASINO http://localhost%s/games\nCOOKIE %s=%s\n\n", addr, sessionCookie, cookie)
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
t.Cleanup(func() { _ = srv.Close() })
_ = srv.Serve(ln)
}
// 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)
}
}

View File

@@ -53,7 +53,7 @@ func TestFeeds(t *testing.T) {
}
}
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}

151
internal/web/games.go Normal file
View File

@@ -0,0 +1,151 @@
package web
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"pete/internal/storage"
)
// The euro/chip wire.
//
// gogobee owns the euros and has no inbound API, so it is the only initiator:
// it polls this endpoint for border crossings, moves the money on its side, and
// pushes the verdict back through the durable queue it already uses for
// adventure facts. Pete never calls gogobee. That direction of travel is a
// standing rule of the seam, not an implementation detail — see roster.go.
//
// All three endpoints are bearer-authed with the same ingest token as the
// adventure seam, and all three are idempotent, because the thing on the other
// end of them is a retrying queue and the thing they move is money.
//
// The storage layer under this (internal/storage/games.go) is where the actual
// invariant lives: chips exist only once gogobee confirms it took the euros.
// These handlers are transport, and deliberately nothing more.
// escrowGUID is the body of the two POSTs that name a row.
type escrowGUID struct {
GUID string `json:"guid"`
}
// escrowVerdict is gogobee's answer: did the money move, and what is the
// player's euro balance now.
type escrowVerdict struct {
GUID string `json:"guid"`
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
BalanceAfter float64 `json:"balance_after"`
}
// handleEscrowPending is gogobee's poll: every crossing waiting to be moved.
//
// It includes rows gogobee claimed but never reported on — see
// storage.PendingEscrow. Re-offering those is the whole reason the guid is an
// idempotency key: if gogobee already moved the euros, the retry is a no-op
// that reports the same answer, and if it died before moving them, the money
// gets moved now instead of being stranded.
func (s *Server) handleEscrowPending(w http.ResponseWriter, r *http.Request) {
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
rows, err := storage.PendingEscrow(escrowPollLimit)
if err != nil {
slog.Error("games: pending escrow", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if rows == nil {
rows = []storage.Escrow{} // an empty poll is [], never null
}
writeJSON(w, rows)
}
// escrowPollLimit caps one poll. gogobee polls every few seconds, so a backlog
// drains in a handful of ticks rather than arriving as one enormous body.
const escrowPollLimit = 50
// handleEscrowClaim marks a row as taken. It is not a lock — a row already
// claimed can be claimed again, which is how a stale re-offer works — but a row
// that has already reached a verdict cannot be, which is what stops a settled
// cash-out being paid twice.
//
// The claimed row goes back in the response, so gogobee moves the money against
// the amount and the user *Pete* holds rather than the ones it read a poll ago.
func (s *Server) handleEscrowClaim(w http.ResponseWriter, r *http.Request) {
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req escrowGUID
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&req); err != nil || req.GUID == "" {
http.Error(w, "guid is required", http.StatusBadRequest)
return
}
e, err := storage.ClaimEscrow(req.GUID)
if errors.Is(err, storage.ErrNoSuchEscrow) {
http.Error(w, "no such escrow", http.StatusNotFound)
return
}
if err != nil {
slog.Error("games: claim escrow", "guid", req.GUID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, e)
}
// handleEscrowSettled applies gogobee's verdict. This is the only way chips are
// ever created, and it runs exactly once per guid no matter how many times the
// push is redelivered.
//
// An unknown guid is a 400 rather than a shrug: gogobee has, by this point,
// already moved real euros for a row Pete has no record of. Under the contract
// the adventure seam established, a 400 makes gogobee's sender park the row
// instead of retrying it forever — which is right, because no amount of retrying
// invents the missing row. It leaves the payload sitting in gogobee's queue,
// where a human can find it.
func (s *Server) handleEscrowSettled(w http.ResponseWriter, r *http.Request) {
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var v escrowVerdict
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if v.GUID == "" {
http.Error(w, "guid is required", http.StatusBadRequest)
return
}
e, err := storage.SettleEscrow(v.GUID, v.OK, v.Reason, v.BalanceAfter)
if errors.Is(err, storage.ErrNoSuchEscrow) {
slog.Error("games: verdict for an escrow row we have never heard of — "+
"gogobee has moved euros against it and Pete cannot honour them",
"guid", v.GUID, "ok", v.OK)
http.Error(w, "no such escrow", http.StatusBadRequest)
return
}
if err != nil {
slog.Error("games: settle escrow", "guid", v.GUID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("games: escrow settled", "guid", e.GUID, "user", e.MatrixUser,
"kind", e.Kind, "amount", e.Amount, "state", e.State, "reason", e.Reason)
writeJSON(w, e)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("games: write response", "err", err)
}
}

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,349 @@
package web
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"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"`
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"`
}
func viewHoldem(g holdem.State) holdemView {
v := holdemView{
Tier: g.Tier,
Button: g.Button,
HandNo: g.HandNo,
Street: g.Street.String(),
Pot: g.Total(),
ToAct: g.ToAct,
Phase: string(g.Phase),
Stack: g.Seats[holdem.You].Stack,
BoughtIn: g.BoughtIn,
Rake: g.Paid, // the part you actually paid, not the part the table lifted
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. A bot'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 == holdem.You,
Stack: p.Stack,
Bet: p.Bet,
State: seatStates[p.State],
Pos: g.Position(i),
Won: p.Won,
}
mine := i == holdem.You
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 == holdem.You {
v.Owed = g.Owed(holdem.You)
v.CanCheck = v.Owed == 0
v.CanRaise = g.CanRaise(holdem.You)
v.MinRaise = g.MinRaiseTo(holdem.You)
v.MaxRaise = g.MaxRaiseTo(holdem.You)
}
if top := g.Tier.MaxBuy - g.Seats[holdem.You].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"`
}
func viewHoldemEvents(evs []holdem.Event) []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))
}
// A card may ride an event only if it is the board, your own hand, or a hand
// being shown down. Anything else is a bot's business.
if len(v.Cards) > 0 && e.Seat >= 0 && e.Seat != holdem.You && e.Kind != "show" {
v.Cards = nil
}
out = append(out, v)
}
return out
}
// handleHoldemSit buys chips onto a table. The chips are staked first, in the
// same statement that checks they exist, so two sit-downs fired at once cannot
// buy in with the same chip.
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"`
}
if err := decodeJSON(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
tier, err := holdem.TierBySlug(req.Tier)
if err != nil {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
return
}
if req.BuyIn < tier.MinBuy || req.BuyIn > tier.MaxBuy {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{
"error": "that isn't a legal buy-in for this table",
})
return
}
if req.Bots < 1 || req.Bots > holdem.MaxBots {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick some opponents"})
return
}
if err := storage.Stake(user, req.BuyIn); err != nil {
if 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
}
slog.Error("games: holdem buy-in", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
seed1, seed2 := newSeeds()
g, evs, err := holdem.New(tier, req.Bots, req.BuyIn, blackjack.DefaultRules().RakePct, seed1, seed2)
if err != nil {
_ = storage.Award(user, req.BuyIn) // nobody sat down, so nothing was bought
slog.Error("games: holdem sit", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.persistHoldem(w, user, g, evs, seed1, seed2, true)
}
// handleHoldemMove plays one move: an action in a hand, or the three that are
// about the session — deal the next hand, put more chips on the table, get up.
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
}
live, err := storage.LoadLiveHand(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 load", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if live.Game != gameHoldem {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
return
}
var g holdem.State
if err := json.Unmarshal(live.State, &g); err != nil {
slog.Error("games: unreadable holdem table", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// A top-up is real money crossing the border, so the chips come off the stack
// before the engine is asked — and go straight back if it says no. 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) {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you don't have those chips"})
return
}
slog.Error("games: holdem top-up", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
topped = move.Amount
}
next, evs, err := holdem.ApplyMove(g, move)
if err != nil {
if topped > 0 {
_ = storage.Award(user, topped) // the top-up didn't happen
}
msg := "that move isn't legal here"
switch {
case errors.Is(err, holdem.ErrHandLive):
msg = "finish the hand first"
case errors.Is(err, holdem.ErrNotYourTurn):
msg = "it isn't your turn"
case errors.Is(err, holdem.ErrCantCheck):
msg = "there's a bet to you"
case errors.Is(err, holdem.ErrTooSmall):
msg = "that's under the minimum raise"
case errors.Is(err, holdem.ErrTooBig):
msg = "you don't have that many chips"
case errors.Is(err, holdem.ErrBadBuyIn):
msg = "that would put you over the table maximum"
}
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
return
}
s.persistHoldem(w, user, next, evs, live.Seed1, live.Seed2, false)
}
// persistHoldem writes the table back and answers the browser.
//
// The session settles exactly once — when the player gets up, or when they have
// nothing left to get up with. Until then Done is false and `commit` moves no
// chips at all, which is what makes a hundred hands of poker a single trip across
// the border rather than a hundred.
func (s *Server) persistHoldem(w http.ResponseWriter, user string, g holdem.State, evs []holdem.Event, seed1, seed2 uint64, fresh bool) {
blob, err := json.Marshal(g)
if err != nil {
slog.Error("games: marshal holdem", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
done := g.Phase == holdem.PhaseDone
outcome := "left"
switch {
case done && g.Payout == 0:
outcome = "busted"
case done && g.Payout > g.BoughtIn:
outcome = "up"
case done && g.Payout < g.BoughtIn:
outcome = "down"
}
v, ok := s.commit(w, user, finished{
Game: gameHoldem, Blob: blob,
// Paid, not Rake: the audit log is the house's income, and the house only
// makes money off the player. What it lifts off a bot's pot is not income.
Bet: g.BoughtIn, Payout: g.Payout, Rake: g.Paid,
Outcome: outcome, Done: done,
Seed1: seed1, Seed2: seed2, Fresh: fresh,
})
if !ok {
return
}
// A closed session is gone from storage, so the table view has none to show —
// but the browser still needs the last board to land the verdict on.
if done {
hv := viewHoldem(g)
v.Holdem = &hv
}
v.HoldemEvents = viewHoldemEvents(evs)
writeJSON(w, v)
}

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

208
internal/web/games_pages.go Normal file
View File

@@ -0,0 +1,208 @@
package web
import (
"net/http"
"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"
)
// The casino's two pages. Both require a signed-in visitor — there is money in
// here, and a player has to be somebody gogobee's ledger can name.
//
// Neither page renders any game state server-side. The felt is drawn by the
// browser from /api/games/table, because a hand is a thing that *happens*: cards
// are dealt one at a time and the table plays that back. A server-rendered hand
// would arrive fully formed, which is the one thing a card table must never do.
// gameTeaser is a table that isn't open yet. They're on the lobby because an
// empty casino with one game reads as broken, and this reads as early.
type gameTeaser struct {
Name string
Emoji string
Blurb string
}
// 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}
// The casino is not called Pete — the news app is Pete's, and this is somewhere
// you go. It has two names, and which one is over the door depends on the hour:
// the lights come on at six and the place turns into Casino Night Zone until
// dawn. Same tables, different room.
type room struct {
Slug string // drives the palette: html[data-room="…"]
Name string // what's on the sign
}
var (
roomDay = room{Slug: "casinopolis", Name: "Casinopolis"}
roomNight = room{Slug: "casino-night", Name: "Casino Night Zone"}
)
// roomAt picks the room for an hour of the day. Daylight is 6am to 6pm; the rest
// belongs to the neon. The browser re-runs this same rule against its own clock
// (games_layout.html), so a player in another timezone sees their own evening —
// this server-side pick only exists so the first paint isn't the wrong room.
func roomAt(hour int) room {
if hour >= 6 && hour < 18 {
return roomDay
}
return roomNight
}
// gamesPage is deliberately *not* pageData. The casino shares Pete's design
// language and nothing else — no channels, no weather, no sources, no push.
// Giving it its own page struct is what stops the news app's furniture drifting
// back in one convenient field at a time.
type gamesPage struct {
Room room
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
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/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/start", s.handleUnoStart)
mux.HandleFunc("POST /api/games/uno/move", s.handleUnoMove)
mux.HandleFunc("POST /api/games/holdem/sit", s.handleHoldemSit)
mux.HandleFunc("POST /api/games/holdem/move", s.handleHoldemMove)
}
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
// Anyone who is signed in but carries a session from before the casino existed
// has no username in it, so they get sent through sign-in too — which mints one.
func (s *Server) requirePlayer(w http.ResponseWriter, r *http.Request) bool {
if !s.gamesReady() {
http.NotFound(w, r)
return false
}
u := s.auth.userFromRequest(r)
if u != nil && u.MatrixUser(s.cfg.Games.MatrixServer) != "" {
return true
}
http.Redirect(w, r, "/auth/login?next="+r.URL.Path, http.StatusFound)
return false
}
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
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,
Stakes: holdem.Tiers,
MaxBots: holdem.MaxBots,
}
}
func (s *Server) handleLobby(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "games", s.gamesPage(r))
}
func (s *Server) handleBlackjack(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
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))
}

655
internal/web/games_play.go Normal file
View File

@@ -0,0 +1,655 @@
package web
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/rand/v2"
"net/http"
"time"
"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"
)
// The table, as a browser sees it.
//
// Everything here is server-authoritative. The browser sends intents — deal me
// in, hit, stand — and gets back a *view*: the cards it is entitled to see and
// nothing else. The shoe stays in game_live_hands, on this side of the wire.
// That is not belt-and-braces, it is the whole reason the engines are Go: a game
// with money on it cannot trust a client-reported result, and a client that
// holds the deck can read the next card.
//
// The stake leaves the player's stack *before* the hand is dealt, and comes back
// only through the engine's own payout. So a hand that crashes halfway costs the
// player their bet and nothing more, and a hand that Pete restarts through is
// still sitting there when they come back.
// gamesReady reports whether the casino can actually run: it needs sign-in (a
// player has to be someone) and a Matrix server name (that someone has to exist
// in gogobee's ledger).
func (s *Server) gamesReady() bool {
return s.cfg.Games.Enabled && s.auth != nil && s.cfg.Games.MatrixServer != ""
}
// player resolves the signed-in visitor to their Matrix id, or writes the
// failure. An empty id means a session from before games existed, which carries
// no username: sending them back through sign-in mints one.
func (s *Server) player(w http.ResponseWriter, r *http.Request) (string, bool) {
if !s.gamesReady() {
http.NotFound(w, r)
return "", false
}
u := s.auth.userFromRequest(r)
if u == nil {
writeJSONStatus(w, http.StatusUnauthorized, map[string]string{"error": "sign in to play"})
return "", false
}
mx := u.MatrixUser(s.cfg.Games.MatrixServer)
if mx == "" {
writeJSONStatus(w, http.StatusForbidden, map[string]string{
"error": "your session predates the casino — sign out and back in",
})
return "", false
}
return mx, true
}
// ---- what the browser is allowed to see -----------------------------------
// cardView is one card, pre-rendered. The browser draws faces, not logic: it
// gets the glyph and the colour rather than a rank it has to map itself.
type cardView struct {
Label string `json:"label"` // "A♠"
Rank string `json:"rank"` // "A"
Suit string `json:"suit"` // "♠"
Red bool `json:"red"`
}
func viewCard(c cards.Card) cardView {
label := c.String()
// String() renders rank then a three-byte suit glyph, except for a card that
// isn't one ("??"), which has no glyph to split off.
if len(label) <= len("♠") {
return cardView{Label: label, Rank: label}
}
cut := len(label) - len("♠")
return cardView{Label: label, Rank: label[:cut], Suit: label[cut:], Red: c.Red()}
}
// handView is a blackjack hand as its player may see it. While they are still
// acting, the dealer's hole card is *absent* — not sent and flagged hidden, but
// genuinely not in the payload. A field the browser is told to ignore is a field
// somebody reads in devtools.
type handView struct {
Phase string `json:"phase"`
Bet int64 `json:"bet"`
Player []cardView `json:"player"`
Dealer []cardView `json:"dealer"`
Hole bool `json:"hole"` // true: the dealer has a face-down card
Total int `json:"total"`
Soft bool `json:"soft"`
DTotal int `json:"dealer_total"` // what the *shown* dealer cards add to
Outcome string `json:"outcome,omitempty"`
Payout int64 `json:"payout,omitempty"`
Rake int64 `json:"rake,omitempty"`
Net int64 `json:"net"`
Double bool `json:"can_double"`
}
func viewHand(st blackjack.State) handView {
v := handView{
Phase: string(st.Phase),
Bet: st.Bet,
Outcome: string(st.Outcome),
Payout: st.Payout,
Rake: st.Rake,
Net: st.Net(),
Double: st.CanDouble(),
}
for _, c := range st.Player {
v.Player = append(v.Player, viewCard(c))
}
v.Total, v.Soft = blackjack.HandValue(st.Player)
dealer := st.Dealer
if st.Phase == blackjack.PhasePlayer && len(dealer) > 1 {
dealer = dealer[:1] // the hole card is the dealer's business until it isn't
v.Hole = true
}
for _, c := range dealer {
v.Dealer = append(v.Dealer, viewCard(c))
}
v.DTotal, _ = blackjack.HandValue(dealer)
return v
}
// eventView is the dealing script. The engine emits one event per card off the
// shoe, in order, and the table animates them one at a time — which is why the
// events go over the wire at all rather than the browser diffing two states.
type eventView struct {
Kind string `json:"kind"`
Card *cardView `json:"card,omitempty"`
Text string `json:"text,omitempty"`
}
// viewEvents renders the engine's events for the browser, dropping the cards it
// is not yet allowed to see: the dealer's second card is dealt face-down, and
// only the "reveal" event turns it over.
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}
if e.Card != nil {
c := viewCard(*e.Card)
v.Card = &c
}
if e.Kind == "dealer_card" {
dealerCards++
// The hole card, while the hand is still the player's to play: send
// the event so the table deals a face-down card, but not the face.
if dealerCards == 2 && phase == blackjack.PhasePlayer {
v.Card = nil
v.Kind = "dealer_hole"
}
}
out = append(out, v)
}
return out
}
// 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"`
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 game in progress.
func (s *Server) table(user string) (tableView, error) {
st, err := storage.Chips(user)
if err != nil {
return tableView{}, err
}
v := tableView{
Chips: st.Chips,
Pending: st.Pending,
Euros: st.EuroBalance,
Cap: storage.MaxChipsOnTable,
Rake: blackjack.DefaultRules().RakePct,
}
live, err := storage.LoadLiveHand(user)
if errors.Is(err, storage.ErrNoLiveHand) {
return v, nil
}
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 {
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:
var g uno.State
if err := json.Unmarshal(live.State, &g); err != nil {
return s.dropUnreadable(user, v, err)
}
uv := viewUno(g)
v.Uno = &uv
case gameHoldem:
var g holdem.State
if err := json.Unmarshal(live.State, &g); err != nil {
return s.dropUnreadable(user, v, err)
}
hv := viewHoldem(g)
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
}
// ---- handlers -------------------------------------------------------------
// handleTable is the page's poll: chips, euros, and whatever hand is on the felt.
func (s *Server) handleTable(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
v, err := s.table(user)
if err != nil {
slog.Error("games: table", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, v)
}
// amountBody is {amount: n} — chips, which are euros.
type amountBody struct {
Amount int64 `json:"amount"`
}
func decodeJSON(r *http.Request, v any) error {
return json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(v)
}
// handleBuyIn opens a buy-in. It creates no chips: it writes an escrow row, and
// gogobee decides — up to three seconds later — whether the player could afford
// it. The browser watches `pending` fall to zero to know how it went.
func (s *Server) handleBuyIn(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req amountBody
if err := decodeJSON(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
e, err := storage.RequestBuyIn(user, req.Amount)
switch {
case errors.Is(err, storage.ErrBadAmount):
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "buy in for something more than nothing"})
return
case errors.Is(err, storage.ErrOverTableCap):
writeJSONStatus(w, http.StatusBadRequest, map[string]string{
"error": "that would put more than the table cap in front of you",
})
return
case err != nil:
slog.Error("games: buy-in", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
storage.Touch(user)
slog.Info("games: buy-in requested", "user", user, "amount", e.Amount, "guid", e.GUID)
v, err := s.table(user)
if err != nil {
slog.Error("games: table after buy-in", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, v)
}
// handleCashOut sends chips back across the border. The chips are destroyed
// here and now — see storage.RequestCashOut for why that has to happen before
// gogobee has said anything — so the response already shows an empty stack. An
// amount of zero or less means "all of it", which is what the button does.
func (s *Server) handleCashOut(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req amountBody
if err := decodeJSON(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// You cannot walk away from a hand you have chips riding on. The stake is
// already off the stack, so this isn't about the money — it's that a hand
// left half-played would settle into a session that no longer exists.
if _, err := storage.LoadLiveHand(user); err == nil {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand first"})
return
} else if !errors.Is(err, storage.ErrNoLiveHand) {
slog.Error("games: cash-out live-hand check", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
amount := req.Amount
if amount <= 0 {
st, err := storage.Chips(user)
if err != nil {
slog.Error("games: cash-out", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
amount = st.Chips
}
e, err := storage.RequestCashOut(user, amount)
switch {
case errors.Is(err, storage.ErrBadAmount), errors.Is(err, storage.ErrInsufficientChips):
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you don't have those chips"})
return
case err != nil:
slog.Error("games: cash-out", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("games: cash-out requested", "user", user, "amount", e.Amount, "guid", e.GUID)
v, err := s.table(user)
if err != nil {
slog.Error("games: table after cash-out", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, v)
}
// ---- blackjack ------------------------------------------------------------
// handleDeal takes the bet and deals. The order matters: chips are staked first,
// in the same statement that checks they exist, so two deals fired at once
// cannot bet the same chip. Only then is a hand dealt.
func (s *Server) handleDeal(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req struct {
Bet int64 `json:"bet"`
}
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
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: stake", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
seed1, seed2 := newSeeds()
rng := rand.New(rand.NewPCG(seed1, seed2))
st, evs, err := blackjack.New(req.Bet, blackjack.DefaultRules(), rng)
if err != nil {
// The hand never happened, so the stake never should have left. Give it back.
_ = storage.Award(user, req.Bet)
slog.Error("games: deal", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.persist(w, user, st, evs, seed1, seed2, true)
}
// handleMove plays one move of the hand in progress.
func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req struct {
Move string `json:"move"`
}
if err := decodeJSON(r, &req); 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 hand in progress"})
return
}
if err != nil {
slog.Error("games: load hand", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var st blackjack.State
if err := json.Unmarshal(live.State, &st); err != nil {
slog.Error("games: unreadable live hand", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
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 {
if !st.CanDouble() {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only double on the first two cards"})
return
}
if err := storage.Stake(user, st.Bet); err != nil {
if errors.Is(err, storage.ErrInsufficientChips) {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to double"})
return
}
slog.Error("games: stake double", "user", user, "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
}
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that move isn't legal here"})
return
}
s.persist(w, user, next, evs, live.Seed1, live.Seed2, false)
}
// 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 both games go through it so that neither has to re-derive an ordering
// that took a while to get right.
//
// It returns the table as it now stands. ok is false when it has already
// written an error response and the caller must simply return.
func (s *Server) commit(w http.ResponseWriter, user string, f finished) (tableView, bool) {
// Seat the game before doing anything else with it — even one that is already
// over, because a blackjack natural settles the instant it's dealt. The insert
// is what enforces one game at a time, and it has to happen for *every* new
// one: a natural dealt on top of a game already in progress would otherwise
// settle, clear the felt, and take the other game's stake down with it.
live := storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2}
save := storage.SaveLiveHand
if f.Fresh {
save = storage.StartLiveHand
}
if err := save(user, live); err != nil {
if errors.Is(err, storage.ErrHandInProgress) {
// Somebody was already sitting here. This game was never seated, so the
// chips it staked go back: the player is in one game, not two.
_ = storage.Award(user, f.Bet)
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"})
return tableView{}, false
}
slog.Error("games: save game", "user", user, "game", f.Game, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return tableView{}, false
}
if f.Done {
// Pay first, then clear. If Pete dies between the two, the player has been
// paid and the worst case is a settled game still showing on the felt —
// which reads as done and can be cleared. The other order loses them a win.
if err := storage.Award(user, f.Payout); err != nil {
slog.Error("games: award", "user", user, "payout", f.Payout, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return tableView{}, false
}
if err := storage.RecordHand(storage.Hand{
MatrixUser: user, Game: f.Game,
Bet: f.Bet, Payout: f.Payout, Rake: f.Rake,
Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2,
}); err != nil {
slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's game
}
if err := storage.ClearLiveHand(user); err != nil {
slog.Error("games: clear game", "user", user, "err", err)
}
}
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)
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 {
slog.Error("games: marshal hand", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
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), 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 done {
hv := viewHand(st)
v.Hand = &hv
}
v.Events = viewEvents(evs, st.Phase)
writeJSON(w, v)
}
// newSeeds mints the shoe's seed. It goes in the audit log, so a hand somebody
// disputes can be dealt again exactly as it fell.
func newSeeds() (uint64, uint64) {
return rand.Uint64(), uint64(time.Now().UnixNano())
}
func writeJSONStatus(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("games: write response", "err", err)
}
}

View File

@@ -0,0 +1,275 @@
package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"pete/internal/config"
"pete/internal/storage"
)
const testPlayer = "@reala:parodia.dev"
// newCasino is a server with the tables open and one signed-in player. The
// Authenticator is built by hand rather than through OIDC discovery: sign-in is
// a network call, and none of what's under test is about the handshake.
func newCasino(t *testing.T) *Server {
t.Helper()
s, _ := newAdvServer(t, "tok")
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
s.cfg.Games = config.GamesConfig{Enabled: true, MatrixServer: "parodia.dev"}
return s
}
// as returns a request carrying the signed session of the given username. An
// empty username is the pre-casino session: signed in, but nobody the economy
// can name.
func as(t *testing.T, s *Server, username, method, path 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)
payload, _ := json.Marshal(SessionUser{
Sub: "sub-1", Username: username, Exp: time.Now().Add(time.Hour).Unix(),
})
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
return r
}
// call runs one request against a handler and decodes the table view.
func call(t *testing.T, s *Server, h http.HandlerFunc, r *http.Request) (tableView, int) {
t.Helper()
w := httptest.NewRecorder()
h(w, r)
var v tableView
if w.Code == 200 {
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
t.Fatalf("decode table: %v (body %q)", err, w.Body.String())
}
}
return v, w.Code
}
// fund puts chips in front of the player the way the border really does it.
func fund(t *testing.T, chips int64) {
t.Helper()
e, err := storage.RequestBuyIn(testPlayer, 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 chipsNow(t *testing.T) int64 {
t.Helper()
st, err := storage.Chips(testPlayer)
if err != nil {
t.Fatal(err)
}
return st.Chips
}
// TestDealTakesTheStakeAndHidesTheHoleCard is the one thing the table cannot get
// wrong: the bet leaves the stack, and the dealer's second card does not leave
// the server.
func TestDealTakesTheStakeAndHidesTheHoleCard(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
v, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal", map[string]int64{"bet": 100}))
if code != 200 {
t.Fatalf("deal = %d, want 200", code)
}
if v.Chips != 900 {
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
}
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))
}
// A natural settles on the spot and legitimately shows both dealer cards.
if v.Hand.Phase == "done" {
return
}
if !v.Hand.Hole || len(v.Hand.Dealer) != 1 {
t.Fatalf("dealer shows %d cards (hole=%v), want 1 with the hole card held back",
len(v.Hand.Dealer), v.Hand.Hole)
}
// And it isn't smuggled out in the dealing script either.
for _, e := range v.Events {
if e.Kind == "dealer_hole" && e.Card != nil {
t.Fatal("the hole card's face went to the browser in the events")
}
}
}
// TestHandSettlesIntoTheChipStack plays a hand to the end and checks the chips
// moved by exactly what the engine said they did — and that the hand left the
// felt and landed in the audit log.
func TestHandSettlesIntoTheChipStack(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
for v.Hand != nil && v.Hand.Phase == "player" {
v, _ = call(t, s, s.handleMove, as(t, s, "reala", "POST", "/move", map[string]string{"move": "stand"}))
}
if v.Hand == nil || v.Hand.Phase != "done" {
t.Fatalf("hand didn't finish: %+v", v.Hand)
}
// 1000, minus the stake, plus whatever came back.
want := int64(1000) - 100 + v.Hand.Payout
if got := chipsNow(t); got != want {
t.Fatalf("chips = %d, want %d (payout %d, outcome %q)", got, want, v.Hand.Payout, v.Hand.Outcome)
}
if _, err := storage.LoadLiveHand(testPlayer); err == nil {
t.Fatal("a settled hand is still sitting on the felt")
}
if v.Hand.Outcome == "" {
t.Fatal("a finished hand with no outcome")
}
// The rake only ever comes out of winnings.
if v.Hand.Outcome == "push" && v.Hand.Payout != 100 {
t.Fatalf("a push paid %d, want the 100 back untouched", v.Hand.Payout)
}
if v.Hand.Net < 0 && v.Hand.Rake != 0 {
t.Fatalf("a losing hand was raked %d", v.Hand.Rake)
}
}
// TestOneHandAtATime is the double-click: a second Deal must not overwrite the
// hand the first one is paying for, and must not keep the chips it staked.
func TestOneHandAtATime(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
first, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
if first.Hand != nil && first.Hand.Phase == "done" {
t.Skip("dealt a natural; there is no live hand to protect")
}
before := chipsNow(t)
w := httptest.NewRecorder()
s.handleDeal(w, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
if w.Code != http.StatusConflict {
t.Fatalf("second deal = %d, want 409", w.Code)
}
if got := chipsNow(t); got != before {
t.Fatalf("the refused deal cost the player %d chips", before-got)
}
// The original hand is untouched.
live, err := storage.LoadLiveHand(testPlayer)
if err != nil {
t.Fatalf("the live hand went missing: %v", err)
}
var st struct {
Player []struct{} `json:"player"`
}
if err := json.Unmarshal(live.State, &st); err != nil {
t.Fatal(err)
}
if len(st.Player) != len(first.Hand.Player) {
t.Fatal("the refused deal replaced the hand in progress")
}
}
// TestCannotCashOutMidHand — the stake is on the table, so the session it would
// settle into cannot be closed underneath it.
func TestCannotCashOutMidHand(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
if v.Hand != nil && v.Hand.Phase == "done" {
t.Skip("dealt a natural; nothing is in progress")
}
w := httptest.NewRecorder()
s.handleCashOut(w, as(t, s, "reala", "POST", "/cashout", map[string]int64{"amount": 0}))
if w.Code != http.StatusConflict {
t.Fatalf("cash-out mid-hand = %d, want 409", w.Code)
}
if got := chipsNow(t); got != 900 {
t.Fatalf("the refused cash-out moved chips: %d, want 900", got)
}
}
// TestDoubleWithoutTheChipsChangesNothing: a double the player can't cover is
// refused, and refusing it must not quietly pocket the raise.
func TestDoubleWithoutTheChipsChangesNothing(t *testing.T) {
s := newCasino(t)
fund(t, 100)
v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
if v.Hand == nil || v.Hand.Phase != "player" {
t.Skip("no live hand to double on")
}
if chipsNow(t) != 0 {
t.Fatal("test wants a player with nothing left to raise with")
}
w := httptest.NewRecorder()
s.handleMove(w, as(t, s, "reala", "POST", "/move", map[string]string{"move": "double"}))
if w.Code != http.StatusBadRequest {
t.Fatalf("broke double = %d, want 400", w.Code)
}
if got := chipsNow(t); got != 0 {
t.Fatalf("chips = %d after a refused double, want 0", got)
}
// And the hand is still there, still doubleable once they can afford it.
after, code := call(t, s, s.handleTable, as(t, s, "reala", "GET", "/table", nil))
if code != 200 || after.Hand == nil || !after.Hand.Double {
t.Fatalf("the hand should be intact and still doubleable: %d %+v", code, after.Hand)
}
}
// TestTableNeedsAPlayerTheEconomyCanName. Anonymous is a 401. A session minted
// before the casino existed carries no username, so it can't be mapped to a
// Matrix id — that's a 403, and the fix is to sign in again.
func TestTableNeedsAPlayerTheEconomyCanName(t *testing.T) {
s := newCasino(t)
w := httptest.NewRecorder()
s.handleTable(w, httptest.NewRequest("GET", "/api/games/table", nil))
if w.Code != http.StatusUnauthorized {
t.Fatalf("anonymous = %d, want 401", w.Code)
}
w = httptest.NewRecorder()
s.handleTable(w, as(t, s, "", "GET", "/api/games/table", nil))
if w.Code != http.StatusForbidden {
t.Fatalf("session with no username = %d, want 403", w.Code)
}
}
// TestCasinoIsShutWithoutAServerName. No Matrix server name means no player can
// be named to gogobee, so the tables 404 rather than dealing hands whose money
// has nowhere to go.
func TestCasinoIsShutWithoutAServerName(t *testing.T) {
s := newCasino(t)
s.cfg.Games.MatrixServer = ""
w := httptest.NewRecorder()
s.handleTable(w, as(t, s, "reala", "GET", "/api/games/table", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("table with no server name = %d, want 404", w.Code)
}
}

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

248
internal/web/games_test.go Normal file
View File

@@ -0,0 +1,248 @@
package web
import (
"bytes"
"encoding/json"
"net/http/httptest"
"testing"
"pete/internal/storage"
)
// pollEscrow runs one gogobee poll.
func pollEscrow(t *testing.T, s *Server, token string) ([]storage.Escrow, int) {
t.Helper()
req := httptest.NewRequest("GET", "/api/games/escrow/pending", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.handleEscrowPending(w, req)
if w.Code != 200 {
return nil, w.Code
}
var out []storage.Escrow
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("decode pending: %v (body %q)", err, w.Body.String())
}
return out, w.Code
}
func claimEscrow(t *testing.T, s *Server, token, guid string) (storage.Escrow, int) {
t.Helper()
body, _ := json.Marshal(escrowGUID{GUID: guid})
req := httptest.NewRequest("POST", "/api/games/escrow/claim", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.handleEscrowClaim(w, req)
var e storage.Escrow
if w.Code == 200 {
_ = json.Unmarshal(w.Body.Bytes(), &e)
}
return e, w.Code
}
func settleEscrow(t *testing.T, s *Server, token string, v escrowVerdict) (storage.Escrow, int) {
t.Helper()
body, _ := json.Marshal(v)
req := httptest.NewRequest("POST", "/api/games/escrow/settled", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.handleEscrowSettled(w, req)
var e storage.Escrow
if w.Code == 200 {
_ = json.Unmarshal(w.Body.Bytes(), &e)
}
return e, w.Code
}
// TestEscrowBuyInRoundTrip walks the happy path a player actually takes: ask for
// chips, watch gogobee pick the row up, and have the chips appear only once the
// euros are confirmed gone.
func TestEscrowBuyInRoundTrip(t *testing.T) {
s, _ := newAdvServer(t, "tok")
const user = "@reala:parodia.dev"
req, err := storage.RequestBuyIn(user, 500)
if err != nil {
t.Fatal(err)
}
// No chips yet. The euros are still gogobee's.
if st, _ := storage.Chips(user); st.Chips != 0 || st.Pending != 500 {
t.Fatalf("before settle: chips=%d pending=%d, want 0/500", st.Chips, st.Pending)
}
pending, code := pollEscrow(t, s, "tok")
if code != 200 || len(pending) != 1 || pending[0].GUID != req.GUID {
t.Fatalf("poll = %d %+v, want the one buy-in", code, pending)
}
if pending[0].MatrixUser != user || pending[0].Kind != storage.KindBuyIn || pending[0].Amount != 500 {
t.Fatalf("poll row = %+v, want the row we opened", pending[0])
}
claimed, code := claimEscrow(t, s, "tok", req.GUID)
if code != 200 || claimed.State != storage.EscrowClaimed {
t.Fatalf("claim = %d state=%q, want 200/claimed", code, claimed.State)
}
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: req.GUID, OK: true, BalanceAfter: 1500})
if code != 200 || e.State != storage.EscrowFunded {
t.Fatalf("settle = %d state=%q, want 200/funded", code, e.State)
}
st, _ := storage.Chips(user)
if st.Chips != 500 || st.Pending != 0 {
t.Fatalf("after settle: chips=%d pending=%d, want 500/0", st.Chips, st.Pending)
}
if st.EuroBalance != 1500 {
t.Fatalf("euro balance = %v, want the 1500 gogobee reported", st.EuroBalance)
}
// Settled rows are done. A later poll must not offer it again, or gogobee
// would debit the player a second time.
if pending, _ := pollEscrow(t, s, "tok"); len(pending) != 0 {
t.Fatalf("settled row still pending: %+v", pending)
}
}
// TestEscrowVerdictReplayedCreatesChipsOnce is the property the whole guid
// scheme exists for. gogobee's push queue retries: the same verdict lands twice,
// and only the first one may create chips.
func TestEscrowVerdictReplayedCreatesChipsOnce(t *testing.T) {
s, _ := newAdvServer(t, "tok")
const user = "@replay:parodia.dev"
req, err := storage.RequestBuyIn(user, 200)
if err != nil {
t.Fatal(err)
}
if _, code := claimEscrow(t, s, "tok", req.GUID); code != 200 {
t.Fatalf("claim = %d", code)
}
for i := 0; i < 3; i++ {
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: req.GUID, OK: true, BalanceAfter: 800})
if code != 200 || e.State != storage.EscrowFunded {
t.Fatalf("settle %d = %d state=%q", i, code, e.State)
}
}
if st, _ := storage.Chips(user); st.Chips != 200 {
t.Fatalf("chips = %d after three deliveries of one verdict, want 200", st.Chips)
}
}
// TestEscrowRejectedBuyInMovesNothing — the player couldn't cover it. gogobee
// took no euros, so Pete must create no chips, and the pending amount has to
// clear or it would eat into the table cap forever.
func TestEscrowRejectedBuyInMovesNothing(t *testing.T) {
s, _ := newAdvServer(t, "tok")
const user = "@broke:parodia.dev"
req, err := storage.RequestBuyIn(user, 9000)
if err != nil {
t.Fatal(err)
}
if _, code := claimEscrow(t, s, "tok", req.GUID); code != 200 {
t.Fatalf("claim = %d", code)
}
e, code := settleEscrow(t, s, "tok",
escrowVerdict{GUID: req.GUID, OK: false, Reason: "insufficient_funds", BalanceAfter: 12})
if code != 200 || e.State != storage.EscrowRejected {
t.Fatalf("settle = %d state=%q, want 200/rejected", code, e.State)
}
if e.Reason != "insufficient_funds" {
t.Fatalf("reason = %q, want it carried back for the player to read", e.Reason)
}
st, _ := storage.Chips(user)
if st.Chips != 0 || st.Pending != 0 {
t.Fatalf("after rejection: chips=%d pending=%d, want 0/0", st.Chips, st.Pending)
}
}
// TestEscrowCashOutFailureReturnsChips — gogobee couldn't pay. The chips were
// destroyed the moment the cash-out opened, so if we simply mark it done the
// player's money is gone from both sides. It has to come back.
func TestEscrowCashOutFailureReturnsChips(t *testing.T) {
s, _ := newAdvServer(t, "tok")
const user = "@unlucky:parodia.dev"
buy, err := storage.RequestBuyIn(user, 300)
if err != nil {
t.Fatal(err)
}
if _, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: buy.GUID, OK: true}); code != 200 {
t.Fatalf("fund = %d", code)
}
out, err := storage.RequestCashOut(user, 300)
if err != nil {
t.Fatal(err)
}
if st, _ := storage.Chips(user); st.Chips != 0 {
t.Fatalf("chips during cash-out = %d, want 0 — they must not be bettable in flight", st.Chips)
}
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: out.GUID, OK: false, Reason: "ledger_error"})
if code != 200 {
t.Fatalf("settle = %d", code)
}
if e.State != storage.EscrowFunded {
t.Fatalf("state = %q, want funded — a failed cash-out gives the chips back", e.State)
}
if st, _ := storage.Chips(user); st.Chips != 300 {
t.Fatalf("chips = %d after a failed cash-out, want the 300 back", st.Chips)
}
}
// TestEscrowUnknownGUIDIsA400 — a verdict for a row Pete has never heard of.
// gogobee has already moved real euros for it, and no amount of retrying will
// conjure the row, so the answer is the one that parks the row in gogobee's
// queue for a human rather than the one that retries forever.
func TestEscrowUnknownGUIDIsA400(t *testing.T) {
s, _ := newAdvServer(t, "tok")
if _, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: "ghost", OK: true}); code != 400 {
t.Fatalf("settle unknown guid = %d, want 400", code)
}
if _, code := claimEscrow(t, s, "tok", "ghost"); code != 404 {
t.Fatalf("claim unknown guid = %d, want 404", code)
}
}
// TestEscrowNeedsTheBearerToken. These endpoints move money and are reachable on
// the same mux as the public news site.
func TestEscrowNeedsTheBearerToken(t *testing.T) {
s, _ := newAdvServer(t, "tok")
req, err := storage.RequestBuyIn("@x:parodia.dev", 10)
if err != nil {
t.Fatal(err)
}
if _, code := pollEscrow(t, s, "wrong"); code != 401 {
t.Fatalf("poll with a bad token = %d, want 401", code)
}
if _, code := claimEscrow(t, s, "", req.GUID); code != 401 {
t.Fatalf("claim with no token = %d, want 401", code)
}
if _, code := settleEscrow(t, s, "wrong", escrowVerdict{GUID: req.GUID, OK: true}); code != 401 {
t.Fatalf("settle with a bad token = %d, want 401", code)
}
if st, _ := storage.Chips("@x:parodia.dev"); st.Chips != 0 {
t.Fatal("an unauthenticated settle created chips")
}
}
// TestEscrowEmptyPollIsAnArrayNotNull. gogobee decodes into []Escrow; a bare
// `null` body decodes fine in Go but is a trap for anything else that ever reads
// this, and an empty poll is the overwhelmingly common case.
func TestEscrowEmptyPollIsAnArrayNotNull(t *testing.T) {
s, _ := newAdvServer(t, "tok")
req := httptest.NewRequest("GET", "/api/games/escrow/pending", nil)
req.Header.Set("Authorization", "Bearer tok")
w := httptest.NewRecorder()
s.handleEscrowPending(w, req)
if got := w.Body.String(); got != "[]\n" {
t.Fatalf("empty poll body = %q, want %q", got, "[]\n")
}
}

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

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

@@ -0,0 +1,284 @@
package web
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"pete/internal/games/blackjack"
"pete/internal/games/uno"
"pete/internal/storage"
)
// UNO, played for chips against bots.
//
// The seam is the same as every other table, but there is one thing here that no
// other table has: opponents. The obvious way to give a browser opponents is a
// socket, and the plan says solo UNO must not need one — so it doesn't. A move
// goes up, and what comes back is the player's move *plus every bot turn it
// handed off to*, as a script of events. One request, one round of the table.
//
// What the browser is allowed to see: its own hand, the card in play, the colour
// in play, and how many cards each bot is holding. Not the deck, not a bot's
// hand, not even the face of a card a bot drew. That last one is most of the
// deck, and it is the thing that would turn a game of counting cards into a game
// of reading the network tab.
// unoCardView is one card, ready to draw. The browser gets the colour and the
// face as words, not as the engine's integers — the same bargain the blackjack
// table makes, and for the same reason: the browser draws faces, not logic.
type unoCardView struct {
Color string `json:"color"` // "red" | "blue" | "yellow" | "green" | "wild"
Value string `json:"value"` // "0"…"9" | "skip" | "reverse" | "+2" | "wild" | "+4"
Wild bool `json:"wild"` // it's a wild, whatever colour it was played as
}
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, and a number of cards. A bot's
// cards are a *count*. There is no field here for what they are.
type unoSeatView struct {
Name string `json:"name"`
Cards int `json:"cards"`
You bool `json:"you"`
Uno bool `json:"uno"` // down to one card
}
// unoView is a game as its player may see it.
type unoView struct {
Tier uno.Tier `json:"tier"`
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
Turn int `json:"turn"`
Dir int `json:"dir"`
Bet int64 `json:"bet"`
Pays int64 `json:"pays"` // what going out right now would actually pay
Phase string `json:"phase"`
Outcome string `json:"outcome,omitempty"`
Winner int `json:"winner"`
Payout int64 `json:"payout,omitempty"`
Rake int64 `json:"rake,omitempty"`
Net int64 `json:"net"`
}
func viewUno(g uno.State) unoView {
v := unoView{
Tier: g.Tier,
Top: viewUnoCard(g.Top()),
Color: g.Color.String(),
Deck: g.Left(),
Turn: g.Turn,
Dir: g.Dir,
Bet: g.Bet,
Pays: g.Pays(),
Phase: string(g.Phase),
Outcome: string(g.Outcome),
Winner: -1,
Payout: g.Payout,
Rake: g.Rake,
Net: g.Net(),
}
for i, n := range g.Counts() {
seat := unoSeatView{Cards: n, You: i == uno.You, Uno: n == 1}
if i == uno.You {
seat.Name = "You"
} else if i-1 < len(g.Bots) {
seat.Name = g.Bots[i-1]
}
v.Seats = append(v.Seats, seat)
if n == 0 {
v.Winner = i
}
}
for _, c := range g.Hands[uno.You] {
v.Hand = append(v.Hand, viewUnoCard(c))
}
v.Playable = g.Playable()
if v.Playable == nil {
v.Playable = []int{}
}
return v
}
// unoEventView is one beat of the script the table plays back: a card going
// down, a seat eating a +4, the turn coming round. The engine's own events carry
// engine types, so they are re-rendered here rather than shipped raw — and this
// is also the wall where a bot's drawn card is dropped on the floor.
type unoEventView struct {
Kind string `json:"kind"`
Seat int `json:"seat"`
Card *unoCardView `json:"card,omitempty"`
Color string `json:"color,omitempty"`
N int `json:"n,omitempty"`
Left int `json:"left"` // never omitempty: a seat that goes out leaves zero, and zero is the number the table has to draw
Text string `json:"text,omitempty"`
}
func viewUnoEvents(evs []uno.Event) []unoEventView {
out := make([]unoEventView, 0, len(evs))
for _, e := range evs {
v := unoEventView{Kind: e.Kind, Seat: e.Seat, N: e.N, Left: e.Left, Text: e.Text}
if e.Color != uno.Wild {
v.Color = e.Color.String()
}
if e.Card != nil {
// The engine only ever attaches a card to an event the seat is entitled
// to see it in — a card played face up, or one *you* drew. This check is
// the belt to that pair of braces: a bot's draw never carries a face,
// whatever the engine thinks it's doing.
if e.Kind == uno.EvDraw || e.Kind == uno.EvForced {
if e.Seat == uno.You {
c := viewUnoCard(*e.Card)
v.Card = &c
}
} else {
c := viewUnoCard(*e.Card)
v.Card = &c
}
}
out = append(out, v)
}
return out
}
// handleUnoStart takes the bet and deals. Same order as every other table: the
// chips are staked first, in the same statement that checks they exist, so two
// deals fired at once cannot bet the same chip.
func (s *Server) handleUnoStart(w http.ResponseWriter, r *http.Request) {
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 := uno.TierBySlug(req.Tier)
if err != nil {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
return
}
if err := storage.Stake(user, req.Bet); err != nil {
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
return
}
slog.Error("games: uno stake", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
seed1, seed2 := newSeeds()
g, evs, err := uno.New(req.Bet, tier, blackjack.DefaultRules().RakePct, seed1, seed2)
if err != nil {
// The game never happened, so the stake never should have left.
_ = storage.Award(user, req.Bet)
slog.Error("games: uno deal", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.persistUno(w, user, g, evs, seed1, seed2, true)
}
// handleUnoMove plays one turn: a card, a draw, or passing on the card you drew.
// The bots' turns come back with it.
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
}
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: uno load", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if live.Game != gameUno {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
return
}
var g uno.State
if err := json.Unmarshal(live.State, &g); err != nil {
slog.Error("games: unreadable uno game", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
next, evs, err := uno.ApplyMove(g, move)
if err != nil {
// The refusals a player can actually cause, said in words rather than as
// "that move isn't legal here" — which, in a game with this many rules, is
// the table refusing to explain itself.
msg := "that move isn't legal here"
switch {
case errors.Is(err, uno.ErrCantPlay):
msg = "that card doesn't go on this one"
case errors.Is(err, uno.ErrNeedColor):
msg = "pick a colour for the wild"
case errors.Is(err, uno.ErrMustPlayNow):
msg = "play the card you drew, or pass"
case errors.Is(err, uno.ErrCantPass):
msg = "draw first, then you can pass"
}
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
return
}
s.persistUno(w, user, next, evs, live.Seed1, live.Seed2, false)
}
// persistUno writes the game back and answers the browser.
func (s *Server) persistUno(w http.ResponseWriter, user string, g uno.State, evs []uno.Event, seed1, seed2 uint64, fresh bool) {
blob, err := json.Marshal(g)
if err != nil {
slog.Error("games: marshal uno", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
done := g.Phase == uno.PhaseDone
v, ok := s.commit(w, user, finished{
Game: gameUno, Blob: blob,
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
Outcome: string(g.Outcome), Done: done,
Seed1: seed1, Seed2: seed2, Fresh: fresh,
})
if !ok {
return
}
// A finished game is gone from storage, so the table has none to show — but the
// browser still needs the final board to land the verdict on.
if done {
uv := viewUno(g)
v.Uno = &uv
}
v.UnoEvents = viewUnoEvents(evs)
writeJSON(w, v)
}

View File

@@ -0,0 +1,147 @@
package web
import (
"testing"
"pete/internal/games/uno"
"pete/internal/storage"
)
// The one thing this table cannot get wrong: the stake leaves the stack, and no
// card a player is not entitled to see leaves the server. At UNO that is not one
// hole card — it is the deck and every bot's hand, which is most of the game.
func TestUnoStartTakesTheStakeAndKeepsTheCards(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
v, code := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
map[string]any{"bet": 100, "tier": "full"}))
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.Game != gameUno || v.Uno == nil {
t.Fatalf("start returned no uno game: game=%q", v.Game)
}
g := v.Uno
if len(g.Hand) != uno.HandSize {
t.Errorf("you were dealt %d cards, want %d", len(g.Hand), uno.HandSize)
}
if len(g.Seats) != 4 {
t.Fatalf("a full house is four seats, got %d", len(g.Seats))
}
// A bot is a name and a number. There is no field here that could carry a
// card, which is the point — this is a compile-time guarantee, and the test
// exists to make deleting it loud.
for i, seat := range g.Seats {
if i == 0 {
if !seat.You || seat.Name != "You" {
t.Errorf("seat 0 should be you: %+v", seat)
}
continue
}
if seat.You || seat.Name == "" {
t.Errorf("seat %d should be a named bot: %+v", i, seat)
}
if seat.Cards != uno.HandSize {
t.Errorf("seat %d holds %d cards, want %d", i, seat.Cards, uno.HandSize)
}
}
if g.Top.Value == "" {
t.Error("no card in play")
}
if g.Turn != 0 {
t.Errorf("you play first, turn is %d", g.Turn)
}
}
// 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, 1000)
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
map[string]any{"bet": 100, "tier": "table"}))
if v.Uno == nil {
t.Fatal("no game")
}
// Draw, which is legal from any hand: the turn passes to the bots and comes
// back, unless the card drawn happens to be playable.
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "draw"}))
if code != 200 {
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)
}
}
}
// You cannot play a wild without naming a colour — and the zero value of the
// colour field is not red, so a move that simply omits it is refused rather than
// quietly played as one.
func TestUnoWildWithNoColourIsRefused(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
// Deal until a wild lands in the opening hand: it's four cards in 108, so it
// doesn't take long, and this is the only way to get one without rigging the
// deck through a door the server doesn't have.
var wild = -1
for try := 0; try < 40 && wild < 0; try++ {
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
map[string]any{"bet": 10, "tier": "duel"}))
if v.Uno == nil {
t.Fatal("no game")
}
for i, c := range v.Uno.Hand {
if c.Wild {
wild = i
break
}
}
if wild < 0 {
// Abandon this deal and take another. The live row is keyed on the
// player, so it has to go before the next start can be seated.
if err := storage.ClearLiveHand(testPlayer); err != nil {
t.Fatal(err)
}
}
}
if wild < 0 {
t.Skip("40 deals and not one wild — vanishingly unlikely, but not a failure")
}
_, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "play", "index": wild}))
if code != 400 {
t.Fatalf("a wild with no colour = %d, want 400", code)
}
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "play", "index": wild, "color": int(uno.Green)}))
if code != 200 {
t.Fatalf("a wild played as green = %d, want 200", code)
}
if v.Uno != nil && v.Uno.Phase != "done" && v.Uno.Color != "green" && v.Uno.Color != "" {
// The bots have played since, so the colour may have moved on — what must
// not happen is the wild going down as anything we didn't ask for.
t.Logf("colour in play after the bots: %s", v.Uno.Color)
}
}

View File

@@ -105,6 +105,8 @@ type pageData struct {
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
PushPublicKey string // VAPID public key handed to the client to subscribe
TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
NoIndex bool // emit <meta name="robots" content="noindex"> — used by the adventure section
OGImage string // absolute og:image URL for link unfurls (adventure emblem); "" = none
}
type channelPage struct {
@@ -117,6 +119,13 @@ type channelPage struct {
PrevURL string
NextURL string
Total int
// Roster is the live adventurer board, populated on the adventure section
// only and only on page 1 — it is present tense, and page 2 of an archive is
// not where anyone looks for what's happening right now.
Roster []RosterView
RosterStale bool
ShowRoster bool
}
type indexPage struct {
@@ -158,7 +167,7 @@ func (s *Server) base(r *http.Request) pageData {
}
d := pageData{
SiteTitle: s.cfg.SiteTitle,
Channels: channels,
Channels: s.channels,
Weather: currentWeather(time.Now()),
AllSources: jsForScript(srcJSON),
AuthEnabled: s.auth != nil,
@@ -215,8 +224,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
}
dayStart := time.Now().Add(-24 * time.Hour).Unix()
stats := make([]channelStat, 0, len(channels))
for _, ch := range channels {
stats := make([]channelStat, 0, len(s.channels))
for _, ch := range s.channels {
total, _ := storage.CountClassifiedByChannel(ch.Slug)
lastTs := storage.GetLastPostTime(ch.Slug)
stat := channelStat{
@@ -327,6 +336,9 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
base := s.base(r)
base.Active = ch.Slug
// The adventure section names player characters; keep it out of search
// indexes to bound the cached-forever exposure (see plan gap #5).
base.NoIndex = ch.Slug == "adventure"
data := channelPage{
pageData: base,
Channel: ch,
@@ -338,6 +350,10 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
NextURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page+1),
Total: total,
}
if ch.Slug == "adventure" && page == 1 {
data.Roster, data.RosterStale, _ = s.roster()
data.ShowRoster = true
}
s.render(w, "channel", data)
}

View File

@@ -42,7 +42,7 @@ func TestReaderCardDataAndArticleAPI(t *testing.T) {
t.Fatal(err)
}
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}

183
internal/web/roster.go Normal file
View File

@@ -0,0 +1,183 @@
package web
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"pete/internal/storage"
)
// The live adventurer board.
//
// Everything else Pete publishes about the realm is an *accomplishment* — a
// death, a clear, a milestone. Those are clippings: they read as archive the
// instant they land, however fast we deliver them, and no refresh interval fixes
// that. The board is the opposite kind of thing. It is state that is currently
// true, so it is worth looking at *now*, and it goes stale on its own if we stop
// hearing from gogobee.
//
// Direction of travel is gogobee → Pete, like every other adventure payload:
// Pete has no route back into the game box's network and we are not opening one.
// gogobee pushes the whole board; we replace ours with it.
const (
// rosterStaleAfter — how old a snapshot can get before the board stops
// claiming to be live. gogobee pushes every couple of minutes, so this is
// several missed pushes, not one unlucky one.
//
// This matters more than it looks: if gogobee dies mid-expedition, the last
// snapshot says "Josie is in holymachina" and would say so forever. A board
// that lies confidently is worse than one that admits it lost the wire —
// especially once players can act on it (see the target-list note below).
rosterStaleAfter = 12 * time.Minute
// rosterMaxEntries — hard cap on a snapshot. A bounded realm; this only
// exists so a malformed or hostile push can't spool unbounded rows.
rosterMaxEntries = 500
)
// rosterPush is the payload gogobee POSTs to /api/ingest/roster.
type rosterPush struct {
SnapshotAt int64 `json:"snapshot_at"`
Adventurers []storage.RosterEntry `json:"adventurers"`
}
// RosterView is one row as the page renders it.
type RosterView struct {
Name string
Level int
ClassRace string
OnRun bool
Zone string
Region string
Where string // "holymachina, day 3" | "in town"
Idle string // "quiet for 2 days" — only when idle
}
// handleRosterIngest replaces the board with gogobee's latest snapshot.
func (s *Server) handleRosterIngest(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var push rosterPush
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&push); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if len(push.Adventurers) > rosterMaxEntries {
http.Error(w, "roster too large", http.StatusBadRequest)
return
}
// A snapshot with no timestamp can't be aged, so it could never go stale —
// it would sit on the page claiming to be live forever. Treat it as now.
if push.SnapshotAt <= 0 {
push.SnapshotAt = time.Now().Unix()
}
// Never trust the channel with a name. Same rule as factGuard: gogobee
// pre-sanitizes to character names, and Pete checks anyway, because this is
// the last thing standing between the payload and a public page.
for i, e := range push.Adventurers {
if e.Token == "" || e.Name == "" {
http.Error(w, "each adventurer needs token and name", http.StatusBadRequest)
return
}
if e.Status != "expedition" && e.Status != "idle" {
http.Error(w, fmt.Sprintf("adventurer %d: unknown status %q", i, e.Status), http.StatusBadRequest)
return
}
}
if err := storage.ReplaceRoster(push.Adventurers, push.SnapshotAt); err != nil {
slog.Error("roster ingest: replace failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers))
w.WriteHeader(http.StatusOK)
}
// handleRosterAPI serves the board as JSON for the page's own re-poll, so an
// open tab goes live without a reload. Public — same exposure as the rendered
// page, no more.
func (s *Server) handleRosterAPI(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
views, stale, _ := s.roster()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"stale": stale,
"adventurers": views,
})
}
// roster loads the board and decides whether it is still live. A stale board is
// still returned — the page shows it dimmed and says so, rather than blanking,
// because "here is who was out when we lost contact" beats an empty page.
func (s *Server) roster() (views []RosterView, stale bool, snapshotAt int64) {
entries, snapshotAt, err := storage.LoadRoster()
if err != nil {
slog.Error("roster: load failed", "err", err)
return nil, true, 0
}
stale = snapshotAt == 0 || time.Since(time.Unix(snapshotAt, 0)) > rosterStaleAfter
for _, e := range entries {
views = append(views, toRosterView(e))
}
return views, stale, snapshotAt
}
func toRosterView(e storage.RosterEntry) RosterView {
v := RosterView{
Name: e.Name,
Level: e.Level,
ClassRace: e.ClassRace,
OnRun: e.Status == "expedition",
Zone: e.Zone,
Region: e.Region,
}
if v.OnRun {
where := e.Zone
if e.Region != "" {
where = fmt.Sprintf("%s, %s", e.Zone, e.Region)
}
if e.Day > 0 {
where = fmt.Sprintf("%s — day %d", where, e.Day)
}
v.Where = where
return v
}
v.Where = "in town"
v.Idle = humanIdle(e.IdleHours)
return v
}
// humanIdle renders the idle clock the way a person would say it. Deliberately
// coarse: this is colour on a board, not the boredom ticker's actual threshold.
func humanIdle(hours int) string {
switch {
case hours <= 0:
return ""
case hours < 2:
return "just got back"
case hours < 24:
return fmt.Sprintf("quiet for %dh", hours)
case hours < 48:
return "quiet for a day"
default:
return fmt.Sprintf("quiet for %d days", hours/24)
}
}

193
internal/web/roster_test.go Normal file
View File

@@ -0,0 +1,193 @@
package web
import (
"bytes"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
"pete/internal/storage"
)
func postRoster(t *testing.T, s *Server, token string, push rosterPush) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(push)
req := httptest.NewRequest("POST", "/api/ingest/roster", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.handleRosterIngest(w, req)
return w
}
func entry(token, name, status, zone string) storage.RosterEntry {
return storage.RosterEntry{Token: token, Name: name, Level: 5, ClassRace: "elf ranger", Status: status, Zone: zone}
}
// TestRosterReplacesNeverMerges is the whole contract of the board. gogobee
// sends the *complete* set of adventurers it is willing to show; anyone missing
// from a later snapshot has left the board — they deleted their character, or
// (the case that actually matters) they just ran `!news optout` and must
// disappear from a public page. An upsert would leave them standing there
// forever, which is precisely the exposure the opt-out exists to prevent.
func TestRosterReplacesNeverMerges(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
entry("t1", "Josie", "expedition", "holymachina"),
entry("t2", "Quack", "idle", ""),
}}); w.Code != 200 {
t.Fatalf("first push = %d, want 200", w.Code)
}
// Quack opts out; gogobee stops including her.
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now + 60, Adventurers: []storage.RosterEntry{
entry("t1", "Josie", "expedition", "holymachina"),
}}); w.Code != 200 {
t.Fatalf("second push = %d, want 200", w.Code)
}
views, stale, _ := s.roster()
if stale {
t.Error("fresh snapshot read as stale")
}
if len(views) != 1 {
t.Fatalf("board has %d rows, want 1 — a dropped adventurer survived the swap", len(views))
}
if views[0].Name != "Josie" {
t.Errorf("board kept %q, want Josie", views[0].Name)
}
}
// TestRosterGoesStale: if gogobee dies mid-expedition, the last snapshot says
// "Josie is in holymachina" and would say so forever. The board has to admit it
// lost the wire rather than keep asserting a stale fact as live.
func TestRosterGoesStale(t *testing.T) {
s, _ := newAdvServer(t, "tok")
old := time.Now().Add(-2 * rosterStaleAfter).Unix()
postRoster(t, s, "tok", rosterPush{SnapshotAt: old, Adventurers: []storage.RosterEntry{
entry("t1", "Josie", "expedition", "holymachina"),
}})
views, stale, _ := s.roster()
if !stale {
t.Error("snapshot older than rosterStaleAfter still reported live")
}
// Stale, but still shown: "who was out when we lost contact" beats a blank page.
if len(views) != 1 {
t.Errorf("stale board dropped its rows (%d), want them kept and dimmed", len(views))
}
}
// TestRosterEmptySnapshotIsNotStale: a realm where nobody is playing is a
// legitimate state and must not be confused with gogobee having gone away. This
// is why the snapshot time lives in its own row instead of MAX() over entries.
func TestRosterEmptySnapshotIsNotStale(t *testing.T) {
s, _ := newAdvServer(t, "tok")
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: time.Now().Unix()}); w.Code != 200 {
t.Fatalf("empty push = %d, want 200", w.Code)
}
views, stale, _ := s.roster()
if len(views) != 0 {
t.Errorf("empty snapshot produced %d rows", len(views))
}
if stale {
t.Error("a quiet realm read as a dead wire — the two must not collapse")
}
}
func TestRosterIngestRejects(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
if w := postRoster(t, s, "wrong", rosterPush{SnapshotAt: now}); w.Code != 401 {
t.Errorf("bad bearer = %d, want 401", w.Code)
}
// An unknown status would render as neither "out there" nor "in town".
bad := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
{Token: "t1", Name: "Josie", Status: "vibing"},
}}
if w := postRoster(t, s, "tok", bad); w.Code != 400 {
t.Errorf("unknown status = %d, want 400", w.Code)
}
// A nameless row would render an empty slot on a public page.
nameless := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
{Token: "t1", Status: "idle"},
}}
if w := postRoster(t, s, "tok", nameless); w.Code != 400 {
t.Errorf("nameless adventurer = %d, want 400", w.Code)
}
}
// TestDepartureRenders guards the cross-repo contract: gogobee emits this
// event_type for a bored adventurer, and an event_type Pete doesn't know is a
// 400 that retries and parks the bulletin forever.
func TestDepartureRenders(t *testing.T) {
headline, lede, ok := renderAdventure(AdvFact{
EventType: "departure",
Subject: "Camcast",
Zone: "crypt_valdris",
Level: 1,
Actors: []string{"Camcast"},
})
if !ok {
t.Fatal("departure event_type unknown to Pete — gogobee's bulletin would 400 and park")
}
if headline == "" || lede == "" {
t.Error("departure rendered empty")
}
}
func TestRosterViewWhere(t *testing.T) {
out := toRosterView(storage.RosterEntry{
Name: "Josie", Status: "expedition", Zone: "holymachina", Region: "the deep", Day: 3,
})
if !out.OnRun || out.Where != "holymachina, the deep — day 3" {
t.Errorf("expedition row = %+v", out)
}
in := toRosterView(storage.RosterEntry{Name: "Quack", Status: "idle", IdleHours: 50})
if in.OnRun || in.Where != "in town" || in.Idle != "quiet for 2 days" {
t.Errorf("idle row = %+v", in)
}
}
// TestAdventurePageRendersBoard drives the real page through the real template.
// The handler tests above prove the data is right; this proves it reaches a
// reader. A template slip (bad field, unclosed block) doesn't fail a build — it
// 500s the whole adventure section at request time, board and clippings alike.
func TestAdventurePageRendersBoard(t *testing.T) {
s, _ := newAdvServer(t, "tok")
postRoster(t, s, "tok", rosterPush{
SnapshotAt: time.Now().Unix(),
Adventurers: []storage.RosterEntry{
{Token: "t1", Name: "Josie", Level: 14, ClassRace: "human cleric",
Status: "expedition", Zone: "holymachina", Day: 3},
{Token: "t2", Name: "Quack", Level: 4, ClassRace: "elf ranger",
Status: "idle", IdleHours: 50},
},
})
req := httptest.NewRequest("GET", "/adventure", nil)
w := httptest.NewRecorder()
s.handleChannel(w, req, Channel{Slug: "adventure", Title: "Adventure", Theme: "adventure"})
if w.Code != 200 {
t.Fatalf("GET /adventure = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{
"Out there right now", // the board's headline
"Josie", "holymachina", "day 3", // live zone, shown while she's still in it
"Quack", "in town", "quiet for 2 days",
"/api/roster", // the client re-poll, so an open tab stays true
} {
if !strings.Contains(body, want) {
t.Errorf("adventure page missing %q", want)
}
}
}

View File

@@ -9,6 +9,7 @@ import (
"io/fs"
"log/slog"
"net/http"
"strings"
"sync"
"time"
@@ -31,6 +32,10 @@ type Channel struct {
}
// channels in display order. Add a new entry here to add a section.
//
// This is the full catalogue, used to resolve a story's channel slug to its
// display metadata. It is NOT the list of live sections: an entry can be gated
// off (adventure), so route registration and the nav read s.channels instead.
var channels = []Channel{
{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
{Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."},
@@ -42,6 +47,7 @@ var channels = []Channel{
{Slug: "kids", Title: "Kids", Theme: "kids", Emoji: "🧒", Blurb: "World news written for younger readers — short, clear, and curious."},
{Slug: "finance", Title: "Finance", Theme: "finance", Emoji: "💰", Blurb: "Markets, money, and the business of the world. Read-only — these stories don't post to Matrix."},
{Slug: "lego", Title: "LEGO", Theme: "lego", Emoji: "🧱", Blurb: "Sets, releases, and the wider brick-building world."},
{Slug: "adventure", Title: "Adventure", Theme: "adventure", Emoji: "⚔️", Blurb: "Dispatches from the realm — deaths, first-clears, arrivals, and rivalries. Reporting from the field, this is Pete."},
}
// SourceInfo is the trimmed view of a configured feed for the settings panel.
@@ -61,6 +67,9 @@ type Server struct {
tts *ttsService // nil when server-side read-aloud is disabled
adminSubs map[string]bool // OIDC subjects allowed to view /status
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
adv config.AdventureConfig // gogobee adventure-news seam
advPost PriorityPoster // posts priority adventure beats to Matrix; nil = web-only
channels []Channel // live sections: the catalogue minus anything gated off (adventure)
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
// Guarded by metricsMu; never persisted (see metrics.go).
@@ -69,23 +78,40 @@ type Server struct {
salt [16]byte
}
// New builds the server. Templates are parsed once at startup. Each page
// gets its own template set sharing layout.html + _card.html, which avoids
// `main` define collisions between pages.
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool) (*Server, error) {
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status"}
tpls := make(map[string]*template.Template, len(pages))
for _, p := range pages {
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
"templates/layout.html",
"templates/_card.html",
"templates/"+p+".html",
)
// New builds the server. Templates are parsed once at startup. Each page gets
// its own template set sharing a layout, which avoids `main` define collisions
// between pages.
//
// There are two layouts, and they are not the same site. The news pages hang off
// layout.html: Pete's face, the channel nav, search, the reader, the weather
// canvas. The casino hangs off games_layout.html, which shares the *design* —
// the palette vars, the fonts, the fat rounded cards — and none of the
// furniture. games.parodia.dev is its own place, not a news page with a felt on
// it, so it doesn't inherit a single control it has no use for.
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) {
sets := []struct {
layout string
shared []string
pages []string
}{
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
}
tpls := make(map[string]*template.Template)
for _, set := range sets {
for _, p := range set.pages {
files := []string{"templates/" + set.layout + ".html"}
for _, s := range set.shared {
files = append(files, "templates/"+s+".html")
}
files = append(files, "templates/"+p+".html")
t, err := template.New("").Funcs(funcs).ParseFS(templateFS, files...)
if err != nil {
return nil, err
}
tpls[p] = t
}
}
infos := make([]SourceInfo, 0, len(sources))
for _, sc := range sources {
if !sc.Enabled || sc.Name == "" {
@@ -99,7 +125,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
adminSubs[sub] = true
}
}
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs}
// The adventure section only exists when it's enabled: with the seam off,
// there's no nav tab, no /adventure page and no /adventure feed, rather than
// an empty section nobody can fill.
live := make([]Channel, 0, len(channels))
for _, ch := range channels {
if ch.Slug == "adventure" && !adv.Enabled {
continue
}
live = append(live, ch)
}
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live}
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
// provider is unreachable at boot we log and serve anonymously rather than
@@ -147,7 +184,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
mux.HandleFunc("GET /api/related", s.handleRelated)
mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, "") })
mux.HandleFunc("GET /feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, "") })
for _, ch := range channels {
for _, ch := range s.channels {
ch := ch
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
s.handleChannel(w, r, ch)
@@ -165,6 +202,39 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
_, _ = w.Write([]byte("ok"))
})
// gogobee adventure-news ingest. Bearer-authed (not OIDC), so it lives
// outside the sign-in block. Self-gates on adv.Enabled (404 when off).
mux.HandleFunc("POST /api/ingest/adventure", s.handleAdventureIngest)
// The live board. Ingest is bearer-authed like the fact seam; the read side
// is public because it renders on a public page anyway.
mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest)
mux.HandleFunc("GET /api/roster", s.handleRosterAPI)
// 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).
mux.HandleFunc("GET /adventure/{guid}", s.handleAdventureStory)
// Themed per-event emblem (card + og:image). A distinct path depth from
// /adventure/{guid}, so the two patterns never overlap.
mux.HandleFunc("GET /adventure/art/{type}", s.handleAdventureArt)
// The euro/chip border. gogobee polls pending, claims a row, moves the euros,
// and pushes the verdict back. Bearer-authed on the same ingest token as the
// adventure seam, so like it, these sit outside the sign-in block — the caller
// is a machine on the tailnet, not a browser.
mux.HandleFunc("GET /api/games/escrow/pending", s.handleEscrowPending)
mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim)
mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled)
// 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() {
s.casinoRoutes(mux)
}
if s.auth != nil {
mux.HandleFunc("GET /auth/login", s.auth.handleLogin)
mux.HandleFunc("GET /auth/callback", s.auth.handleCallback)
@@ -187,12 +257,56 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
s.srv = &http.Server{
Addr: cfg.ListenAddr,
Handler: mux,
Handler: s.hostRouter(mux),
ReadHeaderTimeout: 5 * time.Second,
}
return s, nil
}
// hostRouter puts the casino at the root of its own hostname without giving it
// its own mux. games.parodia.dev and news.parodia.dev are the same process on
// the same port — Caddy sends both here — so a request that arrives on the games
// host has /games prefixed onto its path, and "/" lands on the lobby.
//
// Shared plumbing (the API, sign-in, static files, health) is left alone: it is
// the same plumbing whichever door you came in by, and the login round-trip in
// particular has to keep working on both hosts.
func (s *Server) hostRouter(mux http.Handler) http.Handler {
host := strings.ToLower(strings.TrimSpace(s.cfg.Games.Host))
if host == "" || !s.gamesReady() {
return mux
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.EqualFold(hostOnly(r.Host), host) || isSharedPath(r.URL.Path) {
mux.ServeHTTP(w, r)
return
}
// Rewrite a copy: the request's URL is shared with anything that logged it.
r2 := r.Clone(r.Context())
r2.URL.Path = "/games" + strings.TrimSuffix(r.URL.Path, "/")
mux.ServeHTTP(w, r2)
})
}
// isSharedPath marks the paths that mean the same thing on every host and must
// not be shuffled under /games.
func isSharedPath(p string) bool {
for _, prefix := range []string{"/api/", "/auth/", "/static/", "/img/", "/games"} {
if strings.HasPrefix(p, prefix) {
return true
}
}
return p == "/healthz" || p == "/sw.js" || p == "/manifest.webmanifest"
}
// hostOnly strips the port from a Host header.
func hostOnly(host string) string {
if i := strings.IndexByte(host, ':'); i >= 0 {
return host[:i]
}
return host
}
// Start runs the HTTP server and blocks until ctx is canceled.
func (s *Server) Start(ctx context.Context) {
go func() {

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,414 @@
// 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
// 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.
//
// Which is also why the hole card works the way it does: the server sends a
// "dealer_hole" event with no card attached, because while you are still acting
// it hasn't told anyone what that card is. It arrives with the reveal.
//
// The money is animated on the same principle. Every number the server sends is
// also a movement: a stake is chips leaving your pile and landing on the spot, a
// payout is chips coming out of the house's rack, a loss is your stack being
// 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.
(function () {
"use strict";
var root = document.querySelector("[data-blackjack]");
if (!root) return;
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]");
var actions = root.querySelector("[data-actions]");
var betAmount = root.querySelector("[data-bet-amount]");
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.
var purseEl = document.querySelector("[data-chips]");
var spotEl = root.querySelector("[data-spot]");
var stackEl = root.querySelector("[data-stack]");
var spotTotalEl = root.querySelector("[data-spot-total]");
var houseEl = root.querySelector("[data-house]");
// The spot owns the chips on the felt and the number under them — see PeteFX.
// Nothing is bet until a chip is on it, so `bet` starts at nothing rather than
// at a default stake nobody put down.
var spot = FX.spot({ spot: spotEl, stack: stackEl, total: spotTotalEl });
var bet = 0; // what you're building between hands
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 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 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) {
if (!text) { msgEl.classList.add("hidden"); return; }
msgEl.textContent = text;
msgEl.classList.remove("hidden");
msgEl.style.color = tone === "bad" ? "#cc3d4a" : "";
}
// ---- drawing --------------------------------------------------------------
//
// 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 money on the felt -------------------------------------------------
// stake moves chips from your pile onto the spot: the bet you build before a
// deal, and the second bet a double puts down beside it.
function stake(amount, from) {
return spot.pour(from || purseEl, amount);
}
// settleChips is what the felt does about the outcome, after the cards have
// finished telling you what it is. It reads the same two numbers the ledger
// moved: `bet` (already off your pile since the deal) and `payout` (what comes
// back — stake plus winnings less rake, or nothing at all).
function settleChips(final) {
var payout = final.payout || 0;
var back = payout - final.bet; // what the house is adding, if anything
if (payout <= 0) {
// The house takes it. The stack goes to the rack and doesn't come back.
return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
}
// The house pays first, into the spot beside your stake, so you watch the
// winnings arrive on top of the bet that earned them.
return spot
.pour(houseEl, back, { gap: 60 })
.then(function () { return wait(back > 0 ? 380 : 200); })
// Paid, then swept up: the whole lot comes back to your pile, and only then
// does the number in the bar move.
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
}
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.
if (v.dealer && v.dealer.length) {
dTotalEl.textContent = v.hole ? v.dealer_total + " showing" : String(v.dealer_total);
dTotalEl.classList.remove("hidden");
} else {
dTotalEl.classList.add("hidden");
}
}
// 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.
function paint(v) {
dealerEl.innerHTML = "";
playerEl.innerHTML = "";
if (!v) { setPhase(null); spot.render(0); return; }
v.player.forEach(function (c) { playerEl.appendChild(cardEl(c)); });
v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); });
if (v.hole) dealerEl.appendChild(cardEl(null));
spot.render(v.phase === "done" ? 0 : v.bet);
totals(v);
setPhase(v);
}
var VERDICTS = {
blackjack: "Blackjack! 🎉",
win: "You win!",
dealer_bust: "Dealer busts. You win!",
lose: "Dealer takes it.",
bust: "Bust.",
push: "Push — your bet comes back.",
};
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");
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.
if (v.outcome === "blackjack") FX.burst(verdictEl, { count: 34 });
}
// 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);
if (live) {
var dbl = actions.querySelector('[data-move="double"]');
if (dbl) dbl.disabled = !v.can_double;
}
if (!v || v.phase !== "player") verdictEl.classList.toggle("hidden", !(v && v.outcome));
}
// ---- the script -----------------------------------------------------------
// play walks the server's events, one card at a time. It is deliberately the
// only thing that renders during a hand: the final state is applied at the end,
// so what you watch and what the server says can't disagree halfway through.
//
// `money` is the one exception, and it's a deliberate one. On a hand that is
// still running, the chip bar is right immediately — your stake left your pile
// when you pressed Deal, and it's sitting on the spot where you can see it. On
// a hand that *settles*, the bar is left alone until the chips have physically
// come home, because a counter that pays you before the dealer has turned over
// is a counter that has told you the ending.
function play(view, money) {
var events = view.events || [];
var final = view.hand;
var settles = !!final && final.phase === "done";
var hole = null; // the face-down card element, once one has been dealt
var chain = Promise.resolve();
var drew = false; // has the dealer drawn since the reveal?
if (!settles) money();
// Whatever the server says the stake is, that's what has to be on the spot.
// Two things get here: a double, which puts a second bet down beside the
// first, and a deal whose bet was typed rather than stacked (you kept last
// hand's number and just pressed Deal). Either way the chips go down before
// the card they're buying does.
if (final && final.bet > spot.amount) {
var extra = final.bet - spot.amount;
chain = chain.then(function () { return stake(extra); });
}
events.forEach(function (e) {
chain = chain.then(function () {
switch (e.kind) {
case "deal":
dealerEl.innerHTML = "";
playerEl.innerHTML = "";
playerEl.dataset.won = "0";
verdictEl.classList.add("hidden");
return;
case "player_card":
playerEl.appendChild(cardEl(e.card));
return wait(DEAL_MS);
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;
return beat.then(function () {
dealerEl.appendChild(cardEl(e.card));
return wait(DEAL_MS);
});
case "dealer_hole":
hole = cardEl(null);
dealerEl.appendChild(hole);
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.
if (!hole) hole = dealerEl.querySelector('.pete-card[data-face="down"]');
if (hole && final && final.dealer && final.dealer[1]) {
turnOver(hole, final.dealer[1]);
}
return wait(FLIP_MS);
case "settle":
return;
}
});
});
return chain.then(function () {
if (!final) { paint(null); money(); return; }
totals(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.
actions.classList.add("hidden");
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
// over a table that is still being paid out invites a click the table then
// has to refuse.
return settleChips(final)
.then(money)
.then(function () { return standing(final.bet); })
.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.
function standing(amount) {
var money = window.PeteGames.view();
if (!amount || !money || money.chips < amount) {
bet = 0;
showBet();
return;
}
bet = amount;
showBet();
return stake(amount);
}
// think is the dealer's beat: a pause with something to look at, so it reads as
// deliberation rather than as the page having hung.
function think() {
if (reduced || !dLabelEl) return wait(0);
dLabelEl.classList.add("pete-dealer-think");
return wait(BEAT_MS).then(function () {
dLabelEl.classList.remove("pete-dealer-think");
});
}
// ---- talking to the table -------------------------------------------------
function send(path, body) {
if (busy) return;
busy = true;
say("");
return window.PeteGames.post(path, body)
.then(function (view) {
// play() decides *when* the money lands; see the note on it.
return play(view, function () { window.PeteGames.apply(view); });
})
.catch(function (err) {
say(err.message, "bad");
// Whatever we thought was on the felt, the server is the authority on it.
return window.PeteGames.refresh().then(function (v) {
if (v && !v.hand) spot.render(0);
});
})
.then(function () { busy = false; });
}
// ---- betting --------------------------------------------------------------
//
// A bet is built by putting chips on the spot, one at a time, and it is those
// chips the deal then rides on — the number under the pile is a readout of the
// pile, not the other way round.
function showBet() {
betAmount.textContent = bet.toLocaleString();
var money = window.PeteGames.view();
if (dealBtn) dealBtn.disabled = bet <= 0 || !money || money.chips < bet;
}
// 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();
// 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 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;
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 (dealBtn) {
dealBtn.addEventListener("click", function () {
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
send("/api/games/blackjack/deal", { bet: bet });
});
}
root.querySelectorAll("[data-move]").forEach(function (btn) {
btn.addEventListener("click", function () {
send("/api/games/blackjack/move", { move: btn.dataset.move });
});
});
document.addEventListener("keydown", function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
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()];
if (!move) return;
if (move === "double" && !hand.can_double) 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.
var resumed = false;
window.PeteGames.onUpdate(function (v) {
if (!resumed) {
resumed = true;
if (v.hand) paint(v.hand);
if (v.hand && v.hand.phase === "done") verdict(v.hand);
}
showBet();
});
})();

View File

@@ -0,0 +1,172 @@
// 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.
function turnOver(wrap, face) {
if (!wrap) return;
paint(wrap.querySelector(".pete-card-front"), face);
wrap.dataset.face = "up";
wrap.dataset.key = face.label;
}
window.PeteCards = {
el: el,
paint: paint,
turnOver: turnOver,
aria: aria,
art: SUIT_ART,
};
})();

View File

@@ -0,0 +1,340 @@
// The moving parts of the room.
//
// A casino is mostly one gesture repeated: something of value is picked up here
// and put down there, in front of you, slowly enough that you can object. So the
// only real idea in this file is fly() — take an element's place on screen, take
// another's, and move a chip between them on an arc that a hand would make.
//
// Everything flies in a fixed overlay pinned to the viewport, not inside the
// felt, because a chip's journey starts on a button *outside* the felt and any
// container in between would clip it halfway. Coordinates come from
// getBoundingClientRect, so the overlay needs no knowledge of the layout at all
// and nothing has to be positioned relative to anything else.
//
// Exposed as window.PeteFX. Nothing in here knows what blackjack is.
(function () {
"use strict";
var reduced =
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var layer = null;
function stage() {
if (!layer) {
layer = document.createElement("div");
layer.className = "pete-fly-layer";
layer.setAttribute("aria-hidden", "true");
document.body.appendChild(layer);
}
return layer;
}
// Where a thing is, in viewport coordinates: the middle of it.
function centre(target) {
if (!target) return { x: 0, y: 0 };
if (typeof target.x === "number") return target;
var r = target.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
// DENOMS, biggest first — the order you break an amount down in.
var DENOMS = [500, 100, 25, 5];
// chipsFor turns an amount into the chips a dealer would actually push across:
// as few as possible, biggest first. Capped, because a €10,000 bet is 20 purple
// chips and nobody wants to watch 20 of anything fly. Past the cap the stack
// just gets shorter than the number under it, which is what a real tall stack
// looks like from across a table anyway.
function chipsFor(amount, cap) {
var out = [];
var left = Math.max(0, Math.round(amount || 0));
for (var i = 0; i < DENOMS.length && left > 0; i++) {
while (left >= DENOMS[i]) {
out.push(DENOMS[i]);
left -= DENOMS[i];
}
}
if (left > 0) out.push(5); // a remainder below the smallest chip still shows up
var max = cap || 12;
return out.length > max ? out.slice(0, max) : out;
}
function disc(denom) {
var el = document.createElement("div");
el.className = "pete-disc";
el.dataset.chip = String(denom);
return el;
}
// A deterministic wobble per index, so a stack looks hand-placed but doesn't
// reshuffle itself every time the page repaints.
function jitter(i, spread) {
var n = Math.sin((i + 1) * 12.9898) * 43758.5453;
return ((n - Math.floor(n)) * 2 - 1) * (spread || 1);
}
// 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; 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.
//
// 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);
node.classList.add("pete-fly");
stage().appendChild(node);
var dur = opts.duration || 420;
if (reduced) dur = 1;
// How high it goes: further throws arc higher, and a lob can be forced.
var dist = Math.hypot(b.x - a.x, b.y - a.y);
var lift = Math.min(120, 28 + dist * 0.18) * (opts.lift == null ? 1 : opts.lift);
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 = node.animate(
[
{ transform: t(a.x, a.y, s0, opts.fromSpin || 0), opacity: 1, offset: 0 },
{ transform: t(midX, midY, sMid, spin * 0.6), opacity: 1, offset: 0.5 },
{ transform: t(b.x, b.y, s1, spin), opacity: opts.fade ? 0 : 1, offset: 1 },
],
{ duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: reduced ? 0 : opts.delay || 0, fill: "both" }
);
return anim.finished
.catch(function () {})
.then(function () {
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 || {};
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)";
}
// flyMany throws a whole bet across, one chip after another rather than all at
// once, because a pile of chips arriving as a single object is a progress bar.
// Resolves when the last one lands.
function flyMany(from, to, denoms, opts) {
opts = opts || {};
var gap = reduced ? 0 : opts.gap == null ? 70 : opts.gap;
var each = denoms.map(function (d, i) {
return fly(from, to, {
denom: d,
index: i,
delay: (opts.delay || 0) + i * gap,
duration: opts.duration,
lift: opts.lift,
fade: opts.fade,
spin: opts.spin,
});
});
if (opts.onLand && !reduced) {
denoms.forEach(function (d, i) {
setTimeout(opts.onLand, (opts.delay || 0) + i * gap + (opts.duration || 420), d, i);
});
} else if (opts.onLand) {
denoms.forEach(function (d, i) { opts.onLand(d, i); });
}
return Promise.all(each);
}
// 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();
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.
function burst(target, opts) {
if (reduced) return Promise.resolve();
opts = opts || {};
var c = centre(target);
var n = opts.count || 26;
var colours = opts.colours || ["#f2b53d", "#4caf7d", "#5aa9e6", "#b079d6", "#ffffff", "#cc3d4a"];
var done = [];
for (var i = 0; i < n; i++) {
var bit = document.createElement("div");
bit.className = "pete-spark";
bit.style.background = colours[i % colours.length];
stage().appendChild(bit);
// Fired into a cone that mostly goes up, then let gravity have it.
var angle = (-Math.PI / 2) + jitter(i, 1) * (opts.spread || 1.15);
var speed = 120 + Math.abs(jitter(i + 7, 1)) * 190;
var vx = Math.cos(angle) * speed;
var vy = Math.sin(angle) * speed;
var dropTo = c.y + 220 + jitter(i + 3, 60);
done.push(
bit
.animate(
[
{ transform: t(c.x, c.y, 0.6, 0), opacity: 1, offset: 0 },
{
transform: t(c.x + vx * 0.45, c.y + vy * 0.45, 1, jitter(i, 180)),
opacity: 1,
offset: 0.4,
},
// Held at full colour most of the way down: a piece of confetti that
// starts fading the moment it's thrown reads as smoke.
{
transform: t(c.x + vx * 0.75, c.y + vy * 0.3 + 110, 0.95, jitter(i, 380)),
opacity: 1,
offset: 0.75,
},
{
transform: t(c.x + vx * 0.9, dropTo, 0.9, jitter(i, 540)),
opacity: 0,
offset: 1,
},
],
{
duration: 900 + Math.abs(jitter(i + 11, 1)) * 500,
easing: "cubic-bezier(0.18, 0.7, 0.4, 1)",
fill: "both",
}
)
.finished.catch(function () {})
.then(
(function (b) {
return function () { b.remove(); };
})(bit)
)
);
}
return Promise.all(done);
}
// count rolls a number to a new value instead of swapping it. A chip count that
// jumps is a variable; one that climbs is a payout.
function count(el, to, opts) {
if (!el) return;
opts = opts || {};
var from = parseInt(String(el.textContent).replace(/[^0-9-]/g, ""), 10);
if (isNaN(from) || reduced || from === to) {
el.textContent = (to || 0).toLocaleString();
return;
}
var dur = opts.duration || 520;
var t0 = null;
if (el._petePop) el._petePop.cancel();
el._petePop = el.animate(
[{ transform: "scale(1)" }, { transform: "scale(1.12)" }, { transform: "scale(1)" }],
{ duration: dur, easing: "ease-out" }
);
function step(ts) {
if (t0 === null) t0 = ts;
var p = Math.min(1, (ts - t0) / dur);
var eased = 1 - Math.pow(1 - p, 3);
el.textContent = Math.round(from + (to - from) * eased).toLocaleString();
if (p < 1) requestAnimationFrame(step);
else el.textContent = (to || 0).toLocaleString();
}
requestAnimationFrame(step);
}
window.PeteFX = {
reduced: reduced,
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,170 @@
// The money bar: chips, wallet, buying in, cashing out.
//
// Buying chips is not instant and cannot be. gogobee owns the euros and has no
// inbound API, so it polls Pete for the crossing, moves the money on its side,
// and pushes the verdict back — a few seconds, end to end. So the button does
// not lie about it: the chips show as "buying" until they are real, and this
// file polls until they are. Nothing spendable appears until gogobee has said
// it took the euros.
//
// Exposed as window.PeteGames so the table (blackjack.js) shares one view of
// the money rather than keeping a second copy that drifts from this one.
(function () {
"use strict";
var bar = document.querySelector("[data-chipbar]");
if (!bar) return;
var chipsEl = bar.querySelector("[data-chips]");
var pendingEl = bar.querySelector("[data-pending]");
var eurosEl = bar.querySelector("[data-euros]");
var amountEl = bar.querySelector("[data-buyin-amount]");
var buyBtn = bar.querySelector("[data-buyin]");
var cashBtn = bar.querySelector("[data-cashout]");
var msgEl = bar.querySelector("[data-chipbar-msg]");
var listeners = [];
var view = null;
var pollTimer = null;
var pollUntil = 0;
function money(n) {
return (n || 0).toLocaleString();
}
function say(text, tone) {
if (!msgEl) return;
if (!text) { msgEl.classList.add("hidden"); return; }
msgEl.textContent = text;
msgEl.classList.remove("hidden");
msgEl.style.color = tone === "bad" ? "#cc3d4a" : "";
}
function paint(v) {
var first = view === null;
view = v;
// The chip count rolls to its new value rather than jumping to it — the table
// times this to land with the chips that caused it, so a payout reads as the
// number catching up with the felt. On the first paint there's nothing to
// catch up with, so it just sets.
if (chipsEl) {
if (first || !window.PeteFX) chipsEl.textContent = money(v.chips);
else window.PeteFX.count(chipsEl, v.chips);
}
if (eurosEl) eurosEl.textContent = (v.euros || 0).toFixed(2);
if (pendingEl) {
if (v.pending > 0) {
pendingEl.textContent = "+" + money(v.pending) + " buying…";
pendingEl.classList.remove("hidden");
} else {
pendingEl.classList.add("hidden");
}
}
// 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); });
}
// pollPending keeps asking while a buy-in is in flight, and stops the moment
// it lands — or is refused, which looks the same from here (pending drops to
// zero) and is told apart by whether the chips arrived.
function pollPending() {
clearTimeout(pollTimer);
if (Date.now() > pollUntil) {
say("gogobee hasn't answered yet. Your euros are safe — give it a moment and reload.");
return;
}
pollTimer = setTimeout(function () {
get().then(function (v) {
if (!v) return;
if (v.pending > 0) { pollPending(); return; }
if (v.chips > (pollPending.was || 0)) {
say("Chips are yours. Good luck!");
} else {
say("gogobee wouldn't cover that — not enough euros in your wallet.", "bad");
}
});
}, 1200);
}
function request(path, body) {
return fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
}).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) {
if (!res.ok) throw new Error(data.error || "that didn't work");
return data;
});
});
}
function get() {
return fetch("/api/games/table", { headers: { "Accept": "application/json" } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (v) { if (v) paint(v); return v; })
.catch(function () { return null; });
}
if (buyBtn) {
buyBtn.addEventListener("click", function () {
var amount = parseInt(amountEl && amountEl.value, 10);
if (!(amount > 0)) { say("How many chips?", "bad"); return; }
buyBtn.disabled = true;
say("Asking gogobee for " + money(amount) + " euros…");
pollPending.was = view ? view.chips : 0;
request("/api/games/buyin", { amount: amount })
.then(function (v) {
paint(v);
pollUntil = Date.now() + 60000;
pollPending();
})
.catch(function (err) {
say(err.message, "bad");
buyBtn.disabled = false;
});
});
}
if (cashBtn) {
cashBtn.addEventListener("click", function () {
cashBtn.disabled = true;
say("Cashing you out…");
request("/api/games/cashout", { amount: 0 })
.then(function (v) {
paint(v);
say("Chips are on their way back to euros. They'll show in your wallet shortly.");
// The euro balance Pete shows is whatever gogobee last told it, so it
// only moves once the credit has actually gone through over there.
setTimeout(get, 4000);
})
.catch(function (err) {
say(err.message, "bad");
cashBtn.disabled = false;
});
});
}
window.PeteGames = {
// onUpdate registers a listener called on every fresh view of the money.
onUpdate: function (fn) { listeners.push(fn); if (view) fn(view); },
// apply pushes a view the table already fetched (a deal answers with one),
// so playing a hand doesn't need a second round-trip to refresh the chips.
apply: paint,
refresh: get,
post: request,
say: say,
view: function () { return view; },
};
get();
})();

View File

@@ -0,0 +1,555 @@
// 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;
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));
});
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 });
}
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();
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,659 @@
// 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 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 busy = false;
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;
// The seats along the top, and you underneath.
seatsEl.innerHTML = "";
youEl.innerHTML = "";
seatEls = [];
shown = [];
v.seats.forEach(function (s, i) {
var mine = i === 0;
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 !== 0);
between.classList.toggle("hidden", !live || view.phase !== "handover");
if (!live) return;
if (view.phase === "betting" && view.to_act === 0) {
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();
// No table to settle into — the session closed and storage has already cleared
// it. There is nothing to animate onto, and render() would walk seats that
// aren't there.
if (!final) { render0(); return Promise.resolve(); }
if (!events || !events.length) { render(final); return chain; }
events.forEach(function (e) {
chain = chain.then(function () { return beat(e, final); });
});
return chain.then(function () {
render(final);
verdict(events, final);
});
}
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");
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 === 0);
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 === 0 && 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 === 0 && s.cards) ? s.cards[round] : null;
var card = Cards.el(face, { deal: true, tilt: i !== 0 });
built.cards.appendChild(card);
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";
return wait(320);
}
if (e.text === "check") {
flash(s.root);
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) {
chain = chain.then(function () {
boardEl.appendChild(Cards.el(c, { deal: true, tilt: false }));
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 === 0) won += e.amount;
if (e.kind === "show") showed = true;
if (e.kind === "bust") busted = true;
});
if (busted) {
show("You're out of chips. Sit down again when you're ready.", "lose");
return;
}
if (!events.some(function (e) { return e.kind === "end"; })) return;
var me = final.seats[0];
if (won > 0) {
show(showed
? "You win " + money(won) + " with " + article(handName(events)) + "."
: "They folded. You take " + money(won) + ".", "win");
} else if (me.state === "folded") {
show("Folded.", "lose");
} else {
show("No good this time.", "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 === 0; })[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 --------------------------------------------------
function send(body, msgEl) {
if (busy) return Promise.resolve();
busy = 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);
return play(v.holdem_events, v.holdem || null).then(function () {
if (!v.holdem) { render0(); return; }
if (v.holdem.phase === "done") {
var got = v.holdem.payout, put = v.holdem.bought_in;
say(tableMsg, got > put
? "You got up " + money(got - put) + " ahead. " + money(got) + " back on your stack."
: got === 0
? "Cleaned out. Better luck at the next table."
: "You got up " + money(put - got) + " down. " + money(got) + " back on your stack.");
setTimeout(render0, 2600);
}
});
})
.catch(function (err) { say(msgEl, err.message, "bad"); })
.then(function () { busy = false; });
}
// render0 is the table with nobody at it.
function render0() {
view = null;
seatsEl.innerHTML = "";
youEl.innerHTML = "";
boardEl.innerHTML = "";
potStack.innerHTML = "";
potTotal.textContent = "0";
sideEl.classList.add("hidden");
panels();
syncSit();
}
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 () { send({ move: "leave" }, betweenMsg); });
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);
// 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; });
});
// ---- 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);
else render0();
});
})();

View File

@@ -0,0 +1,729 @@
// 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);
});
}
// ---- 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 });
}
// 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);
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,554 @@
// 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);
return;
case "right":
reveal(e.choice, e.correct);
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);
return wait(1100);
case "timeout":
reveal(-1, e.correct);
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,707 @@
// The UNO 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 turn it handed
// off to*, as a script of events. So a round trip here is a whole lap of the
// table, and this file's main job is to play that lap back slowly enough that
// you can see what happened to you.
//
// Two rules carried over from the tables that came before, both load-bearing:
//
// The number under the pile is a readout of the pile (PeteFX.spot owns that), and
// the chip bar does not move until the chips that justify it have landed. So a
// settling game holds the money back until the payout has physically swept home
// — a counter that pays you before the last card goes down has told you the
// ending.
//
// And the browser never learns a bot's hand. It gets a *count*. Every fan of
// backs you see up there is drawn from a number, because a number is all that
// crossed the wire.
(function () {
"use strict";
var root = document.querySelector("[data-uno]");
if (!root) return;
var FX = window.PeteFX;
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 paysEl = root.querySelector("[data-pays]");
var meterEl = root.querySelector("[data-meter]");
var tableEl = root.querySelector("[data-table-name]");
var wildEl = root.querySelector("[data-wild]");
var betting = root.querySelector("[data-betting]");
var playing = root.querySelector("[data-playing]");
var drawBtn = root.querySelector("[data-draw]");
var passBtn = root.querySelector("[data-pass]");
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]");
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 game as the server last described it
var tier = "table";
var pendingWild = -1; // the wild you clicked, waiting on a colour
var played = -1; // the card you just played, so the script lifts that one out
// of the hand and not merely the first one that lit up
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" : "";
}
// ---- drawing the cards -----------------------------------------------------
// GLYPHS are what goes in the middle of an action card. The face the engine
// sends is a word ("skip", "+2"); this is the drawing of it.
var GLYPHS = { skip: "🚫", reverse: "⇄", "+2": "+2", wild: "★", "+4": "+4" };
// card builds one UNO card. The oval across the middle at an angle is the whole
// look of the thing — without it a card reads as a coloured button.
function card(c, opts) {
opts = opts || {};
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";
oval.textContent = GLYPHS[c.value] || c.value;
face.appendChild(oval);
// The corners, as printed.
["tl", "br"].forEach(function (at) {
var corner = document.createElement("span");
corner.className = "pete-uno-corner";
corner.dataset.at = at;
corner.textContent = GLYPHS[c.value] || c.value;
corner.setAttribute("aria-hidden", "true");
face.appendChild(corner);
});
// A wild is four quadrants of colour — and once it has been played as a
// colour, that colour is the one it wears on the pile.
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;
}
el.appendChild(face);
el.setAttribute("aria-label", label(c));
return el;
}
function label(c) {
var v = c.value === "+2" ? "draw two" :
c.value === "+4" ? "wild draw four" :
c.value === "wild" ? "wild" : c.value;
if (c.wild) return v + (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 -------------------------------------------------------------
// FAN is the most backs a bot's hand draws, however many it holds. Past this
// the fan is unreadable and the number beside it is doing the work anyway.
var FAN = 8;
function renderSeats(v) {
seatsEl.innerHTML = "";
if (!v) return;
v.seats.forEach(function (s, i) {
if (s.you) 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 && v.phase !== "done" ? "1" : "0";
if (s.uno) el.dataset.uno = "1";
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;
el.appendChild(name);
var count = document.createElement("p");
count.className = "pete-uno-count";
count.dataset.count = "";
count.textContent = s.cards + (s.cards === 1 ? " card" : " cards");
el.appendChild(count);
seatsEl.appendChild(el);
});
}
function seatEl(i) { return seatsEl.querySelector('[data-seat="' + i + '"]'); }
// Where a card flies to or from, for a seat. Yours is the hand; a bot's is its
// fan; the deck is the deck.
function seatAnchor(i) {
if (i === 0) return handEl;
var el = seatEl(i);
return el ? el.querySelector(".pete-uno-fan") : deckEl;
}
function renderHand(v) {
handEl.innerHTML = "";
if (!v || !v.hand) return;
var playable = {};
(v.playable || []).forEach(function (i) { playable[i] = true; });
var yours = v.turn === 0 && v.phase !== "done";
v.hand.forEach(function (c, i) {
var el = card(c, { button: true });
el.style.setProperty("--i", i);
el.dataset.at = String(i);
var ok = yours && playable[i];
el.dataset.on = ok ? "1" : "0";
el.disabled = !ok || busy;
if (ok) el.addEventListener("click", function () { pick(i, c); });
handEl.appendChild(el);
});
}
function renderPiles(v) {
discardEl.innerHTML = "";
if (!v) {
deckCntEl.textContent = "0";
colourEl.textContent = "";
root.querySelector(".pete-uno").dataset.c = "";
return;
}
deckCntEl.textContent = String(v.deck);
var top = card(v.top);
top.classList.add("pete-uno-top");
discardEl.appendChild(top);
// The colour in play, which after a wild is not the colour of the card you
// are looking at. So it is said in words, and the felt takes a tint of it —
// this is the single most missable fact on the table.
colourEl.textContent = v.color;
colourEl.dataset.c = v.color;
feltEl.dataset.c = v.color;
}
var feltEl = root.querySelector(".pete-uno");
function renderRail(v) {
if (!v) {
paysEl.textContent = "—";
meterEl.dataset.cold = "1";
tableEl.textContent = "";
return;
}
paysEl.textContent = (v.pays || 0).toLocaleString();
meterEl.dataset.cold = v.phase === "done" ? "1" : "0";
tableEl.textContent = v.tier.name + " · " + v.tier.base.toFixed(1) + "×";
}
function renderTurn(v) {
if (!v || v.phase === "done") {
turnEl.textContent = "";
countEl.textContent = "";
return;
}
var yours = v.turn === 0;
var who = yours ? "Your turn" : (v.seats[v.turn] || {}).name + " is thinking…";
if (yours && v.phase === "drawn") who = "Play it, or keep it";
turnEl.textContent = who;
turnEl.dataset.you = yours ? "1" : "0";
var n = v.hand.length;
countEl.textContent = n + (n === 1 ? " card — UNO!" : " cards");
}
// ---- phases ----------------------------------------------------------------
var VERDICTS = {
won: "You went out! 🎉",
lost: "Beaten to it.",
stuck: "Nobody could move.",
};
function verdict(v) {
var text = VERDICTS[v.outcome] || "";
if (v.outcome === "lost" && v.winner > 0 && v.seats[v.winner]) {
text = v.seats[v.winner].name + " went out first.";
}
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");
if (v.outcome === "won") FX.burst(verdictEl, { count: 34 });
}
// controls is the one place that decides what you can click: whose turn the
// server says it is, and whether a move of yours is still in flight. Both
// halves matter, and they used to be spread across three functions that each
// knew half of it — which is how the table came to unlock itself in the middle
// of a bot's turn.
function controls() {
var live = !!game && game.phase !== "done";
var yours = live && game.turn === 0;
var drawn = live && game.phase === "drawn";
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
b.disabled = busy || !yours || b.dataset.on !== "1";
});
if (drawBtn) drawBtn.disabled = busy || !yours || drawn;
if (passBtn) passBtn.disabled = busy || !yours;
if (deckEl) deckEl.disabled = busy || !yours || drawn;
}
function setPhase(v) {
game = v;
var live = !!v && v.phase !== "done";
var drawn = live && v.phase === "drawn";
betting.classList.toggle("hidden", live);
playing.classList.toggle("hidden", !live);
hideWild();
if (drawBtn) drawBtn.classList.toggle("hidden", drawn);
if (passBtn) passBtn.classList.toggle("hidden", !drawn);
controls();
if (!v || !v.outcome) verdictEl.classList.add("hidden");
}
// paint puts the board up with no animation: the resume path, after a reload or
// a redeploy. Whatever the server says is on the table is on the table.
function paint(v) {
renderSeats(v);
renderPiles(v);
renderHand(v);
renderRail(v);
renderTurn(v);
spot.render(v && v.phase !== "done" ? v.bet : 0);
setPhase(v);
}
// ---- the script ------------------------------------------------------------
// throwCard sends a card from one place to another across the felt.
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,
});
}
// bump keeps a bot's fan honest during the script: the server's count is the
// truth, but between events the fan has to grow and shrink as cards move, or
// the flight lands on a pile that hasn't changed.
function bump(seat, left) {
if (seat === 0 || left == null) return;
var el = seatEl(seat);
if (!el) return;
var fan = el.querySelector(".pete-uno-fan");
var count = el.querySelector("[data-count]");
if (count) count.textContent = left + (left === 1 ? " card" : " cards");
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";
});
}
// badge floats a word off a seat: SKIPPED, UNO!, +4. The events already say
// these things; the badge is what makes them land on somebody.
function badge(seat, text, tone) {
var host = seat === 0 ? 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. On a live game the money is already right —
// your stake left your pile when you pressed Deal, and it's on the spot — so
// the chip bar can catch up immediately. On a settling one it waits.
function play(view, money) {
var events = view.uno_events || [];
var final = view.uno;
var settles = !!final && final.phase === "done";
var chain = Promise.resolve();
if (!settles) money();
events.forEach(function (e, n) {
chain = chain.then(function () {
switch (e.kind) {
case "deal":
// The deal isn't animated card by card: seven cards to each of four
// seats is 28 flights and a player who wants to play. The hand fans in
// on its own (CSS), which reads as being dealt without taking as long.
paint(final);
return wait(320);
case "play": {
spotlight(e.seat);
var node = card(e.card);
var from = seatAnchor(e.seat);
// Your own card leaves the hand it was in, so the hand has to lose it
// before the flight or the card is briefly in two places.
if (e.seat === 0 && played >= 0) {
var live = handEl.querySelector('.pete-uno-card[data-at="' + played + '"]');
if (live) live.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;
feltEl.dataset.c = e.color;
}
return wait(e.seat === 0 ? 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 }));
}
var punished = e.kind === "forced";
if (punished) badge(e.seat, "+" + e.n, "bad");
return Promise.all(backs).then(function () {
bump(e.seat, e.left);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
// Your own drawn card comes face up — it's yours, and the server
// sent its face for exactly this.
if (e.seat === 0 && e.card) return wait(160);
return wait(punished ? 380 : 180);
});
}
case "skip":
return badge(e.seat, "Skipped", "bad").then(function () { return wait(80); });
case "reverse":
feltEl.dataset.rev = feltEl.dataset.rev === "1" ? "0" : "1";
return badge(e.seat, "Reverse").then(function () { return wait(60); });
case "uno":
return badge(e.seat, "UNO!", "uno");
case "reshuffle":
// The discard has just gone back under, so the counter has to as
// well: the draws that follow in this same script count down from it,
// and counting down from a deck that still reads empty gets nowhere.
deckCntEl.textContent = String(e.n);
deckEl.classList.add("pete-uno-shuffle");
return wait(420).then(function () {
deckEl.classList.remove("pete-uno-shuffle");
});
case "pass":
spotlight(e.seat);
return wait(140);
case "settle":
return;
}
});
});
return chain.then(function () {
if (!final) { paint(null); money(); return; }
if (!settles) {
paint(final);
return;
}
// Over: the board settles, the money moves, and only then does the bar
// catch up.
renderSeats(final);
renderPiles(final);
renderHand(final);
renderTurn(final);
renderRail(final);
verdict(final);
playing.classList.add("hidden");
return settleChips(final)
.then(money)
.then(function () { return standing(final.bet); })
.then(function () { setPhase(final); });
});
}
// ---- the money -------------------------------------------------------------
function settleChips(final) {
var payout = final.payout || 0;
if (payout <= 0) return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
// Not `back`: that is the card back up there, and a number wearing its name
// is a landmine for whoever next wants a face-down card in here.
var profit = payout - final.bet;
return spot
.pour(houseEl, profit, { gap: 60 })
.then(function () { return wait(profit > 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 game, the way every
// other table in the room leaves your bet up. pour grows the pile from what it
// is told is already there, so the amount is never set first — that bug printed
// double the stake under trivia's chips for a day.
function standing(amount) {
var money = window.PeteGames.view();
if (!amount || !money || money.chips < amount) {
bet = 0;
showBet();
return;
}
bet = amount;
showBet();
return spot.pour(purseEl, amount);
}
// ---- moves ------------------------------------------------------------------
function pick(i, c) {
if (busy || !game || game.phase === "done" || game.turn !== 0) return;
if (c.wild) { askColour(i); return; }
move({ kind: "play", index: i });
}
function askColour(i) {
pendingWild = i;
wildEl.classList.remove("hidden");
}
function hideWild() {
pendingWild = -1;
if (wildEl) wildEl.classList.add("hidden");
}
// COLOURS maps the colour a player names to the number the engine calls it.
// Wild is 0 there on purpose — a move with no colour on it must not quietly
// mean red — so these start at 1 and this table is the only place that knows.
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();
move({ kind: "play", index: i, color: 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 || !game || game.turn !== 0 || game.phase !== "drawn") return;
move({ kind: "pass" });
});
}
function drawOne() {
if (busy || !game || game.phase !== "play" || game.turn !== 0) return;
move({ kind: "draw" });
}
// ---- talking to the table ----------------------------------------------------
// send takes the table away for the whole move — not just the request, but the
// script that comes back with it. A lap of this table is seconds of animation,
// and for every one of them the game on screen is a game the server has already
// moved past. Clicking a card in there would send a move against a board that
// no longer exists. So busy comes off at the end of the script, not the end of
// the request, which is what the other tables do too.
function send(path, body, where) {
if (busy) return;
busy = true;
say("", null, where);
controls();
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.uno) paint(v.uno);
else { paint(null); spot.render(0); }
});
})
.then(function () {
busy = false;
controls();
showBet();
});
}
// ---- 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 rack are the house's, 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) 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 (bet <= 0) { say("Put something on it first.", "bad"); return; }
// The stake sits on the spot for the whole game: it is what's riding on you
// going out first.
send("/api/games/uno/start", { bet: bet, tier: tier });
});
}
pickTier(tier);
var resumed = false;
window.PeteGames.onUpdate(function (v) {
if (!resumed) {
resumed = true;
if (v.uno) {
paint(v.uno);
if (v.uno.phase === "done") verdict(v.uno);
} else {
paint(null);
}
}
showBet();
});
})();

View File

@@ -9,7 +9,7 @@ import (
)
func TestStatusTemplateExecutes(t *testing.T) {
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}

View File

@@ -0,0 +1,45 @@
{{define "_chipbar"}}
<section data-chipbar
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 gap-x-6 gap-y-4">
<div class="min-w-0">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Chips in front of you</div>
<div class="flex items-baseline gap-2">
<span data-chips class="font-display text-3xl font-bold tabular-nums"></span>
<span data-pending class="hidden text-sm font-semibold text-[color:var(--ink)]/60 animate-pulse"></span>
</div>
</div>
<div class="min-w-0">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your wallet</div>
<div class="flex items-baseline gap-1.5">
<span data-euros class="font-display text-2xl font-bold tabular-nums text-[color:var(--ink)]/70"></span>
<span class="text-sm text-[color:var(--ink)]/40" title="Pete reads this off the game box every couple of minutes, so it lags a little.">€, give or take</span>
</div>
</div>
<div class="ml-auto flex flex-wrap items-center gap-2">
<div class="flex items-center gap-1.5 rounded-full bg-[color:var(--ink)]/5 p-1.5">
<label for="buyin-amount" class="sr-only">How many chips?</label>
<input id="buyin-amount" data-buyin-amount type="number" min="1" step="1" value="100"
inputmode="numeric" enterkeyhint="go"
class="w-24 rounded-full bg-[color:var(--card)] px-3 py-1.5 text-sm font-semibold tabular-nums
border-2 border-[color:var(--ink)]/10 focus:outline-none focus:border-[color:var(--accent)]">
<button type="button" data-buyin
class="rounded-full bg-[color:var(--accent)] px-4 py-1.5 text-sm font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Buy chips
</button>
</div>
<button type="button" data-cashout
class="rounded-full bg-[color:var(--card)] px-4 py-2 text-sm 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">
Cash out
</button>
</div>
</div>
<p data-chipbar-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
{{end}}

View File

@@ -0,0 +1,133 @@
{{define "title"}}Blackjack · {{.Room.Name}}{{end}}
{{define "main"}}
<div class="space-y-6" data-blackjack>
<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">Blackjack</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">Six decks · pays 3:2 · dealer hits soft 17</p>
</div>
{{template "_chipbar" .}}
<!-- The felt. The two things on it that aren't cards are the shoe the cards
come out of and the rack the money comes out of — a chip on this table is
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-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>
<span data-chip="5" style="--stack: 6"></span>
</div>
<!-- The shoe every card flies out of. -->
<div class="pete-shoe" aria-hidden="true"></div>
<div class="relative space-y-8">
<!-- Dealer -->
<div>
<div class="mb-2 flex items-center gap-2">
<span data-dealer-label class="text-xs font-bold uppercase tracking-wider text-white/60">Dealer</span>
<span data-dealer-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 data-dealer class="pete-hand" aria-live="polite"></div>
</div>
<!-- What just happened. -->
<div class="flex min-h-[2.75rem] items-center justify-center">
<!-- The pill is white, so its text is ink — not var(--ink), which is the
room's cream and would be white on white. -->
<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. -->
<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="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>
</div>
</div>
</section>
<!-- Betting: shown between hands. -->
<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="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-deal
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Deal
</button>
</div>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- Playing: shown while a hand is live. -->
<section data-actions 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 justify-center gap-3">
<button type="button" data-move="hit"
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">
Hit
</button>
<button type="button" data-move="stand"
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">
Stand
</button>
<button type="button" data-move="double"
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">
Double
</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
</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/blackjack.js" defer></script>
{{end}}

View File

@@ -13,6 +13,81 @@
</div>
</section>
{{if .ShowRoster}}
<section class="mb-10" id="roster" data-stale="{{.RosterStale}}">
<div class="flex items-baseline justify-between mb-3">
<h2 class="font-display text-2xl font-bold">Out there right now</h2>
<p class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50" id="roster-status">
{{if .RosterStale}}last known — the wire's gone quiet{{else}}live{{end}}
</p>
</div>
<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">
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
<span class="font-semibold">{{.Name}}</span>
<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>
</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>
</section>
<script>
// The board is state, not a story: an open tab should keep telling the truth
// without a reload. Cheap poll — a handful of rows, no auth, no cache.
(function () {
var list = document.getElementById('roster-list');
var status = document.getElementById('roster-status');
var card = document.getElementById('roster-card');
if (!list) return;
function esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
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">' +
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
'<span class="font-semibold">' + esc(a.Name) + '</span>' +
'<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>';
}
function refresh() {
fetch('/api/roster', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data) return;
var rows = data.adventurers || [];
list.innerHTML = rows.length
? rows.map(row).join('')
: '<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>';
status.textContent = data.stale ? "last known — the wire's gone quiet" : 'live';
card.classList.toggle('opacity-60', !!data.stale);
})
.catch(function () { /* transient — the next tick will do */ });
}
setInterval(refresh, 60000);
document.addEventListener('visibilitychange', function () {
if (!document.hidden) refresh();
});
})();
</script>
{{end}}
{{if .Stories}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{{range .Stories}}

View File

@@ -0,0 +1,155 @@
{{define "title"}}{{.Room.Name}}{{end}}
{{define "main"}}
<div class="space-y-8">
<section class="rounded-3xl bg-[color:var(--card)] p-6 sm:p-8 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<h1 class="font-display text-3xl sm:text-4xl font-bold">Welcome in 🎲</h1>
<p class="mt-2 max-w-xl text-[color:var(--ink)]/70">
Real euros, from the same wallet as everything else. Chips are one euro each,
and whatever you don't spend goes straight back when you cash out.
</p>
</div>
<div class="hidden shrink-0 sm:flex items-end gap-1.5" aria-hidden="true">
<span class="cs-stack" data-chip="5"></span>
<span class="cs-stack" data-chip="100" style="--stack:3"></span>
<span class="cs-stack" data-chip="25" style="--stack:2"></span>
</div>
</div>
</section>
{{template "_chipbar" .}}
<section>
<h2 class="font-display text-2xl font-bold mb-4">The tables</h2>
<div class="grid gap-4 sm:grid-cols-2">
<a href="/games/blackjack"
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">Blackjack</h3>
<p class="text-sm text-[color:var(--ink)]/60">Six decks, blackjack pays 3:2.</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">
The dealer stands on 17 and hits a soft one. House takes {{.RakePct}}% of what you win,
and nothing at all when you lose or push.
</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 3.6× for
beating a full table. Anybody else going out first takes your stake.
</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">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--ink)]/5 text-2xl grayscale">{{.Emoji}}</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold text-[color:var(--ink)]/50">{{.Name}}</h3>
<p class="text-sm text-[color:var(--ink)]/40">{{.Blurb}}</p>
</div>
<span class="ml-auto shrink-0 rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 text-xs font-bold uppercase tracking-wider text-[color:var(--ink)]/40">Soon</span>
</div>
</div>
{{end}}
</div>
</section>
<section class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<h2 class="font-display text-xl font-bold mb-2">House rules</h2>
<ul class="space-y-1.5 text-sm text-[color:var(--ink)]/70">
<li>· A chip is a euro. Nothing is worth more here than it is out there.</li>
<li>· You can have {{.Cap}} chips in front of you at most. That's the limit for one sitting.</li>
<li>· The house takes {{.RakePct}}% of your winnings. Never your stake: a push hands your bet straight back.</li>
<li>· Walk away for half an hour and the house cashes you out for you, so your euros never sit in limbo.</li>
<li>· Every hand is logged with the seed it was dealt from, so any hand can be dealt again exactly as it fell.</li>
</ul>
</section>
</div>
{{end}}
{{define "scripts"}}<script src="/static/js/games.js" defer></script>{{end}}

View File

@@ -0,0 +1,91 @@
{{define "layout"}}<!doctype html>
<html lang="en" data-room="{{.Room.Slug}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}{{.Room.Name}}{{end}}</title>
<meta name="robots" content="noindex">
<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">
<link rel="stylesheet" href="/static/css/output.css">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,3 27,9.5 27,22.5 16,29 5,22.5 5,9.5' fill='%23f2b53d' stroke='%232b2118' stroke-width='2.5' stroke-linejoin='round'/%3E%3C/svg%3E" type="image/svg+xml">
<meta name="theme-color" content="#17231d">
<script>
// Which room you're standing in, from your clock rather than the server's:
// Casinopolis in daylight, Casino Night Zone once the lights come on at six.
// Same rule as roomAt() in games_pages.go — keep the two in step.
//
// The server already painted its best guess into the markup, so this only
// corrects a player whose evening isn't the server's.
(function () {
var ROOMS = {
"casinopolis": "Casinopolis",
"casino-night": "Casino Night Zone",
};
function roomAt(h) { return (h >= 6 && h < 18) ? "casinopolis" : "casino-night"; }
function apply() {
var slug = roomAt(new Date().getHours());
// The palette can be set from <head> — the sign can't: this script runs
// before the header exists. So set the attribute now (no flash of the
// wrong room) and always re-stamp the name, which is a no-op until the
// header is there to stamp. Bailing out early when the slug already
// matches would leave the server's name under the browser's palette.
document.documentElement.dataset.room = slug;
document.querySelectorAll("[data-room-name]").forEach(function (el) {
el.textContent = ROOMS[slug];
});
}
apply();
document.addEventListener("DOMContentLoaded", apply);
setInterval(apply, 60000);
})();
</script>
</head>
<body class="min-h-screen bg-[color:var(--bg)] text-[color:var(--ink)]">
<div class="pointer-events-none fixed inset-0 -z-10 cs-room" aria-hidden="true"></div>
<header class="mx-auto max-w-5xl px-4 pt-6 pb-4 sm:pt-10">
<div class="flex items-center justify-between gap-3">
<a href="/games" class="group flex min-w-0 items-center gap-3">
{{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>
{{if .User}}
<a href="/auth/logout" title="Signed in as {{.User.Display}} — sign out"
class="inline-flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-2.5 py-1.5 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
<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>
{{end}}
</div>
</header>
<main class="mx-auto max-w-5xl px-4 pb-20">
{{block "main" .}}{{end}}
</main>
<footer class="mx-auto max-w-5xl px-4 pb-10 text-center text-xs text-[color:var(--ink)]/40">
Play for what you can lose. Cash out whenever you like.
</footer>
{{block "scripts" .}}{{end}}
</body>
</html>{{end}}
{{/* The house mark: a honeycomb cell struck like a chip. Stands in for a logo,
and deliberately isn't a face — nobody is watching you play. */}}
{{define "_comb"}}
<span class="cs-comb grid h-11 w-11 shrink-0 place-items-center transition-transform group-hover:-rotate-6" aria-hidden="true">
<svg viewBox="0 0 32 32" class="h-11 w-11">
<polygon points="16,2.5 27.5,9.25 27.5,22.75 16,29.5 4.5,22.75 4.5,9.25"
fill="var(--accent)" stroke="var(--ink)" stroke-width="2.5" stroke-linejoin="round"/>
<polygon points="16,9 21.5,12.25 21.5,18.75 16,22 10.5,18.75 10.5,12.25"
fill="none" stroke="var(--ink)" stroke-width="1.75" stroke-linejoin="round" opacity="0.5"/>
</svg>
</span>
{{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,212 @@
{{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>
<!-- 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>
<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

@@ -4,6 +4,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}{{.SiteTitle}}{{end}}</title>
{{if .NoIndex}}<meta name="robots" content="noindex">{{end}}
{{if .OGImage}}<meta property="og:image" content="{{.OGImage}}"><meta name="twitter:card" content="summary_large_image">{{end}}
<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">
@@ -320,5 +322,6 @@
<script src="/static/js/settings.js" defer></script>
<script src="/static/js/reader.js" defer></script>
<script src="/static/js/pwa.js" defer></script>
{{block "scripts" .}}{{end}}
</body>
</html>{{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,27 @@
{{define "title"}}{{.Headline}} — {{.SiteTitle}}{{end}}
{{define "main"}}
<article class="mt-2 mb-10 max-w-3xl mx-auto">
<nav class="mb-4">
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
<span aria-hidden="true"></span> All dispatches
</a>
</nav>
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{.Emoji}}</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-80">{{.Emoji}} {{.EventLabel}}</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Headline}}</h1>
<p class="mt-4 text-xs uppercase tracking-wider opacity-75">Reported {{.When}}{{if .Region}} · {{.Region}}{{end}}</p>
</div>
</header>
<div class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
<p class="text-lg leading-relaxed text-[color:var(--ink)]/90 whitespace-pre-line">{{.Body}}</p>
<p class="mt-8 pt-6 border-t border-[color:var(--ink)]/10 text-sm italic text-theme-adventure font-semibold">
Reporting from the realm, this is Pete.
</p>
</div>
</article>
{{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,178 @@
{{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">Go out first, or it was somebody else's night</p>
</div>
{{template "_chipbar" .}}
<!-- The felt. The board takes the room and the money lives in a rail beside it:
there is no corner free on this table for the house rack to sit in. -->
<section class="pete-felt pete-uno relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
<div class="min-w-0 space-y-5">
<!-- The bots. Each one is a name, a fan of backs, and a count — which is
all you are ever told about them, and all a real opponent shows you. -->
<div data-seats class="flex flex-wrap items-start justify-center gap-3 sm:gap-5" aria-label="The other players"></div>
<!-- The middle: what you draw from, and what you play onto. -->
<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>
</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="pete-spot" data-spot>
<span class="pete-spot-label">Bet</span>
<div class="pete-stack" data-stack></div>
<span data-spot-total class="pete-spot-total hidden"></span>
</div>
<div class="text-center">
<div class="pete-meter" data-meter>
<span class="pete-meter-label">Pays</span>
<span data-pays class="pete-meter-value"></span>
</div>
<p data-table-name class="mt-1.5 text-xs font-bold uppercase tracking-wider text-white/40"></p>
</div>
</aside>
</div>
<!-- Naming a colour for a wild. It sits over the felt because until it is
answered there is no legal move on the table underneath it. -->
<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>
</section>
<!-- Playing: shown while a game is live. -->
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-center gap-3">
<p 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-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>
<!-- Betting: shown between games. -->
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Who are you playing?</div>
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Tables}}
<button type="button" data-tier="{{.Slug}}"
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · seven cards each
</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">
Deal
</button>
</div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
Normal rules: no stacking a +2 on a +2, and a reverse is a skip when it's just the two of you.
</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}}

View File

@@ -67,6 +67,14 @@ func thumbURL(src string) string {
if src == "" {
return ""
}
// Root-relative sources are our own local assets (e.g. the adventure
// emblems) — serve them directly; the thumbnailer only handles remote
// http(s) images and would 404 a local path. "//host/x.jpg" is NOT local:
// it's a protocol-relative remote image, and it has to keep going through
// the guarded thumbnailer like any other third-party URL.
if strings.HasPrefix(src, "/") && !strings.HasPrefix(src, "//") {
return src
}
return "/img/" + thumbKey(src) + ".avif?u=" + url.QueryEscape(src)
}

View File

@@ -46,7 +46,7 @@ func TestIndexTrendingAndBadges(t *testing.T) {
storage.RecordStoryView(id)
storage.RecordStoryView(id)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}

126
internal/web/trivia_bank.go Normal file
View File

@@ -0,0 +1,126 @@
package web
import (
"context"
"log/slog"
"time"
"pete/internal/games/trivia"
"pete/internal/opentdb"
"pete/internal/storage"
)
// Keeping the trivia bank stocked.
//
// The bank is not consumed by play — a question drawn is still there afterwards
// — so this loop is about *variety*, not supply. It fills each difficulty up to
// a target and then has nothing to do, which is why a pass that finds the bank
// full costs three COUNT queries and no network at all.
// bankTarget is how many questions of each difficulty we want to hold. Twelve
// rungs drawn from four hundred is enough that a regular player doesn't start
// recognising them, and it's a size OpenTDB's pool can actually fill.
const bankTarget = 400
// bankMaxFetches bounds one pass. At OpenTDB's politeness interval this is a
// couple of minutes of drip, after which the loop goes back to sleep rather than
// hammering a free API for an hour to top up the last few questions.
const bankMaxFetches = 12
// bankInterval is how often we go back and look. The bank is a slow-moving
// thing: it only grows, and it only needs to grow once.
const bankInterval = 12 * time.Hour
// StartTriviaBank launches the refill loop if the casino is on. Safe to call
// unconditionally; a no-op when games are off.
func (s *Server) StartTriviaBank(ctx context.Context) {
if !s.gamesReady() {
return
}
go s.runTriviaBank(ctx)
}
func (s *Server) runTriviaBank(ctx context.Context) {
slog.Info("games: trivia bank refill started", "target", bankTarget, "interval", bankInterval)
s.refillTriviaBank(ctx)
ticker := time.NewTicker(bankInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.refillTriviaBank(ctx)
}
}
}
// refillTriviaBank tops each difficulty up toward the target, politely.
//
// Every failure here is survivable and none of them stop the loop: OpenTDB is a
// free API that is sometimes down, and a thin bank costs a player nothing worse
// than a "give it a minute" when they try to start a ladder.
func (s *Server) refillTriviaBank(ctx context.Context) {
client := opentdb.New()
fetches := 0
for _, t := range trivia.Tiers {
for fetches < bankMaxFetches {
have, err := storage.CountTrivia(t.Difficulty)
if err != nil {
slog.Error("games: trivia bank count", "difficulty", t.Difficulty, "err", err)
break
}
if have >= bankTarget {
break
}
qs, err := client.Fetch(ctx, t.Difficulty, opentdb.Batch)
fetches++
if err != nil {
if ctx.Err() != nil {
return
}
slog.Warn("games: trivia bank fetch", "difficulty", t.Difficulty, "err", err)
// Whatever went wrong, waiting is the only sensible response: the
// likeliest cause is the rate limit, and retrying at once earns another.
if !sleepCtx(ctx, opentdb.Politeness) {
return
}
continue
}
added, err := storage.AddTriviaQuestions(t.Difficulty, qs)
if err != nil {
slog.Error("games: trivia bank store", "difficulty", t.Difficulty, "err", err)
break
}
slog.Info("games: trivia bank filled",
"difficulty", t.Difficulty, "fetched", len(qs), "new", added, "have", have+added)
// The API hands back random batches, so once the bank is deep the
// overlap gets heavy and a batch adds almost nothing new. When it adds
// nothing at all, this difficulty has given us what it has: stop asking.
if added == 0 {
break
}
if !sleepCtx(ctx, opentdb.Politeness) {
return
}
}
}
}
// sleepCtx waits, unless we're being shut down. Reports false if we are.
func sleepCtx(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}

View File

@@ -0,0 +1,80 @@
package web
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"pete/internal/config"
"pete/internal/storage"
)
// TestDrive is not a test. It is a hand crank: it stands the casino up on a real
// port with one funded player so a browser can be pointed at it. Skipped unless
// PETE_DRIVE is set. Delete it if it ever stops earning its keep.
func TestDrive(t *testing.T) {
if os.Getenv("PETE_DRIVE") == "" {
t.Skip("set PETE_DRIVE=1 to hand-drive the casino")
}
// Built through New() with the tables already open, because the /games routes
// are registered at construction — newCasino() switches them on afterwards,
// which is fine for handler tests and useless for a browser.
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "drive.db")); err != nil {
t.Fatal(err)
}
defer storage.Close()
s, err := New(config.WebConfig{
SiteTitle: "Pete",
ListenAddr: ":0",
BaseURL: "http://127.0.0.1",
Games: config.GamesConfig{Enabled: true, MatrixServer: "parodia.dev"},
}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}
// New() only mounts the casino when auth is live, and auth means an OIDC
// discovery call. Sign the cookie by hand and mount the routes here instead —
// the handshake is not what a browser is here to look at.
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
fund(t, 5000)
static, err := fs.Sub(staticFS, "static")
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
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)
srv := httptest.NewServer(mux)
defer srv.Close()
payload, _ := json.Marshal(SessionUser{
Sub: "sub-1", Username: "reala", Exp: time.Now().Add(time.Hour).Unix(),
})
fmt.Printf("DRIVE_URL=%s\nDRIVE_COOKIE=%s=%s\n", srv.URL, sessionCookie, s.auth.sign(payload))
// Long enough for a browser to buy in, deal a few hands, and be screenshotted.
deadline := time.Now().Add(3 * time.Minute)
for time.Now().Before(deadline) {
time.Sleep(500 * time.Millisecond)
if _, err := http.Get(srv.URL + "/healthz"); err != nil {
return
}
}
}

41
main.go
View File

@@ -248,6 +248,13 @@ func main() {
if postingEnabled && roundRobinMode {
channelNames := make([]string, 0, len(cfg.Matrix.Channels))
for name := range cfg.Matrix.Channels {
// The adventure channel drives its own posting: priority beats go
// live at ingest, bulletins wait for the daily digest. Leaving it in
// the rotation would let the scheduler post bulletins one-by-one
// (they're "classified, not yet posted") and steal them from the digest.
if cfg.Adventure.Enabled && name == cfg.Adventure.Channel {
continue
}
channelNames = append(channelNames, name)
}
rr := scheduler.New(channelNames, cfg.Posting.RoundRobin.IntervalHours, queue)
@@ -258,14 +265,41 @@ func main() {
}
}
// Adapter: the web ingest handler posts priority adventure beats live to
// Matrix through the same queue everything else uses (bypasses pacing).
//
// Adventure carries its own enable switch and is push-based: facts arrive
// from gogobee at ingest, they are not polled, classified or paced, and they
// never enter the round-robin rotation. So it is gated on [adventure] alone,
// NOT on posting.enabled — that flag governs the RSS pipeline's chatter, and
// an operator running "news on the web only, adventure live in the games
// room" is a legitimate configuration. A nil poster (adventure off, or no
// channel) still keeps both the live beats and the digest loop shut.
var advPost web.PriorityPoster
if cfg.Adventure.Enabled && cfg.Adventure.Channel != "" {
advPost = func(p web.AdvPost) {
queue.PostNow(poster.QueueItem{
GUID: p.GUID,
Headline: p.Headline,
Lede: p.Lede,
ImageURL: p.ImageURL,
ArticleURL: p.ArticleURL,
Source: p.Source,
Channel: p.Channel,
})
}
}
// Start the read-only web UI alongside the Matrix bot.
if cfg.Web.Enabled {
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled)
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled, cfg.Adventure, advPost)
if err != nil {
slog.Error("web server init failed", "err", err)
} else {
go ws.Start(ctx)
ws.StartPushSender(ctx)
ws.StartAdventureDigest(ctx)
ws.StartTriviaBank(ctx)
}
}
@@ -370,8 +404,9 @@ func runLocal(cfg *config.Config) {
poller.Start(ctx)
slog.Info("local: pollers started")
// Local mode never connects to Matrix, so it's always web-only.
ws, err := web.New(cfg.Web, cfg.Sources, false)
// Local mode never connects to Matrix, so it's always web-only: adventure
// stories still ingest and render, but nothing posts (nil priority poster).
ws, err := web.New(cfg.Web, cfg.Sources, false, cfg.Adventure, nil)
if err != nil {
slog.Error("local: web server init failed", "err", err)
os.Exit(1)

920
pete_games_plan.md Normal file
View File

@@ -0,0 +1,920 @@
# Pete Games — games.parodia.dev
A web casino/arcade on Pete, authenticated by Authentik, playing for gogobee euros.
Blackjack, Texas Hold'em, UNO (normal + no-mercy), Hangman, Trivia.
Companion to `gogobee_mischief_plan.md`, which already established the web↔game seam.
This plan reuses that seam wholesale and does not invent a second one.
---
## 0. Progress — last updated 2026-07-14
A multi-session build. This section is the handover; read it before anything else.
### Decisions taken (these close §9's open questions)
- **Chips are 1:1 with euros.** No second denomination.
- **Session buy-in cap: €10,000**, enforced against chips held *plus* buy-ins still
in flight, so it can't be cleared by firing several requests at once.
- **A house rake**, 5% in blackjack's `DefaultRules`, taken from *winnings only*
never the stake. A push returns the bet untouched; a loss is never charged a fee.
- **The site shares Pete's design, not Pete's shell.** *(Revised 2026-07-13 — this
replaces the earlier "the site must look like Pete", which meant `layout.html`
itself.)* The casino is its own place. It takes the design language — Fredoka/
Nunito, the four palette vars, `rounded-3xl`, `shadow-pete`, the bubbly weight of
everything — and takes none of the furniture: no Pete avatar, no channel nav, no
search, no reader, no settings, no weather canvas, no PWA. It has its own layout
(`games_layout.html`), its own header, its own footer, its own scripts. Still not
an SPA; still server-rendered `html/template`.
- **It has two names, on a clock.** Casinopolis by day, Casino Night Zone from six
in the evening — palette, felt and the sign over the door all change together.
This is the news app's phase system pointed at a joke: one `data-room` attribute,
two palette blocks, and a rule shared between `roomAt()` in Go (first paint) and
the same rule in JS (the player's own clock, so a player abroad gets their own
evening).
- **Dealing is animated.** Cards visibly dealt and flipped, chips that move. This is
a requirement, not polish to drop when the clock runs out.
### Done
- **Phase 0 — euro idempotency (gogobee).** `euro_transactions.external_id` + a
partial unique index, and `CreditIdem`/`DebitIdem` in `internal/plugin/euro.go`:
balance mutation and transaction log in one tx, keyed by the escrow GUID. A replay
reports success without moving money again; a rejection writes nothing, so the same
GUID stays retryable once the player is good for it. Six tests, including eight
goroutines racing one GUID. *(gogobee `ab2bcf0`)*
- **`pete/internal/games/cards`** — the shared deck gogobee never had. RNG is
threaded, never the package global, so a hand is reproducible from its seed.
*(pete `8310b30`)*
- **`pete/internal/games/blackjack`** — pure reducer,
`ApplyMove(state, move) (state, []Event, error)`, where an error means the move was
illegal and nothing else. State is a plain value: it serializes, so a hand survives
a redeploy, and it replays. Six decks, 3:2, dealer hits soft 17, plus the rake.
*(pete `8310b30`)*
- **`pete/internal/storage/games.go`** — the euro/chip border. `game_chips`,
`game_escrow`, `game_hands`. Chips appear only once gogobee confirms it took the
euros; chips are destroyed the moment a cash-out opens (so they can't be bet while
their euros are in flight) and come back if the credit fails. Table cap, 30-minute
reaper, per-hand audit log with seeds. 17 tests. *(pete `f9a98f7`)*
- **The wire protocol.** Pete serves `GET /api/games/escrow/pending`, `POST …/claim`,
`POST …/settled` (`internal/web/games.go`), bearer-authed on the adventure ingest
token. gogobee polls every 3s (`internal/plugin/pete_games.go`), claims a row,
calls `DebitIdem`/`CreditIdem` against the escrow GUID, and pushes the verdict
back through `pete_emit_queue` — which grew a `path` column so escrow verdicts
ride the same durable queue as adventure facts rather than getting a second one.
`peteclient.Flush` sends the verdict immediately instead of waiting out the 15s
sender tick, because a player is watching a spinner. A row re-offered after a
gogobee crash replays as a no-op: 13 tests across both repos, including a fake
Pete that offers the same row three times and a player who is charged once.
- **Identity.** `preferred_username` now rides in the signed session, and
`SessionUser.MatrixUser(server)` maps it to `@user:parodia.dev`. The session cookie
takes an opt-in `web.auth.cookie_domain`, so a sign-in on news is a sign-in on games;
the OAuth round-trip cookie deliberately stays host-only, and the redirect_uri is
derived per-request so a login that starts on games comes back to games. A Host we
don't own is never echoed into a redirect. *(pete `cb84e1d`)*
- **Blackjack, playable end to end.** `game_live_hands` (the hand in progress,
engine state and all, so a redeploy mid-hand is survivable), the session-authed play
surface (`internal/web/games_play.go`), the lobby and table pages, and the dealing
animation. Driven in a real browser: chips staked before the deal, hole card withheld
from the payload until the reveal, payout settled back into the stack.
- **The casino moved out.** Its own layout (`games_layout.html`), parsed as its own
template set alongside the news one; `gamesPage` no longer embeds the news
`pageData`, which is what stops the old furniture drifting back one convenient
field at a time. Two rooms on a clock (above), the felt reupholstered from the
room's vars, and a house mark that is a honeycomb chip rather than a face.
- **The cards are cards.** Corner indices in both corners (the bottom one upside
down, as printed), pips laid out on the three-by-seven grid a real deck uses,
bottom-half pips inverted, courts as a letter with the suit over each shoulder,
and a screen-reader label that says "Queen of hearts" instead of "Q♥".
- **The money moves.** The felt grew the two things it was missing: a **bet spot**
in front of you and the **house's rack** beside the shoe, so every chip on the
table is always travelling between one of those and the other. A bet is *built*
by throwing chips onto the spot (the chip you clicked is the chip that flies);
the stake sits there through the hand; the house pays out of its rack into the
spot; the whole pile is then swept back to your pile. A loss goes to the rack and
doesn't come back. `casino-fx.js` is the shared engine — `fly`/`flyMany` (WAAPI,
on an arc, out of a fixed overlay so nothing clips them), `chipsFor` (an amount
broken into the fewest chips, capped at what's worth watching), `burst`, `count`.
Two rules hold it together, and both are load-bearing:
1. **The number under the pile is a readout of the pile**, never the other way
round. So the bet starts at zero rather than at a default nobody put down, and
a settled hand puts your stake *back on the spot* as a standing bet — otherwise
the panel prints "your bet: 300" over an empty circle.
2. **The chip bar does not move until the chips that justify it have landed.** On
a live hand the money applies immediately (your stake left your pile and is
visibly on the spot); on a settling hand `play()` holds the apply until the
payout has swept home. A counter that pays you before the dealer turns over is
a counter that has told you the ending.
Also: cards land with weight (overshoot, a shadow that takes the hit, a degree or
two of resting tilt each), the dealer takes a beat before drawing out, and a
natural gets confetti — the only thing in the room that does.
- **A way to actually look at it.** `internal/web/devcasino_test.go` is the casino on
a port with one signed-in, funded player: `PETE_DEV_CASINO=:7788 go test
./internal/web -run TestDevCasino -timeout 0`. Skipped without the env var. It
wires its own routes because `New()` decides whether the casino exists at the
moment it builds the mux, and the test rig signs the player in afterwards. **Drive
the table in a real browser before believing anything about it** — this pass found
a white-on-white verdict pill, a rack that collided with the dealer, and Hit still
being offered over a table that was being paid out, none of which a Go test can see.
- **Deployed, 2026-07-14.** https://games.parodia.dev is live. What that took, since
the shape of it was not quite what this plan guessed:
- The edge is **Traefik**, not Caddy (`/mash/traefik/config/provider.yml`, root-owned,
file provider so it hot-reloads). The casino needed no router of its own — the
existing `pete` router's rule grew a second host:
``Host(`news.parodia.dev`) || Host(`games.parodia.dev`)``, and ACME issued the cert
on its own. DNS for the games host already pointed at the box.
- Authentik lives at **auth.parodia.dev**, and the app's OAuth2 provider is "Pete News"
(pk 12). It now holds both callbacks, strict: news and games. The provider's
`redirect_uris` is a list of objects, not strings.
- Server config gained `[web.games]` (enabled, host, `matrix_server = "parodia.dev"`)
and `web.auth.cookie_domain = ".parodia.dev"`, which is what makes a news sign-in a
games sign-in. Old host-only session cookies don't carry over — a signed-in user
signs in once more, and after that the session spans both.
- **gogobee is not on that box.** It runs on the LAN at `reala@192.168.1.212`, in a
screen session, out of `~/gogobee`, and it has no key for its own GitHub remote —
deploy it with `ssh -A` so the pull rides your agent. Its escrow loop needs no new
config: it is gated on the `FEATURE_PETE_NEWS` / `PETE_INGEST_*` env in `~/.env`
that adventure news already set. Restarted, it logs
`pete games: escrow loop started interval=3s`.
- **Hangman, and it plays for chips.** *(2026-07-14. This revises §7's "Phase 2 —
no escrow": the decision was that a free game in a casino reads as a demo, so
hangman stakes chips like everything else and reuses the money path whole.)*
- **The gallows is the payout meter.** You pick a tier, stake, and get six
lives. Every wrong guess draws a limb *and* takes a tenth off the base
multiple — one event, shown as one event. Short phrases pay 2.6×, medium 2.0×,
long 1.6× (short is hardest: fewer letters, less to go on). Floored at 1×, so
a win never hands back less than the stake, and the rake still comes out of
winnings only.
- `internal/games/hangman` — the same pure reducer as blackjack, phrases
embedded (`phrases.txt`, 205 of them, video-game flavoured, lifted from
gogobee). `State.Pays()` is the number the felt quotes *and* the number
settle() lands on: they were briefly two sums and the table advertised a
pre-rake payout it didn't honour. One function now, and a test that walks a
game asserting the quote equals the payout at every step.
- **The browser never sees the phrase.** Cells carry the letter or an empty
string — not the letter with a hidden flag — and the phrase itself is only
added to the payload once the game is over and it decides nothing.
- Two things the storage layer already gave us for free, and one it didn't:
`game_live_hands` is keyed on the *player*, so "one game at a time" holds
across games with no new code (a live hangman 409s a blackjack deal). But
`table()` used to unmarshal any live row as a blackjack hand — which does not
*fail* on a hangman row, it just silently yields an empty hand. It now
dispatches on `live.Game`.
- `commit()` in games_play.go is the shared settle path (seat → pay → audit →
clear → touch). Both games go through it so neither re-derives an ordering
that took a while to get right. `casinoRoutes()` is likewise the single route
list, because devcasino_test.go has to wire its own mux and a second copy is
a copy that stops including the newest game.
- Driven in a real browser, win and loss: a 200 stake at 2.34× paid 455 and the
bar landed on it; six wrong took the stake and nothing more; a reload
mid-phrase brought back the board, the limbs, the multiple, the spent keys and
the stake on the spot. Two layout bugs only the browser could show: the lives
counter ran under the house rack, and the board wrapped a word early because
the rack's clearance padding was on the whole column instead of the one row
level with it.
- **Solitaire, and it plays for chips.** *(2026-07-14, jumping the queue ahead of
trivia because the user asked for it.)*
- **Vegas scoring**, which is the only way solitaire has ever actually been a
gambling game. 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 fifty-second of the
tier's multiple back. Cash the board whenever you like and keep what you've
banked; a board that has gone dead is therefore a decision, not a wall. There
is no undo, because 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**
(draw 1, unlimited passes, 1.4×, square at 38 cards), **Vegas** (draw 3, three
passes, 2.2×, square at 24), **Cutthroat** (draw 3, one pass, 3.4×, square at
16). `Tier.BreakEven()` is what the felt quotes, because "2.2×" tells a player
nothing about a game where the multiple is paid a card at a time.
- `internal/games/klondike` — the same pure reducer. `Pays()` is one function for
the same reason hangman's is. Two fuzzers hold the deck together: no card is
ever lost or duplicated by any sequence of moves, and the board stays
well-formed (every face-up run is a run, no column has cards face-down under
nothing). The first thing a test caught was a **recycle that reversed the
waste** — it flips as a block, so the card drawn first comes out first, and
reversing would have dealt a different game on every pass and broken the seed.
- **The browser never sees the stock or a face-down card.** Bigger than
blackjack's hole card: that's most of the deck. Columns send a face-down
*count*, never the cards. The events, unlike blackjack's, need no filtering —
every card they carry is one the move just turned face up.
- **The table re-renders and animates the difference (FLIP).** Blackjack plays
back a script because a hand only grows at one end; solitaire moves runs from
anywhere to anywhere and an auto-finish moves eleven cards at once. So
`solitaire.js` measures where every card is, re-renders the board the server
sent, and plays each card from its old place to its new one. The board on
screen is therefore always exactly the board the server says exists. The events
supply only what a diff can't: where a *newly revealed* card came from (the
stock, or a flip in place) and what the board is now worth.
- **The rules are mirrored in JS**, deliberately, and only to light up the columns
a held card can go to. The server still decides every move; a disagreement
snaps the board back to whatever it says. Being shown where a card goes is the
game teaching you; being told no after you commit is the game scolding you.
- Two things got extracted rather than copied, which is the rule this room runs
on: **`casino-cards.js`** (the deck — faces, pips, the flip; was inside
blackjack.js) and **`PeteFX.spot()`** (the pile of chips and the number under
it, which owns the "the number is a readout of the pile" rule so no table can
break it). Blackjack now uses both.
- **Driven in a browser, 2026-07-14, and it holds up.** Every worry on the list
came back clean. A Patient deck bought for 200 dealt a correct Klondike (28 cards
across the seven columns, 24 left in the stock), quoted `+5.4 a card` and `38 more
to break even` — which is the tier's arithmetic, not a guess — and the money
conserved end to end: 5,000 → 4,800 to buy the deck → one card home banked 5 →
4,805 cashed out. The FLIP does not jump on a re-render. The seven columns fit at
390px with no horizontal overflow (`docScrollW == clientW`), the rail stacks under
the board rather than colliding with it, and the console is silent.
- **And blackjack survived the rewire**, which was the real thing to check. Five
hands, and the felt agreed with `/api/games/table` on every one. The rake still
comes out of winnings only: a 400 win paid back 780 (the stake, plus 400 less 5%),
and a push returned all 600 with nothing taken.
- One thing to know before you go looking for a bug that isn't there: the bare
`<span data-chip>` elements are the *house rack's decoration*. Only
`button[data-chip]` carries a listener. A driver script that clicks `[data-chip]`
hits the rack, nothing happens, and it looks like the bet is broken. Blackjack's
action buttons are also `[data-move="stand"]`, not `[data-stand]`.
- **Trivia, and it plays for chips.** *(2026-07-14. Built, and now **played** — see
"Driven in a browser" at the bottom of this entry, which is where the two bugs
were.)*
- **A ladder.** Stake once, then answer a run of twelve. Every right answer
multiplies what you're holding, a wrong one loses the lot, and you may walk
with what you've built. Clearing all twelve ends the run and banks it — a
ladder with no top is a slot machine you can't stop playing, and eventually
every player loses everything to one bad question.
- **The clock is the game, and it is the anti-google mechanism.** Trivia answers
are lookupable, so a right answer is worth what it's worth *when you give it*:
the multiple decays from Fast to Buzzer across the tier's limit (easy 1.30→1.10
over 20s, medium 1.55→1.20 over 18s, hard 1.90→1.30 over 15s), and running out
of time loses exactly as much as being wrong. A timeout that merely cost you the
speed bonus would make "look it up in the other tab" the strongest way to play.
The countdown in the browser is decoration; the clock that scores is
`time.Now()` against the `AskedAt` the server stamped. A reload does not restart
it.
- **A pure reducer still, but the time is an argument** — `ApplyMove(state, move,
now)`. A reducer cannot own a timer, so it doesn't: the only thing that knows
what o'clock it is remains the caller, and the engine stays value-in, value-out.
- **You cannot walk off the first rung** (`ErrNothingBanked`). If you could, seeing
question one and walking would be a free look: stake, peek, walk, restake, and
reshuffle until the question is one you happen to know. The first question is the
price of sitting down.
- **The browser never learns which answer is right.** The four answers cross the
wire without the index; that index is in the engine state, on the server. It
comes back only in the event that *decides* the question, by which point knowing
it is worth nothing. The ladder's remaining questions are never sent at all.
- `internal/games/trivia` — engine, 11 tests. The one that matters most is the
same one hangman needed: the number the felt quotes (`Pays()`) is asserted equal
to the number `settle()` lands on, at every rung.
- **The bank is prefetched, not fetched per question** (`internal/opentdb`,
`storage.DrawTrivia`, table `trivia_questions`). A ladder asks a question every
fifteen seconds with money on a clock the player is scored against; a live fetch
would put OpenTDB's latency and rate limit *inside* that clock. The refill is a
slow background drip (`StartTriviaBank`, 400 per difficulty, one request per six
seconds, stops early when a batch adds nothing new), and a round never waits on
it. Answers are shuffled per-game against the game's own seed, so where the right
answer sits in the table tells a player nothing.
- **The dev rig seeds its own bank.** A fresh dev database has an empty bank and
every start 503s, so `TestDevCasino` now takes one real batch per difficulty
from OpenTDB (`seedTriviaBank`) — fifty questions each, four ladders' worth,
through the same fetch-decode-store path production uses. It does *not* run
`StartTriviaBank`: a rig that spends its first two minutes dripping four hundred
questions per difficulty out of a free API is a rig you cannot use.
- **Driven in a browser, 2026-07-14, and the clock and the money hold up.** The
ladder plays: a 200 stake on Easy dealt a real OpenTDB question with its
entities decoded, the clock bar drained honestly (847px → 711px over three
seconds, countdown 18.7s → 15.7s), two right answers compounded 1.00× → 1.26×
→ 1.58×, and walking paid exactly the 311 the felt had been quoting. The
reveal marks the wrong pick red and the right answer green. A reload mid-rung
brought the board back and — the thing that matters — the server's clock kept
running through it (17.5s left before, 16.2s after; it does not restart).
**The timeout lands as a timeout**, which was the loudest worry: going quiet
through a 20s question fired the auto-submit at zero, came back 200 with a
`timeout` event and "Out of time.", not a "that move isn't legal". The next
question's answer is never sent (`correct: -1` in the ask event); only the
decided one reveals.
- **Two bugs, and only a browser could have found either.**
1. **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 whatever it is told is already there — so a 200 stake settled to a spot
reading 400, and a 400 one to 800. This is exactly the rule the felt is built
on ("the number under the pile is a readout of the pile") failing quietly:
the money was always right, the *number under the chips* was not. Blackjack
and hangman pour without pre-setting; trivia now does too.
2. **The house rack sat on top of the multiplier at 390px.** The rack is a 147px
block inset 5.75rem from the edge, and that inset is not a margin — it is the
width of *blackjack's shoe*, which the rack sits beside. On a phone that puts
it in the middle of the felt, on top of trivia's "1.53×". On small screens the
rack now shrinks and, where there is nothing in the corner, pulls into the
corner. Which rack is which is what `data-at` says: unmarked is alone in the
corner, `shoe` is blackjack (pull that one to the edge and it slides under the
deck — this was caught after doing exactly that), `rail` is solitaire, whose
rack isn't on the felt at all. All four tables re-checked at 390px and 1280px,
live games on the felt: no overlap with text, no overlap with the shoe, no
horizontal overflow, desktop geometry unchanged.
- **UNO, and it plays for chips.** *(2026-07-14. Built, tested, and now **played** —
see "Driven in a browser" at the bottom of this entry, which is where the three
bugs were.)*
- **You beat the table, or you don't.** The user's call between three money
models: stake once, go out first and take the tier's multiple; anybody else
going out first takes the stake. **The table size is the tier**, which is the
one dial UNO actually has: Duel (1 bot, 2.2×), Table (2 bots, 2.9×), Full
House (3 bots, 3.6×). Rake on winnings only, as everywhere.
- **The multiples are measured, not guessed.** A player who just plays the first
legal card they hold goes out first 43% / 32% / 27% of the time against the
bots, so the tiers are priced to make that lose about 8% a game — which leaves
good play (holding the wilds back, dumping the colour you're long in) worth
roughly the house's edge. The measurement is a throwaway test, not in the tree;
re-run it if the bots or the tiers change, because the two are a pair.
- **The bots move inside `ApplyMove`, and that is what keeps solo UNO off a
socket.** One request plays your move *and every bot turn it hands off to*,
and returns the lot as a script of events the felt plays back in order. §7 said
solo first, no sockets; this is what that costs.
- **The RNG is in the state, not an argument.** The bots choose and a spent deck
reshuffles, so the engine needs randomness *mid-game* — but there is no rng
alive across requests to hand it. So the seed rides in the state (which never
leaves the server; the deck is in there too) and each step derives its own
generator from the seed and the step count. Value in, value out, and a game
still replays exactly as it fell.
- **The zero value of `Color` is Wild, deliberately.** It was Red for an hour, and
a wild played with the `color` field simply missing from the JSON went down as
a red one. The zero has to be "no colour named", so the omission is refused
instead of quietly meaning something. This is the kind of bug a rules test
finds and a browser never would.
- **The browser never sees a bot's card.** Not the deck, not a hand, not even the
face of a card a bot drew — that last one is most of the deck, and sending it
would turn counting cards into reading the network tab. Seats cross the wire as
a name and a *count*. There are two walls: the engine only attaches a face to
an event the seat may see it in, and `viewUnoEvents` drops it again anyway.
- `internal/games/uno` — engine, 22 tests. The census one is the load-bearing one:
108 cards, each in exactly one place, asserted after every move of 100 games
played out end to end. It is what would catch a reshuffle that leaks cards (the
wilds go back into the deck as *wilds*, not as the colour they were played as)
or a turn the bots never hand back.
- `PeteFX.flyNode` — the throw, with the chip taken out of it. `fly()` is now that
with a chip in it, because UNO wanted the same arc with a card in it. Extracted
rather than copied, same as `casino-cards.js` and `PeteFX.spot()` before it.
- The felt has no corner free for the house rack (bots along the top, piles in the
middle, your hand at the bottom), so it takes solitaire's **rail** instead:
`data-at="rail"`, off the felt, no collision to check for.
- **Driven in a browser, 2026-07-14, and it plays.** A Full House game went the
distance: the bots' turns come back as a readable script (a card flies from the
seat that played it, SKIPPED and +2 land on somebody), the wild picker takes a
colour and the felt changes to it, a reload mid-game brings back the hand, the
counts, the colour in play and the stake, and the money is right — a Duel staked
200 and won paid 428 back into a 4,600 stack (2.2× is 240 of winnings, less the
5% rake, so +228 net), while a lost Full House took the stake and nothing else.
A thirteen-card hand wraps to three rows at 390px with no sideways overflow and
nothing colliding. Console silent.
- **Three bugs, and the first one was the whole table.**
1. **Every visit to `/games/uno` was a 500.** The handler was wired, the route
was in `casinoRoutes()`, the template was written — and `uno` was never added
to the list of pages `server.go` parses into the games template set, so
`render()` answered "unknown page". No Go test saw it because the casino tests
all call the handlers *directly* and never go through `render()`. There is now
a test that does: `TestEveryCasinoPageRenders` walks the mux, asks for every
page the casino routes to, and fails on a 500 or a half-rendered body. **Add a
game, add it there.**
2. **The wrong card left your hand.** The play script hid `.pete-uno-card[data-on="1"]`
— the *first* card that lit up, not the one you clicked — so playing any other
playable card made an innocent one vanish while the card you played sat there
and a copy of it flew to the discard. It self-corrected on the re-render, which
is why it read as a flicker rather than a bug. The index you played is now kept
(`played`) and that card is the one lifted out.
3. **On a phone the card in play sat on top of the colour in play.** The mobile
query shrank `.pete-uno-discard`'s *box* with a raw height and width, but the
card inside it is a `.pete-uno-card` and takes its size from `--uno-h`/`--uno-w`,
which the discard never set — so a full-size card hung out of a small hole and
covered the RED/BLUE pill under it. The vars go on the discard now. Worth
remembering as a rule: **size a card by its vars, never by the box you put it
in.**
- **Hold'em, and it is a cash game.** *(2026-07-14. Built, tested, and driven in a
browser. The bots had to be retrained from scratch — see below, it is the whole
story of this phase.)*
- **You buy in, you play, you leave with what's in front of you.** This is the
only table in the casino that is a *session* rather than a game. Everywhere else
stakes once and pays a multiple; poker isn't that shape. So the live row lives
across hands, and chips cross the border exactly twice: once when you sit down
and once when you get up. In between every pot is inside the engine and storage
sees none of it. Three stakes (1/2, 5/10, 25/50), buy in for 20100 big blinds,
top up between hands, bust and the session simply ends.
- **The rake is a real cardroom's rake**: five percent of a pot that *sees a flop*,
capped at three big blinds. No flop, no drop — so folding your blind round after
round costs you the blinds and no fee. Still winnings-only in the sense that
matters: you pay it out of a pot you win, never out of a bet you lose.
- **The bots move inside ApplyMove**, as UNO's do, which is what keeps poker off a
socket. One request plays your action, every bot action behind it, and whatever
streets that finishes — so shoving all-in returns the flop, turn, river, showdown
and payout in a single response, as a script the felt plays back.
- **The CFR policy was a lie, twice over, and this is the part worth reading.**
§5 called it "the single highest-value asset in either repository". It was not
being used *at all*, and could not have been:
1. **The key never matched.** The trainer packed a single "am I last to act" bit
and wrote its keys as `IP`/`OOP`. The runtime looked them up with the labels a
player would recognise — `BTN`, `SB`, `BB`, `UTG`. Not one key ever hit, for
the entire life of the game in gogobee. Nothing looked broken: a policy miss is
not an error, it is a silent fall back to a pot-odds heuristic. The bots played;
they just never once read the five million iterations sitting in policy.gob.
2. **And it was the wrong game anyway.** `TrainCFR` opens with `stack0, stack1 = 20,
20` at 1/2 blinds — a **ten big blind** push-fold stack. 82% of its nodes are in
the "stack smaller than the pot" bucket. A 20100BB cash game lands almost
nowhere near it. Worse, the tree it trained on was not hold'em: a call always
ended the street (so no big-blind option and no check-check), turn order was
history-length parity, and the payoff was ±half the pot regardless of who had
put what in.
- **So the trainer was rewritten to play the real engine.** `internal/games/holdem/
train.go` + `cmd/holdem-train`. External-sampling MCCFR, every move applied through
`Step` — the same reducer the felt calls — so the blinds, the min-raise, street
completion, side pots and the money are the ones a player actually meets. The
stack depth is drawn fresh every hand across the whole 20100BB range, because
poker at twenty big blinds and poker at a hundred are different games and a bot
that only knows one folds into the other.
- **The key is built by one function, `State.spot`, called by both the trainer and
the table.** That is the entire fix for bug (1), and it is structural: they cannot
drift apart because there is only one of them. And because a miss is *still* silent,
`Hits`/`Misses` are counted at the point a bot looks itself up, and
`TestTheBotsAreActuallyTrained` fails if the heads-up hit rate drops under 60%. It
is 95%. Multiway degrades on purpose — the policy is heads-up, six-handed reuses it
as a documented approximation, and the rest falls through to pot odds.
- **Three money bugs, and the tests earned their keep.** `TestChipsAreConserved`
plays a hundred sessions of real hands and counts every chip after every move; it
caught an **uncalled bet that minted chips** (the rule skipped folded players when
working out what had been matched, so a river bet folded to came back *whole*,
including the part called on the flop). A Go var-init ordering trap made `deck52`
build from an empty conversion table, so every card was identical, every showdown
tied and **every bot believed it held exactly 50% equity** — package-level vars are
built before `init()` runs. And the browser found the third: **the rake was
silently zero**, because the tiers declared `RakePct: 5` meaning percent while
`New()` overwrites it with blackjack's `0.05` meaning a fraction, and the integer
arithmetic floored 5% of a hundredth of the pot to nothing. Every rake test built
its own `State` by hand and so never saw the number the table runs on. There is now
one that does.
- **Driven in a browser, 2026-07-14, and it holds up.** Sitting down took 500 off a
5,000 stack and put it on the table; a hand played out; getting up put 1,004 back
(money conserved to the chip). The raise slider, the pot/half-pot/max presets, a
shove that runs the whole board out in one response, and a reload mid-hand that
brings back the hand, the board, the pot, the street and the stack — all clean.
**Side pots and split pots balance**: a three-way all-in paid 758 + 757 + 920 out
of a 2,495 pot with 60 raked, and `paid + rake == pot` on every multi-winner hand
sampled. A bot's cards never cross the wire until a showdown, and a folded seat's
never do at all. Six-handed at 390px: no sideways overflow, nothing colliding.
Console silent.
- Two felt bugs only the browser could show. **`.pete-stack` is `position: absolute;
inset: 0`** — so the pot's chips, sharing a box with the number under them, painted
straight over it: the pot showed a chip and no total. The pile needs a box of its
own. And **a `.pete-spot` is 7rem across** because blackjack has exactly one of
them; six of those is most of a felt, so a seat's bet spot is less than half the
size, with chips scaled to match. The bet total hangs *below* the ring
(`.pete-spot-total`), which is the existing rule for exactly this reason.
### Pick this up here — a 20M-hand policy is still training
The `policy.gob` on main is a **300,000-hand run** — a placeholder. It works (95%
heads-up hit rate, and the bots play a real game off it), but it is thin: 4,129
nodes. A **20,000,000-hand run** was started on millenia on 2026-07-14 and needs
collecting:
```sh
ssh reala@192.168.1.212 'tail -2 ~/pete-train/train.log' # is it done?
scp reala@192.168.1.212:~/pete-train/policy-new.gob internal/games/holdem/policy.gob
go test ./internal/games/holdem/ -run TestTheBotsAreActuallyTrained -v # hit rate must hold ≥60%
```
Then re-drive the table in a browser and commit it. If the run was lost, just do it
again — it is one command and about an hour:
```sh
rsync -az --delete --exclude .git --exclude node_modules ./ reala@192.168.1.212:~/pete-train/
ssh reala@192.168.1.212 'cd ~/pete-train && go build ./cmd/holdem-train && \
nohup ./holdem-train -iterations 20000000 -workers 30 -out ~/pete-train/policy-new.gob \
> ~/pete-train/train.log 2>&1 &'
```
millenia (`reala@192.168.1.212`) has 32 cores and does ~250k hands a minute. The
local box does ~110k. Nothing about the *code* is waiting on this — the policy is a
data file, and a better one only makes the bots harder.
### Next, in order
1. **Deploy.** Hangman, solitaire, trivia, UNO and hold'em are all played and all
five are sitting on main un-deployed — the live casino is blackjack and nothing
else. The server runs `StartTriviaBank`, so trivia's bank fills itself once the
binary is out there, but the first player to try a ladder in the first minute
after a deploy gets the 503.
2. **No Mercy UNO.** The plan's header line has always promised "UNO (normal +
no-mercy)" and only normal was ever built. gogobee has the rules
(`uno_nomercy.go`: a 168-card deck, draw-stacking, elimination at 25 cards,
sudden-death point scoring). It is a *rules dial orthogonal to the table-size
tier*, so the lobby becomes 3 sizes × 2 rule sets — and **the multiples have to be
re-measured**, because the current 2.2×/2.9×/3.6× are priced off a measured
go-out-first rate (43/32/27%) that draw-stacking and mercy elimination change
completely. Shipping No Mercy on the regular tier's prices would misprice it.
Still open on hold'em, none of it blocking: the policy is **heads-up**, so a
six-handed table is an approximation of it (the hit rate falls from 95% to about 17%
at six seats, and the rest is pot odds) — a multiway policy would want its own
training run with more than two seats in the tree. Blackjack still has no **split**.
### How the browser half fits together
- `GET /games` (lobby), `GET /games/blackjack` (table) — signed-in only. On the games
host, the mux prefixes `/games` onto the path, so the lobby is that host's `/`. Shared
paths (`/api/`, `/auth/`, `/static/`) mean the same thing on every host and are left
alone.
- `GET /api/games/table`, `POST /api/games/{buyin,cashout}`,
`POST /api/games/blackjack/{deal,move}` — session-authed, JSON, all returning the same
`tableView` so the money and the felt can never disagree.
- **The browser never sees the shoe.** The dealer's hole card is *absent* from the
payload — not flagged hidden — until the reveal, and the deck lives only in
`game_live_hands`. The response carries the engine's events (one per card off the
shoe), which is what the table plays back as an animation.
- Money order-of-operations: stake leaves the stack *before* the hand is dealt, in the
same statement that checks it's there; the hand is *seated* (a plain INSERT on the
primary key) before it can settle, which is what makes a double-clicked Deal a 409 with
the stake refunded rather than a silently overwritten hand.
### Notes for whoever picks this up
- SQLite runs at `MaxOpenConns(1)` in *both* repos. Any `db.Get().Exec` inside an
open transaction deadlocks against itself. Do the pre-work before `Begin`.
- **A buy-in can currently take a player into debt.** `DebitIdem` inherits
`BLACKJACK_DEBT_LIMIT` (default 1000), so someone with an empty wallet can buy
€1,000 of chips, win, and cash out while still €1,000 down. That is exactly what
gogobee's Matrix blackjack already allows, so it is consistent rather than a bug —
but a web casino runs far more hands, and this is the knob to turn if the economy
starts leaking. A buy-in-specific floor of 0 is a two-line change.
- gogobee's blackjack taxes 5% of the *gross* payout into a community pot
(`communityTax`). Pete's rake takes 5% of the *profit*. Deliberately different, and
gentler; don't "fix" one to match the other without deciding which is right.
---
## 1. The three constraints everything else follows from
**gogobee owns the euros.** The ledger is `euro_balances` / `euro_transactions`
(gogobee `internal/db/db.go:1316,1324`), tied to the wider economy — adventure, shop,
lottery, mischief. Pete does not get a second wallet. Pete never writes a balance.
**gogobee has no inbound API and isn't getting one.** The only listening socket in the
whole repo is the Matrix appservice transaction endpoint (`internal/bot/appservice.go:255`).
Pete's own source says it plainly (`internal/web/roster.go:23`):
> Direction of travel is gogobee → Pete ... Pete has no route back into the game box's
> network and we are not opening one.
So gogobee stays the only initiator. It **polls** Pete for work and **pushes** results
back through the existing durable queue. Same as mischief (`gogobee_mischief_plan.md:191-197`).
**One binary.** Games live in the Pete process. gogobee already runs ~50 plugins and six
games in one process with in-memory table state and it's fine. Caddy points
`games.parodia.dev` at the same port; the mux branches on Host.
---
## 2. Identity — free, no link codes
MAS imports the Authentik `preferred_username` as the Matrix localpart
(`gogobee_mischief_plan.md:176-186`). So an Authentik session on Pete maps to a Matrix
user deterministically:
```
OIDC preferred_username -> strings.ToLower(u) -> @<u>:parodia.dev
```
Pete's `SessionUser` (`internal/web/auth.go`) carries `Sub`/`Name`/`Email` today. Add
`PreferredUsername` to the claims struct and the signed cookie payload. That is the whole
identity story.
Note the existing precedent: `email_nag.go:52` already asserts "Authentik usernames ==
Matrix localparts".
---
## 3. Money — session escrow, not per-hand settlement
### Why not settle each hand
Mischief is fire-and-forget: place an order, gogobee claims it within 30s, nobody cares.
A blackjack hand cannot work that way. If every bet round-trips through a poll loop you
wait half a minute to be dealt, and again for the payout.
### The model
Borrow the semantics hold'em already uses — `euro.Debit(..., "holdem_buyin")`
(`holdem.go:319`), `euro.Credit(..., "holdem_cashout")` (`holdem.go:371`) — and apply it
to the whole web casino:
1. **Buy in.** You convert euros to *chips* for a games session. One debit. Tolerates poll latency.
2. **Play.** Blackjack, UNO, hold'em, all at full speed against chips held in Pete's SQLite.
Zero economy calls in the hot path.
3. **Cash out.** Chips convert back to euros. One credit. Tolerates the same latency.
Two economy touches per *session* instead of two per *hand*. Poll latency stops mattering.
### The invariant
> A euro is either in gogobee's `euro_balances` or in Pete's chip escrow. Never both.
> It moves between them only via a GUID-idempotent claim.
Pete's balance display is advisory only, sourced from the roster push and up to 2 minutes
stale. The authoritative check is `euro.Debit` at claim time. This preserves
`gogobee_mischief_plan.md:198-202` — *"Pete never writes a balance, so no double-spend surface."*
### The prerequisite: euro idempotency (BLOCKING)
`euro_transactions` (`db.go:1324`) has **no external id and no unique constraint**. `Debit`
is an atomic conditional UPDATE, but calling it twice debits twice. That is safe today only
because every caller is a Matrix message that arrives once. A *retrying poll loop* breaks
that: a claim that succeeds but whose ack is lost on the wire gets retried, and the player
pays twice.
**Before any of this ships:**
```sql
ALTER TABLE euro_transactions ADD COLUMN external_id TEXT;
CREATE UNIQUE INDEX idx_euro_tx_external ON euro_transactions(external_id)
WHERE external_id IS NOT NULL;
```
plus `CreditIdem(userID, amount, reason, externalID)` / `DebitIdem(...)` in `euro.go` that
do the balance mutation and the transaction insert **in one tx**, and treat a unique-violation
on `external_id` as success-already-applied. Everything web-initiated goes through these.
---
## 4. The wire protocol
All new Pete endpoints are bearer-authed with the existing ingest token
(`internal/web/adventure.go:307` `bearerOK`). gogobee grows its first GET path in
`internal/peteclient/client.go` — the poll loop the mischief plan already calls for.
### Pete serves (gogobee polls, ~3s interval)
```
GET /api/games/escrow/pending -> [{guid, matrix_user, kind: buyin|cashout, amount}]
POST /api/games/escrow/claim <- {guid} idempotent, marks claimed
```
### gogobee pushes (existing peteclient queue, guid-idempotent)
```
POST /api/games/escrow/settled -> {guid, ok: bool, reason?: "insufficient_funds", balance_after}
```
Reuse `pete_emit_queue` (`client.go:121-125`, `INSERT OR IGNORE` on `guid` PK) — it already
does durability, backoff and parking. Don't build a second queue.
### State machine (Pete side, table `game_escrow`)
```
requested -> claimed -> funded (buyin ok; chips become spendable)
-> rejected (insufficient funds; nothing spendable)
requested -> claimed -> settled (cashout ok; chips destroyed, euros credited)
```
Poll interval 3s, not 30s: a player waiting to be dealt is watching a spinner. 3s of
"buying chips…" is acceptable; 30s is not.
### The reaper
Chips left in an abandoned session are euros in limbo. Auto-cash-out any session idle for
30 minutes. A crashed Pete must reconcile on boot: any `claimed` escrow with no `settled`
push gets re-polled by GUID.
---
## 5. Code reuse — copy, don't share
Separate modules, both mine, and the shells diverge (Matrix vs HTTP). Copy the pure cores
into `pete/internal/games/`, let them drift, no shared module.
### Verdict per game
| Game | Copy | Rewrite | Notes |
|---|---|---|---|
| **Hold'em** | ~1,400 LOC | the shell + **the whole CFR trainer** | See the warning below. |
| **UNO** | ~1,400 LOC | the turn engine | Great primitives, unshippable engine. |
| **Hangman** | ~250 LOC | loading/persistence | Clean rune-safe state machine. |
| **Blackjack** | ~95 LOC | everything else | 95 lines is the entire core. |
| **Trivia** | ~80 LOC | everything else | **No question bank exists.** |
### Hold'em — take the poker, not the brain
> **This section was wrong, and it cost most of Phase 4. Read §0's hold'em entry before
> you believe any of it.** `data/policy.gob` is **not** "the single highest-value asset in
> either repo". It is a 10-big-blind push-fold policy, trained against a model of poker
> that is not poker (a call always ends the street), and *it was never once read* — the
> trainer wrote its keys under `IP`/`OOP` and the runtime looked them up under
> `BTN`/`SB`/`BB`, so every lookup in the history of the game missed and fell through to a
> pot-odds heuristic. Nothing about that is visible from the outside: a policy miss is not
> an error. **Retrain against your own engine.** Pete now does — `internal/games/holdem/
> train.go` plays every move through the real reducer, and both the trainer and the table
> build the info-set key with the same function so they cannot drift apart again.
Already mautrix-free, verified by import check:
- `holdem_cfr.go` (1,285) — CFR trainer + NPC policy runtime, info-set packing into a
`uint64`, regret pruning, board-texture/SPR/equity bucketing. The *bucketing* is worth
taking (equity, SPR, board texture). The trainer, the tree and the trained policy are
not: see the warning above.
- `holdem_equity.go` + `holdem_equity_range.go` (548) — Monte-Carlo equity, equity-vs-range,
draw/out detection. 100% pure, well tested.
- `holdem_betting.go` (383) — side pots, min-raise, all-in, street completion. The fiddly
poker rules you do not want to rewrite. **Untested in gogobee — write tests as you port.**
- `holdem_game.go`, `holdem_eval.go`, `holdem_render.go`.
Entanglements to break (mechanical):
1. `id.UserID``PlayerID string` (`holdem_betting.go:283,314`; `holdem_eval.go` winnings maps).
2. Delete `RoomID`/`DMRoomID` from `HoldemGame` — table identity belongs to the shell.
3. Hoist the four `*time.Timer` fields out of `HoldemGame` (`holdem_game.go:92-95`).
4. `LoadPolicy(path)` does `os.Open``LoadPolicyFrom(io.Reader)`, so the policy can be `embed.FS`'d.
Hand evaluation is **not** homegrown — `holdem_eval.go:12` wraps `poker.Evaluate`. Just take
the `github.com/chehsunliu/poker` dependency.
### UNO — lift the primitives, rewrite the engine
Copy verbatim (already unit-tested in `uno_test.go`):
- `unoCard`/`unoColor`/`unoValue`, `canPlayOn`, `newUnoDeck`, draw/reshuffle (`uno.go:21-364`)
- The bot AI as free functions: `botPickCard`, `botPickNormal`, `botPickAggressive`,
`botPickColor` (`uno.go:1465-1585`)
- `uno_nomercy.go` is ~90% pure: scoring, stacking rules, no-mercy deck, second bot.
**Rewrite the turn engine.** In gogobee the engine *is* the message sender —
`executeMultiTurn`, `applyAndAnnounce`, `handlePlayerPlay` mutate state and call
`p.SendReply(...)` mid-turn, and their `error` returns mean "send failed", not "illegal move".
There is no `ApplyMove(game, move) (Result, error)` seam anywhere. Disentangling that costs
more than rewriting it against the (good) primitives. One near-seam worth keeping:
`applyCardEffects` (`uno_multi.go:1459`) already returns a struct instead of sending.
### Hangman — take the struct
`hangmanGame` + `guessLetter`/`guessSolution`/`displayPhrase` + the `gallows [7]string` ASCII
art (`hangman.go:26-274`). Strip three fields (`participants`, `solvedBy`, `threadID`). Copy
`hangman_phrases.txt` (237 lines) and `embed` it instead of `os.Getenv("HANGMAN_PHRASE_FILE")`.
Drop the dreamclient translation path for v1.
### Blackjack — retype it
`handValue` (correct soft-ace demotion), `isBlackjack`, and their tests. That's it — 95 lines.
The rest is `bjTable` keyed by `id.RoomID` with timers embedded, and raw `db.Exec` SQL at
`blackjack.go:867`.
### Trivia — the question bank does not exist
`trivia.go:288` fetches from OpenTDB live, one question per round:
```go
apiURL := "https://opentdb.com/api.php?amount=1"
```
Reuse the category map (`trivia.go:24-53`) and `calculateScore` (time-decay, `:536`). For the
web version, **pre-fetch and cache a bank locally** — a per-question HTTP call in a web game
loop is a latency and rate-limit problem gogobee never had to care about at Matrix pace. Route
outbound fetches through Pete's `internal/safehttp` (SSRF guard).
Trivia has **no euro coupling today** (points only). Keep it that way in v1 — it's the one
game that can ship with zero escrow risk.
### Two things that apply to every copied engine
**Thread the RNG.** Every card game uses the `math/rand/v2` package global —
`blackjack.go:60`, `uno.go:186,277`, `holdem_game.go:102`, and throughout the CFR/Monte-Carlo
code. Nothing is seedable, which is why `TestBotPickCard_*` can only assert weak properties.
The adventure half of gogobee already does this right (`dnd_zone_combat.go:361` threads an
explicit `*rand.Rand` via `rand.NewPCG`). The card games never adopted it. **Threading
`rng *rand.Rand` through the deck constructors is mandatory, not optional** — ~20 call sites,
and it's the difference between a testable engine and one you can only smoke-test. It also
gives you a reproducible shuffle for dispute resolution.
**Hoist the timers.** `bjTable.joinTimer/turnTimer/reminderTimers`, `unoGame.idleTimer/warningTimer`,
`HoldemGame.actionTimer/warningTimer/idleTimer/idleWarningTimer` — all live inside the game
structs today. Timers are a shell concern. Game state must be a plain value you can serialize,
which is also what makes restart-mid-hand survivable.
### Build a `cards` package while you're at it
There is **no shared cards package in gogobee** — blackjack has its own deck
(`blackjack.go:32-75`), UNO has its own (`uno.go:130-189`), hold'em uses the third-party lib.
Consolidate into `pete/internal/games/cards` during the port rather than importing the
duplication.
---
## 6. Architecture in Pete
```
internal/games/
cards/ shared deck primitives (new; consolidates gogobee's duplicates)
blackjack/ pure engine — ApplyMove(state, move) (state, events, error)
holdem/ pure engine + cfr/ (copied) + policy.gob (embedded)
uno/ pure engine (rewritten) over copied primitives + bots
hangman/ pure engine (copied) + phrases.txt (embedded)
trivia/ pure engine (new) + cached question bank
escrow/ chip ledger, the gogobee poll/push seam
table/ session, seating, turn clocks, reconnect — the shell
```
**Server-authoritative, always.** The browser sends intents and never sees the deck. Any
game with money attached cannot trust a client-reported result. This is why the engines have
to be Go on Pete's side rather than ported to JS.
**Every engine is a pure reducer**: `ApplyMove(state, move) (newState, []Event, error)`.
Timers, sockets and persistence all live in `table/`. That's the seam gogobee never had, and
it's what buys testability, replay, and surviving a redeploy.
### Transport
- **Blackjack, Hangman, Trivia, UNO-solo** — request/response over `fetch`. No sockets.
- **Hold'em, UNO-multi** — WebSocket. Lobby, seating, presence, turn clocks, reconnect-mid-hand,
spectators. This is the bulk of the total effort, and it is the only genuinely new
infrastructure in the project.
### Frontend
Pete has **no SPA and no bundler** today — server-rendered `html/template` + `embed.FS`, plain
`<script defer>` tags, npm present only to run the Tailwind CLI. games.parodia.dev is the first
real client-side app in the repo.
Precedent says this is survivable: `weather-gl.js` is 1,028 lines of hand-written WebGL2 with
no build step. Do the same here — vanilla JS per game, no framework, no bundler, Tailwind for
layout. Revisit only if it actually hurts.
### Auth
Session cookie is host-only today — `auth.go:151` sets `Path` but no `Domain`, so a
`news.parodia.dev` session will not travel to `games.parodia.dev`. Set `Domain: ".parodia.dev"`.
Note this widens the cookie to every parodia.dev host including the landing site — a deliberate
loosening, fine here, but not a freebie. Add the `games.parodia.dev` redirect URI to the `pete`
app in Authentik.
Games require login. No anonymous play — there's money in it.
---
## 7. Ship order
**Phase 0 — euro idempotency (gogobee).** `external_id` column + unique index +
`CreditIdem`/`DebitIdem`. Blocking; nothing else is safe without it.
**Phase 1 — escrow + Blackjack.** The full money loop against the simplest possible game
(95 lines of logic). Buy in, play, cash out. This proves cross-subdomain auth, the identity
mapping, the poll loop, the escrow state machine, the reaper, and the frontend shape — all
against a game where the *game* cannot be what's broken.
**Phase 2 — Trivia + Hangman.** No escrow (trivia has no euro coupling; keep hangman's
collaborative credit out of v1). Pure frontend and content work. Cheap wins, and they make the
site feel like a place rather than a demo.
**Phase 3 — UNO.** Solo first (single-player vs bot, no sockets). Then multi, which is where
the WebSocket infrastructure gets built. Forgiving latency, simple turn model — the right place
to learn multiplayer.
**Phase 4 — Hold'em.** Last. It's the hardest engine (side pots, all-ins, split pots), the
biggest port, and the one where collusion is a real threat rather than a theoretical one. Do it
when the multiplayer plumbing has already survived contact with real players.
---
## 8. Risks
**Economy inflation.** A web casino runs orders of magnitude more hands per hour than
Matrix-paced games ever did. Whatever the house edge is, it now compounds far faster in both
directions. Before Phase 1 ships, decide: session buy-in caps, a daily net-win/loss ceiling, or
a rake. This is the risk most likely to be discovered too late.
**Restart mid-hand.** Game state is in memory, so a Pete redeploy kills live tables — the same
property gogobee has today, and it redeploys far less often than Pete does. Mitigate with
serializable state (which the pure-reducer design gives for free) plus a drain-before-restart,
not a second process.
**Collusion in hold'em.** Two browsers, one person, one table. Not solvable in v1; at minimum
log seat/IP/session overlap so it's *detectable* after the fact.
**The gogobee contract is cross-repo.** `roster_test.go` already guards it: an unknown
`event_type` is a 400 that makes gogobee's sender park the row. Add the same guard on the new
escrow endpoints, and keep the payload structs in step across both repos by hand — that's the
cost of copying instead of sharing, and it's the right trade here.
---
## 9. Open questions
- **Chips 1:1 with euros, or a separate denomination?** 1:1 is simpler and honest. A separate
denomination gives you a knob for the inflation problem.
- **Do web results feed the Matrix room?** Pete already has a priority poster
(`adventure.go:151`). "Reala just took a 12k pot" is a good bulletin, and this is nearly free.
- **NPC opponents at the web tables?** The CFR bot is right there and it's good. It also means
a table never sits empty, which matters a lot for a small community.