// What beats what. // // This is the one module in the tool that can pay the wrong person without anybody // noticing. A pot wizard that asks "who won?" is only ever as wrong as the dealer // ticking the box; one that answers the question itself is wrong on its own account, // silently, and a table that has come to trust it will not check. So the ordering is // tested from both ends: every category against every other, the traps that a // hand-rolled evaluator gets wrong (the wheel, the counterfeited kicker, the board // that plays), and then a lot of random seven-card hands against a second // implementation written a different way. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); const { HandRank } = require("./load"); const best = (...cards) => HandRank.best(cards.flat()); const beats = (a, b) => HandRank.compare(a, b) > 0; const chops = (a, b) => HandRank.compare(a, b) === 0; // A hand written as one string, for the tests that list a lot of them. const hand = str => str.trim().split(/\s+/); describe("the five-card hand", () => { const of = str => HandRank.evaluate5(hand(str)); test("each category is recognised", () => { const cases = [ ["AS KS QS JS TS", HandRank.STRAIGHT_FLUSH, "Royal flush"], ["9H 8H 7H 6H 5H", HandRank.STRAIGHT_FLUSH, "Straight flush, nine high"], ["7C 7D 7H 7S 2C", HandRank.FOUR_OF_A_KIND, "Four of a kind, sevens"], ["KC KD KH 3S 3C", HandRank.FULL_HOUSE, "Full house, kings full of threes"], ["AD JD 8D 5D 2D", HandRank.FLUSH, "Flush, ace high"], ["9C 8D 7H 6S 5C", HandRank.STRAIGHT, "Straight, nine high"], ["QC QD QH 7S 2C", HandRank.THREE_OF_A_KIND, "Three of a kind, queens"], ["AC AD 8H 8S 4C", HandRank.TWO_PAIR, "Two pair, aces and eights"], ["QC QD 9H 5S 2C", HandRank.ONE_PAIR, "Pair of queens"], ["AC JD 8H 5S 2C", HandRank.HIGH_CARD, "Ace high"], ]; cases.forEach(([cards, category, described]) => { const ranked = of(cards); assert.equal(ranked.category, category, cards); assert.equal(HandRank.describe(ranked), described, cards); }); }); test("the categories are in the right order", () => { // Listed weakest first, then every pair checked both ways round. The order // itself is the thing being asserted, so it is written out rather than derived // from the constants it is meant to be checking. const ladder = [ "AC JD 8H 5S 2C", // ace high "2C 2D 8H 5S 3C", // a pair of deuces "AC AD 8H 8S 4C", // two pair "3C 3D 3H 7S 2C", // trips "9C 8D 7H 6S 5C", // straight "AD JD 8D 5D 2D", // flush "2C 2D 2H 3S 3C", // full house "7C 7D 7H 7S 2C", // quads "9H 8H 7H 6H 5H", // straight flush ]; for (let low = 0; low < ladder.length; low++) { for (let high = low + 1; high < ladder.length; high++) { assert.ok(beats(of(ladder[high]), of(ladder[low])), `${ladder[high]} should beat ${ladder[low]}`); assert.ok(!beats(of(ladder[low]), of(ladder[high])), `${ladder[low]} should not beat ${ladder[high]}`); } } }); test("the same hand in different suits is a chop", () => { assert.ok(chops(of("AC KD 8H 5S 2C"), of("AS KH 8D 5C 2H"))); assert.ok(chops(of("QC QD 9H 5S 2C"), of("QH QS 9D 5C 2H"))); }); test("nothing but five real cards is a hand", () => { assert.equal(HandRank.evaluate5(hand("AC KD 8H 5S")), null, "four"); assert.equal(HandRank.evaluate5(hand("AC KD 8H 5S 2C 3D")), null, "six"); assert.equal(HandRank.evaluate5(["AC", "KD", "8H", "5S", "ZZ"]), null, "nonsense"); assert.equal(HandRank.evaluate5(["AC", "KD", "8H", "5S", ""]), null, "an empty slot"); assert.equal(HandRank.evaluate5(null), null); }); test("case does not matter, because the picker is not the only way cards get in", () => { assert.ok(chops(of("ac kd 8h 5s 2c"), of("AC KD 8H 5S 2C"))); }); }); describe("the wheel", () => { const of = str => HandRank.evaluate5(hand(str)); // The straight that runs through the ace at the bottom. An evaluator that scores a // straight by its top card and lets the ace keep its usual value makes A-2-3-4-5 // the best straight there is instead of the worst — which pays the wrong player // every time it comes up against a broadway. test("A-2-3-4-5 is a straight", () => { assert.equal(of("AC 2D 3H 4S 5C").category, HandRank.STRAIGHT); assert.equal(HandRank.describe(of("AC 2D 3H 4S 5C")), "Straight, five high"); }); test("and it is the lowest one", () => { assert.ok(beats(of("6C 5D 4H 3S 2C"), of("AC 2D 3H 4S 5C")), "six high beats the wheel"); assert.ok(beats(of("AC KD QH JS TC"), of("AC 2D 3H 4S 5C")), "broadway beats the wheel"); }); test("the same goes for a steel wheel", () => { const steel = of("AH 2H 3H 4H 5H"); assert.equal(steel.category, HandRank.STRAIGHT_FLUSH); assert.equal(HandRank.describe(steel), "Straight flush, five high"); assert.ok(beats(of("6H 5H 4H 3H 2H"), steel)); assert.ok(beats(of("AS KS QS JS TS"), steel)); }); test("an ace with a four-card run either side of it is not a straight", () => { // K-A-2-3-4 does not wrap. assert.equal(of("KC AD 2H 3S 4C").category, HandRank.HIGH_CARD); assert.equal(of("QC KD AH 2S 3C").category, HandRank.HIGH_CARD); }); }); describe("kickers", () => { const of = str => HandRank.evaluate5(hand(str)); test("the fifth card settles two pair", () => { assert.ok(beats(of("AC AD 8H 8S KC"), of("AH AS 8D 8C QC"))); assert.ok(chops(of("AC AD 8H 8S KC"), of("AH AS 8D 8C KD"))); }); test("and a pair, three kickers deep", () => { assert.ok(beats(of("9C 9D AH 7S 2C"), of("9H 9S KD 7C 2D")), "first kicker"); assert.ok(beats(of("9C 9D AH 7S 2C"), of("9H 9S AD 6C 2D")), "second"); assert.ok(beats(of("9C 9D AH 7S 3C"), of("9H 9S AD 7C 2D")), "third"); }); test("the higher pair beats the better kicker", () => { assert.ok(beats(of("TC TD 3H 2S 4C"), of("9C 9D AH KS QC"))); }); test("two pair is read by its higher pair first", () => { assert.ok(beats(of("KC KD 2H 2S 3C"), of("QC QD JH JS AC"))); }); test("a full house is read by its trips first", () => { assert.ok(beats(of("3C 3D 3H 2S 2C"), of("2D 2H 2C AS AD"))); }); test("a flush is read all the way down", () => { assert.ok(beats(of("AD KD 8D 5D 3D"), of("AH KH 8H 5H 2H"))); assert.ok(chops(of("AD KD 8D 5D 3D"), of("AH KH 8H 5H 3H"))); }); }); describe("the best five out of seven", () => { test("it finds the flush the two hole cards do not both belong to", () => { const ranked = best(["AH", "2C"], ["KH", "QH", "7H", "3H", "9S"]); assert.equal(ranked.category, HandRank.FLUSH); assert.equal(HandRank.describe(ranked), "Flush, ace high"); }); test("it plays the board when the board is better than anything you hold", () => { // Nobody can beat a royal on the board; both hands are the board itself. const board = ["AS", "KS", "QS", "JS", "TS"]; const one = best(["2C", "3D"], board); const two = best(["7H", "8H"], board); assert.equal(HandRank.describe(one), "Royal flush"); assert.ok(chops(one, two), "the board plays, so it is a chop"); }); test("it does not use three hole cards, because there are only two", () => { const ranked = best(["AC", "AD"], ["AH", "KS", "KD", "2C", "3H"]); assert.equal(HandRank.describe(ranked), "Full house, aces full of kings"); }); test("a counterfeited two pair falls back on the board", () => { // Holding 6-5 on a board of 8-8-K-K-A: the player's two pair is counterfeited — // the best five are the board's two pair with the ace, and the sixes never play. const ranked = best(["6C", "5D"], ["8H", "8S", "KC", "KD", "AC"]); assert.equal(HandRank.describe(ranked), "Two pair, kings and eights"); assert.deepEqual([...ranked.cards].sort(), ["8H", "8S", "AC", "KC", "KD"].sort()); }); test("five, six and seven cards are all rankable; four is not", () => { assert.ok(best(["AC", "KD"], ["QH", "JS", "TC"]), "five"); assert.ok(best(["AC", "KD"], ["QH", "JS", "TC", "2D"]), "six"); assert.ok(best(["AC", "KD"], ["QH", "JS", "TC", "2D", "3H"]), "seven"); assert.equal(best(["AC", "KD"], ["QH", "JS"]), null, "four"); }); test("the same card twice is not a hand", () => { // A mis-entry, not a monster. Ranking it would mean ranking a deck that does // not exist, and the answer would look exactly as confident as a real one. assert.equal(best(["AC", "AC"], ["KH", "QS", "JD", "2C", "3H"]), null); assert.equal(best(["AC", "KD"], ["KD", "QS", "JD", "2C", "3H"]), null); }); }); describe("the showdown", () => { const board = ["AH", "KD", "9C", "4S", "2H"]; test("it names one winner and says how it read every hand", () => { const result = HandRank.showdown(board, [ { id: "a", cards: ["AS", "KC"] }, // two pair, aces and kings { id: "b", cards: ["9H", "9S"] }, // trips { id: "c", cards: ["AC", "3D"] }, // a pair of aces ]); assert.equal(result.complete, true); assert.deepEqual(result.winners, ["b"]); assert.deepEqual(result.players.map(p => p.place), [2, 1, 3]); assert.deepEqual(result.players.map(p => HandRank.describe(p.hand)), [ "Two pair, aces and kings", "Three of a kind, nines", "Pair of aces", ]); }); test("a chop comes back as two winners on the same place", () => { const result = HandRank.showdown(board, [ { id: "a", cards: ["AS", "QC"] }, { id: "b", cards: ["AD", "QH"] }, { id: "c", cards: ["7H", "5S"] }, ]); assert.deepEqual(result.winners.sort(), ["a", "b"]); assert.deepEqual(result.players.map(p => p.place), [1, 1, 3], "and the player behind them is third, not second"); }); test("an incomplete board is not a showdown", () => { const result = HandRank.showdown(["AH", "KD", "9C"], [ { id: "a", cards: ["AS", "KC"] }, { id: "b", cards: ["9H", "9S"] }, ]); assert.equal(result.boardComplete, false); assert.equal(result.complete, false); assert.deepEqual(result.winners, [], "it must not name one"); }); test("one unknown hand and it will not say who won", () => { // Who is *ahead* and who *won* are different claims, and the second one is the // only one that can pay somebody. A player who never turned their cards over // could have anything. const result = HandRank.showdown(board, [ { id: "a", cards: ["AS", "KC"] }, { id: "b", cards: ["", ""] }, ]); assert.equal(result.complete, false); assert.deepEqual(result.winners, []); assert.deepEqual(result.unknown, ["b"]); assert.ok(result.players[0].hand, "but it still reads the hands it can"); assert.equal(result.players[1].hand, null); }); test("a card in two places stops it dead", () => { const result = HandRank.showdown(board, [ { id: "a", cards: ["AH", "KC"] }, // the ace of hearts is on the board { id: "b", cards: ["9H", "9S"] }, ]); assert.equal(result.duplicated, true); assert.equal(result.complete, false); assert.deepEqual(result.winners, []); }); test("no players at all is not a showdown either", () => { const result = HandRank.showdown(board, []); assert.equal(result.complete, false); assert.deepEqual(result.winners, []); }); }); describe("against a second opinion", () => { // The strongest check available without a poker library: a completely separate // ranking, written the other way round — every hand turned into a sortable string // by counting ranks, with no shared helper between it and the module. Where the two // disagree, one of them is wrong and the test says which hand it was. const VALUE = { T: 10, J: 11, Q: 12, K: 13, A: 14 }; const val = card => VALUE[card[0]] || Number(card[0]); function reference5(cards) { const ranks = cards.map(val).sort((x, y) => y - x); const suited = new Set(cards.map(c => c[1])).size === 1; const tally = {}; ranks.forEach(r => { tally[r] = (tally[r] || 0) + 1; }); const bySize = Object.keys(tally) .map(Number) .sort((x, y) => tally[y] - tally[x] || y - x); const sizes = bySize.map(r => tally[r]).join(""); const unique = [...new Set(ranks)]; let run = null; if (unique.length === 5) { if (unique[0] - unique[4] === 4) run = unique[0]; else if (unique.join(",") === "14,5,4,3,2") run = 5; } // [category, ...tiebreak], the same shape, arrived at from the group sizes // rather than from a chain of ifs about shape[0] and shape[1]. if (suited && run !== null) return [8, run]; if (sizes === "41") return [7, ...bySize]; if (sizes === "32") return [6, ...bySize]; if (suited) return [5, ...ranks]; if (run !== null) return [4, run]; if (sizes === "311") return [3, ...bySize]; if (sizes === "221") return [2, ...bySize]; if (sizes === "2111") return [1, ...bySize]; return [0, ...ranks]; } const worse = (a, b) => { for (let i = 0; i < Math.max(a.length, b.length); i++) { if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0); } return false; }; function referenceBest(cards) { let top = null; for (let a = 0; a < 3; a++) for (let b = a + 1; b < 4; b++) for (let c = b + 1; c < 5; c++) for (let d = c + 1; d < 6; d++) for (let e = d + 1; e < 7; e++) { const score = reference5([cards[a], cards[b], cards[c], cards[d], cards[e]]); if (!top || worse(top, score)) top = score; } return top; } // A deck, and a deterministic shuffle — a failure has to be reproducible or it is // just an anecdote about one CI run. const DECK = []; "23456789TJQKA".split("").forEach(r => "SHDC".split("").forEach(s => DECK.push(r + s))); let seed = 20260904; const random = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; test("twenty thousand random seven-card hands rank the same way", () => { const deck = [...DECK]; for (let round = 0; round < 20000; round++) { // Seven off the top of a shuffle of only what it needs. const drawn = []; for (let i = 0; i < 7; i++) { const pick = i + Math.floor(random() * (deck.length - i)); [deck[i], deck[pick]] = [deck[pick], deck[i]]; drawn.push(deck[i]); } const mine = HandRank.best(drawn); const theirs = referenceBest(drawn); assert.deepEqual([mine.category, ...mine.tiebreak], theirs, `disagreed on ${drawn.join(" ")}`); } }); test("and four thousand random pairs of hands are ordered the same way", () => { // The categories agreeing is not the same as the comparison agreeing: this is // the half that pays somebody. const deck = [...DECK]; for (let round = 0; round < 4000; round++) { const drawn = []; for (let i = 0; i < 9; i++) { const pick = i + Math.floor(random() * (deck.length - i)); [deck[i], deck[pick]] = [deck[pick], deck[i]]; drawn.push(deck[i]); } const board = drawn.slice(0, 5); const one = [...board, drawn[5], drawn[6]]; const two = [...board, drawn[7], drawn[8]]; const mine = Math.sign(HandRank.compare(HandRank.best(one), HandRank.best(two))); const a = referenceBest(one), b = referenceBest(two); const theirs = worse(a, b) ? -1 : worse(b, a) ? 1 : 0; assert.equal(mine, theirs, `board ${board.join(" ")}: ${drawn[5]}${drawn[6]} vs ${drawn[7]}${drawn[8]}`); } }); }); describe("saying it in a sentence", () => { // The verdict line reads "Alexandra wins $25 with ___", so the phrase has to carry // its own article. Which article is not something the first letter can answer: it // is "a full house" and "a flush", but "four of a kind" and "two pair" take none, // and nobody has ever said "an ace high". test("every category takes the article a table would give it", () => { const said = str => HandRank.phrase(HandRank.evaluate5(hand(str))); assert.deepEqual([ said("AS KS QS JS TS"), said("9H 8H 7H 6H 5H"), said("7C 7D 7H 7S 2C"), said("KC KD KH 3S 3C"), said("AD JD 8D 5D 2D"), said("9C 8D 7H 6S 5C"), said("QC QD QH 7S 2C"), said("AC AD 8H 8S 4C"), said("QC QD 9H 5S 2C"), said("AC JD 8H 5S 2C"), ], [ "a royal flush", "a straight flush, nine high", "four of a kind, sevens", "a full house, kings full of threes", "a flush, ace high", "a straight, nine high", "three of a kind, queens", "two pair, aces and eights", "a pair of queens", "ace high", ]); }); test("with nothing to say it says something", () => { assert.equal(HandRank.phrase(null), "the best hand"); }); });