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