diff --git a/src/app/blog/pokersolver-undocumented/page.tsx b/src/app/blog/pokersolver-undocumented/page.tsx
new file mode 100644
index 0000000..7eea7e3
--- /dev/null
+++ b/src/app/blog/pokersolver-undocumented/page.tsx
@@ -0,0 +1,205 @@
+import type { Metadata } from 'next'
+import { A, Item, LegalPage, List, Section } from '@/components/marketing/LegalPage'
+import { BLOG_POSTS, formatPostDate, postMetadata } from '@/config/blog'
+import {
+ formatCards,
+ README_TOSTRING_QUOTE,
+ SOLVER_VERSION,
+ solverCase,
+} from '@/config/pokersolverQuirks'
+
+const post = BLOG_POSTS.find((p) => p.slug === 'pokersolver-undocumented')!
+
+export const metadata: Metadata = postMetadata(post)
+
+/** One worked case: what went in, what came back. */
+function Case({ id, note }: { id: string; note?: string }) {
+ const c = solverCase(id)
+ return (
+
+
+
+ Pip does not have its own hand evaluator. Ranking the best five cards out of seven, with
+ kickers, is a solved problem with sharp edges, so we hand it to{' '}
+ pokersolver and wrap the result.
+ That wrapper is about sixty lines, and writing it meant finding out what the library does
+ when the README stops describing it.
+
+
+ There are five of those. We hit all five. Four of them have been asked about on the
+ library’s own issue tracker and left unanswered for between three and six years, so
+ as far as we can tell there is nowhere to look them up. This is that place. Everything
+ below was produced by running pokersolver@{SOLVER_VERSION}, and a test in our
+ repository re-runs every case on every build, so if the library changes this page fails
+ before it lies.
+
+
+ None of this is a complaint. The library is good, it is free, it is doing the hard part,
+ and it has not needed a release in years, which is usually a compliment.
+
+
+
+
+
+ The obvious reading of hand.cards is that it holds the five cards that make
+ the hand. It holds every card that qualified. Six hearts in, six hearts back.
+
+
+ Seven hearts in, seven back.
+
+
+ This is the one place the README is not merely quiet but wrong, and it is wrong about the
+ string rather than the array. It documents toString() as:
+
+
+ “{README_TOSTRING_QUOTE}”
+
+
+ On the seven-card flush above, toString() returns seven. If you are drawing a
+ board from that string, that is two cards you did not budget for.
+
+
+
+
+
+ A flush overflowing is at least visible in a suit. A full house does it when two different
+ ranks both make trips, which is rare enough that a test suite written from the README will
+ not contain one.
+
+
+
+ The categories that can overflow are flushes and full houses. Straights and straight
+ flushes cannot, because a sixth card in sequence makes a different, higher straight rather
+ than joining the one you have.
+
+
+
+
+
+
+ In a five-high straight the ace is the bottom card, and the library represents that
+ literally: the returned card’s value is the string '1', and
+ its rank is 0, below the deuce.
+
+
+
+ It is the right internal choice and the wrong external one, because{' '}
+ '1' is not a card and any lookup keyed on rank will miss it. If you
+ render the returned cards, you have to map it back. We do, in one line, and it took a
+ wrong-looking table to notice.
+
+
+
+
+
+ This is the behaviour that makes the other two survivable, and it is not written down
+ anywhere, which is a shame because it is the useful one. The array is not sorted by rank.
+ It is the cards that make the hand, in descending order, then the kickers, in descending
+ order. So taking the first five is always correct, even when there are seven.
+
+
+ The case that proves it is a full house whose trips are lower than its pair, because
+ sorting by rank would put the aces first:
+
+
+
+ Our whole handling of the overflow is cards.slice(0, 5) on the strength of
+ that. It has been right in every case we have run, and it is the sort of thing that would
+ break quietly in a minor version, so it now has a test rather than a comment.
+
+
+
+
+
+ name is the category, and a royal flush is not a separate category, so it
+ comes back as a straight flush. Only descr says the words.
+
+
+
+ If you switch on name to pick a celebration, the best hand in poker gets the
+ second-best one’s. Two issues on the tracker are people finding this, in 2019 and in
+ 2021. Both were answered by other users.
+
+
+ Note the ten as well: a ten goes in as T and comes back as 10.
+ Round-tripping a card through the solver does not give you the string you started with.
+
+
+
+
+
+ The most-discussed question on the tracker is how to tell which player won, given{' '}
+ Hand.winners returns hands rather than seats. The accepted answer, and the
+ most-upvoted comment on the repository, is to attach an index to each hand object before
+ passing it in and read it back off the winner.
+
+
+ That works, and it is not necessary. Hand.winners returns the same objects it
+ was given, so a Set of the returned hands answers “did this player
+ win” by identity, with nothing mutated and nothing to keep in sync:
+
+
+ {`const solved = new Map(players.map((p) => [p, Hand.solve(cardsFor(p))]))
+const won = new Set(Hand.winners([...solved.values()]))
+
+const winners = players.filter((p) => won.has(solved.get(p)))`}
+
+
+ That identity guarantee is not in the README either, which is presumably why the hack is
+ the accepted answer. It is the one behaviour here we depend on without being able to see
+ it, so it has a test too.
+
+
+
+
+
+ -
+ It is not a bug report. Four of these are omissions in a README, and the fifth is one
+ sentence about
toString(). The code does something defensible in every
+ case.
+
+ -
+ It is not a fork or a replacement. We use the library, unmodified, at the version named
+ above.
+
+ -
+ It is not exhaustive. It is what a hold’em client hits. The library also deals pai
+ gow, wild cards and five of a kind, none of which we touch, and there may well be more
+ edges in there.
+
+
+
+ If you found this because your flush had six cards in it: yes, that is meant to happen,
+ take the first five, and the ace in your wheel is the one labelled 1.
+
+
+
+ )
+}
diff --git a/src/config/blog.ts b/src/config/blog.ts
index f721c4d..c912dc6 100644
--- a/src/config/blog.ts
+++ b/src/config/blog.ts
@@ -17,6 +17,13 @@ export interface BlogPost {
/** Newest first — the index renders this order as-is. */
export const BLOG_POSTS: BlogPost[] = [
+ {
+ slug: 'pokersolver-undocumented',
+ title: 'Five things pokersolver does that its README does not mention',
+ description:
+ 'Its cards array can hand you seven cards for a five-card hand, an ace playing low comes back with the value 1, and a royal flush is named “Straight Flush”. Each one with the input that produces it.',
+ date: '2026-08-28',
+ },
{
slug: 'what-we-got-wrong',
title: 'Everything we have published that was wrong',
diff --git a/src/config/pokersolverQuirks.ts b/src/config/pokersolverQuirks.ts
new file mode 100644
index 0000000..5a85edf
--- /dev/null
+++ b/src/config/pokersolverQuirks.ts
@@ -0,0 +1,107 @@
+// The worked cases behind /blog/pokersolver-undocumented.
+//
+// Pip's hand evaluation is a thin wrapper over pokersolver (see
+// ../lib/poker/handEval.ts). Five of that library's behaviours are not in its
+// README, and one of them contradicts it. The post documents them, and every
+// row it prints comes from here.
+//
+// The outputs below are typed out by hand on purpose, the same rule as
+// dailyProof.ts: a value read out of the library at render time can never
+// disagree with the library and therefore proves nothing. tests/pokersolverQuirks.test.ts
+// runs each case against the installed pokersolver and fails if a published
+// row stops being true. It is also the guard the wrapper has been missing,
+// since bestFive() asserts three of these in a comment and nothing checked it.
+
+/** The version every case below was produced against. Pinned in package.json as ^2.1.4. */
+export const SOLVER_VERSION = '2.1.4'
+
+export interface SolverCase {
+ /** Stable key, used by the page to pull a case into its prose. */
+ id: string
+ /** The exact array handed to Hand.solve. */
+ input: string[]
+ /** hand.cards, each card's toString(), in the library's own order. */
+ cards: string[]
+ /** hand.name, the category label. */
+ name: string
+ /** hand.descr, the long form. */
+ descr: string
+}
+
+/**
+ * Every case the post prints. Ordered as the post reads, not by interest.
+ *
+ * Seven cards in, because that is a hold'em showdown and it is the shape that
+ * produces the overflow. Three-card and five-card inputs behave too; they just
+ * cannot demonstrate anything here.
+ */
+export const SOLVER_CASES: SolverCase[] = [
+ {
+ id: 'flush-six',
+ input: ['Ah', 'Kh', '9h', '7h', '5h', '3h', '2c'],
+ cards: ['Ah', 'Kh', '9h', '7h', '5h', '3h'],
+ name: 'Flush',
+ descr: 'Flush, Ah High',
+ },
+ {
+ id: 'flush-seven',
+ input: ['Ah', 'Kh', '9h', '7h', '5h', '3h', '2h'],
+ cards: ['Ah', 'Kh', '9h', '7h', '5h', '3h', '2h'],
+ name: 'Flush',
+ descr: 'Flush, Ah High',
+ },
+ {
+ id: 'boat-six',
+ input: ['As', 'Ah', 'Ad', 'Ks', 'Kh', 'Kd', '2c'],
+ cards: ['As', 'Ah', 'Ad', 'Ks', 'Kh', 'Kd'],
+ name: 'Full House',
+ descr: "Full House, A's over K's",
+ },
+ {
+ id: 'wheel',
+ input: ['5h', '4c', '3d', '2s', 'Ah', 'Kd', 'Qc'],
+ cards: ['5h', '4c', '3d', '2s', '1h'],
+ name: 'Straight',
+ descr: 'Straight, 5 High',
+ },
+ {
+ id: 'boat-low-trips',
+ input: ['3s', '3h', '3d', 'As', 'Ah', '7c', '2d'],
+ cards: ['3s', '3h', '3d', 'As', 'Ah'],
+ name: 'Full House',
+ descr: "Full House, 3's over A's",
+ },
+ {
+ id: 'royal',
+ input: ['Ah', 'Kh', 'Qh', 'Jh', 'Th', '9h', '2c'],
+ cards: ['Ah', 'Kh', 'Qh', 'Jh', '10h'],
+ name: 'Straight Flush',
+ descr: 'Royal Flush',
+ },
+ {
+ id: 'straight-flush-long',
+ input: ['9h', '8h', '7h', '6h', '5h', '4h', '2c'],
+ cards: ['9h', '8h', '7h', '6h', '5h'],
+ name: 'Straight Flush',
+ descr: 'Straight Flush, 9h High',
+ },
+]
+
+/** Look a case up by id. Throws rather than returning undefined: a missing id is a typo, not a state. */
+export function solverCase(id: string): SolverCase {
+ const found = SOLVER_CASES.find((c) => c.id === id)
+ if (!found) throw new Error(`no solver case "${id}"`)
+ return found
+}
+
+/**
+ * What the README promises about toString(), quoted so the test can check the
+ * quote is still what the installed package says. This is the only one of the
+ * five that is a contradiction rather than an omission, and the quote is the
+ * whole of the claim.
+ */
+export const README_TOSTRING_QUOTE =
+ 'Returns a formatted string of all cards involved in the identified hand type (maximum of 5 cards).'
+
+/** How the post writes a case's input and output, so prose and test agree on the format. */
+export const formatCards = (cards: string[]) => cards.join(' ')
diff --git a/tests/pokersolverQuirks.test.ts b/tests/pokersolverQuirks.test.ts
new file mode 100644
index 0000000..fd694c4
--- /dev/null
+++ b/tests/pokersolverQuirks.test.ts
@@ -0,0 +1,91 @@
+import { readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import test from 'ava'
+import {
+ README_TOSTRING_QUOTE,
+ SOLVER_CASES,
+ SOLVER_VERSION,
+ solverCase,
+} from '@/config/pokersolverQuirks'
+import { cardFromString, cardToString, type Card } from '@/lib/poker/cards'
+import { bestFive, evaluateHand } from '@/lib/poker/handEval'
+
+// /blog/pokersolver-undocumented publishes seven worked cases and says they are
+// what a named version of somebody else's library does. That is two claims
+// nothing else in this build looks at: the outputs, and the README sentence one
+// of them contradicts.
+//
+// It is also the guard the wrapper never had. bestFive() asserts three of these
+// behaviours in a doc comment (the overflow, the low ace, the ordering) and a
+// comment about a dependency is checked by nobody. A minor version that changed
+// any of them would break the wrapper silently and leave the post wrong at a
+// permanent URL. So the post and the guard are the same file.
+
+const require = createRequire(import.meta.url)
+const { Hand } = require('pokersolver')
+const solverPackage = require('pokersolver/package.json')
+
+const h = (...s: string[]): Card[] => s.map(cardFromString)
+
+for (const c of SOLVER_CASES) {
+ test(`the post's "${c.id}" case is what pokersolver still does`, (t) => {
+ const solved = Hand.solve(c.input)
+ t.deepEqual(
+ solved.cards.map((card: { toString(): string }) => card.toString()),
+ c.cards,
+ `${c.id}: hand.cards changed`,
+ )
+ t.is(solved.name, c.name, `${c.id}: hand.name changed`)
+ t.is(solved.descr, c.descr, `${c.id}: hand.descr changed`)
+ })
+}
+
+test('the post names the version that is actually installed', (t) => {
+ t.is(solverPackage.version, SOLVER_VERSION)
+})
+
+test('the README still promises the five-card maximum the post says it breaks', (t) => {
+ const readme = readFileSync(require.resolve('pokersolver/README.md'), 'utf-8')
+ t.true(
+ readme.includes(README_TOSTRING_QUOTE),
+ 'the README sentence the post quotes is gone: re-read it before the post says it is there',
+ )
+ const solved = Hand.solve(solverCase('flush-seven').input)
+ t.is(
+ solved.toString().split(', ').length,
+ 7,
+ 'toString() no longer overflows, so the post’s one contradiction claim would be false',
+ )
+})
+
+// The three claims bestFive()'s comment makes, each exercised through our own
+// wrapper rather than through the library, because the wrapper is what breaks.
+test('bestFive takes five from an overflowing hand, hand-making cards first', (t) => {
+ const flush = bestFive(evaluateHand(h('Ah', 'Kh'), h('9h', '7h', '5h', '3h', '2c')))
+ t.is(flush.map(cardToString).join(' '), 'Ah Kh 9h 7h 5h')
+
+ const boat = bestFive(evaluateHand(h('As', 'Ah'), h('Ad', 'Ks', 'Kh', 'Kd', '2c')))
+ t.is(boat.map(cardToString).join(' '), 'As Ah Ad Ks Kh')
+
+ // The ordering that would break if the solver sorted the overflow by rank
+ // rather than leaving the hand-making cards first: trips lower than the pair.
+ const lowTrips = bestFive(evaluateHand(h('3s', '3h'), h('3d', 'As', 'Ah', '7c', '2d')))
+ t.is(lowTrips.map(cardToString).join(' '), '3s 3h 3d As Ah')
+})
+
+test('bestFive turns the low ace back into an ace', (t) => {
+ const wheel = bestFive(evaluateHand(h('5h', '4c'), h('3d', '2s', 'Ah', 'Kd', 'Qc')))
+ t.is(wheel.map(cardToString).join(' '), '5h 4c 3d 2s Ah')
+})
+
+// The post says our determineWinners identifies winners by object identity, and
+// that this works because Hand.winners hands back the objects it was given. If
+// that ever stopped being true every showdown in the game would misreport, so it
+// is worth one test on the library directly rather than through a hand.
+test('Hand.winners returns the same objects it was passed', (t) => {
+ const a = Hand.solve(['As', 'Ks', 'Qs', 'Js', 'Ts'])
+ const b = Hand.solve(['Ah', 'Kh', 'Qh', 'Jh', 'Th'])
+ const winners = Hand.winners([a, b])
+ t.is(winners.length, 2)
+ t.true(winners.includes(a) && winners.includes(b))
+})