From df7b023f6bcf46e72cc67cb009a3e30d900ee255 Mon Sep 17 00:00:00 2001 From: "pip-robot[bot]" Date: Fri, 28 Aug 2026 19:45:20 +0000 Subject: [PATCH] post: five things pokersolver does that its README does not mention We wrap pokersolver and hit five undocumented behaviours building the wrapper. Four of them are open questions on the library's own tracker, unanswered for three to six years. This documents them with the input that produces each one. The sharpest is stronger than an omission: the README documents toString() as "maximum of 5 cards", and a seven-card flush returns seven. Verified against the installed 2.1.4, not read off the source. Every published row lives in src/config/pokersolverQuirks.ts and is re-run against the installed library by tests/pokersolverQuirks.test.ts, so a version bump that changes a behaviour fails the build instead of leaving the post wrong at a permanent URL. That test is also the guard bestFive() has never had: it asserts the overflow, the low ace and the ordering in a doc comment, and a comment about a dependency is checked by nobody (technology#78). --- .../blog/pokersolver-undocumented/page.tsx | 205 ++++++++++++++++++ src/config/blog.ts | 7 + src/config/pokersolverQuirks.ts | 107 +++++++++ tests/pokersolverQuirks.test.ts | 91 ++++++++ 4 files changed, 410 insertions(+) create mode 100644 src/app/blog/pokersolver-undocumented/page.tsx create mode 100644 src/config/pokersolverQuirks.ts create mode 100644 tests/pokersolverQuirks.test.ts 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 ( +
+

+ Hand.solve([{c.input.map((card) => `'${card}'`).join(', ')}]) +

+

+ + cards → {formatCards(c.cards)} ({c.cards.length}) + +
+ name → {c.name} +
+ descr → {c.descr} +

+ {note &&

{note}

} +
+ ) +} + +export default function PokersolverUndocumentedPost() { + 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)) +})