// Reading the showdown: the picker that takes the cards in, and the pot maths that // pays somebody on the strength of them. // // The evaluator is tested to death next door in handRank.test.js. What is tested here // is the join — the two places where a correct ranking can still pay the wrong player: // a side pot, where the question is asked again of a smaller field, and a card entered // in two places, where the ranking is of a deck that does not exist. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); const { CardPicker, HandRank, HoldemState, PotCalculator, Money, makeTable } = require("./load"); describe("the picker, filling the board", () => { const table = (board = ["", "", "", "", ""]) => { const state = makeTable({ seatCount: 4, stack: 100 }); state.board = board; return state; }; // The sheet's own drawing is the DOM's business; the slot bookkeeping under it is // not, and that is what these ask about. const headless = () => { CardPicker.render = () => {}; CardPicker.close = () => {}; }; const onBoard = (state, cardIndex = 0, limit = 5) => { headless(); CardPicker.state = state; CardPicker.hooks = { onPick: () => {} }; CardPicker.target = "board"; CardPicker.boardLimit = limit; CardPicker.cardIndex = cardIndex; return CardPicker; }; const onSeat = (state, seatIndex, cardIndex = 0) => { headless(); CardPicker.state = state; CardPicker.hooks = { onPick: () => {} }; CardPicker.target = "seat"; CardPicker.seatIndex = seatIndex; CardPicker.cardIndex = cardIndex; return CardPicker; }; test("it only offers the slots that have actually been turned", () => { const state = table(); assert.equal(onBoard(state, 0, 3).slotValues().length, 3, "the flop is three"); assert.equal(onBoard(state, 0, 4).slotValues().length, 4, "the turn makes four"); assert.equal(onBoard(state, 0, 5).slotValues().length, 5); }); test("a card goes into the board, not into a seat", () => { const state = table(); onBoard(state, 1, 3).assign("KD"); assert.deepEqual(state.board, ["", "KD", "", "", ""]); assert.deepEqual(state.seats[0].cards, ["", ""]); }); test("it walks on to the next empty slot, so a flop is three taps", () => { const state = table(); const picker = onBoard(state, 0, 3); picker.assign("KD"); assert.equal(picker.cardIndex, 1); picker.assign("9C"); assert.equal(picker.cardIndex, 2); picker.assign("4S"); assert.deepEqual(state.board.slice(0, 3), ["KD", "9C", "4S"]); }); test("and it does not walk past the slots it was given", () => { // Filling the turn must not run on to the river: that card is still in the deck. const state = table(["KD", "9C", "4S", "", ""]); const picker = onBoard(state, 3, 4); picker.assign("AH"); assert.deepEqual(state.board, ["KD", "9C", "4S", "AH", ""]); }); test("clearing a slot empties that one and nothing else", () => { const state = table(["KD", "9C", "4S", "", ""]); onBoard(state, 1, 3).clearSlot(); assert.deepEqual(state.board, ["KD", "", "4S", "", ""]); }); }); describe("a card cannot be in two places", () => { const table = () => { const state = makeTable({ seatCount: 4, stack: 100 }); state.board = ["KD", "9C", "4S", "", ""]; state.seats[0].cards = ["AH", "AS"]; state.seats[1].cards = ["", ""]; return state; }; const picker = (state, setup) => { CardPicker.state = state; CardPicker.hooks = { onPick: () => {} }; setup(); return CardPicker; }; test("the board's cards are unavailable to a hand", () => { // The half of this that did not exist before: the picker knew about every // player's cards and nothing about the middle of the table, so the king of // diamonds on the flop could be dealt to somebody as well. const state = table(); const used = picker(state, () => { CardPicker.target = "seat"; CardPicker.seatIndex = 1; CardPicker.cardIndex = 0; }).cardsInUse(); assert.equal(used.get("KD"), "the board"); assert.equal(used.get("9C"), "the board"); assert.ok(used.has("AH"), "and the other players' are still in there"); }); test("and a hand's cards are unavailable to the board", () => { const state = table(); const used = picker(state, () => { CardPicker.target = "board"; CardPicker.boardLimit = 5; CardPicker.cardIndex = 3; }).cardsInUse(); assert.ok(used.has("AH"), "the ace in seat 1's hand"); assert.ok(used.has("KD"), "and the rest of the board"); }); test("but the slot being edited is not competing with itself", () => { const state = table(); const used = picker(state, () => { CardPicker.target = "board"; CardPicker.boardLimit = 3; CardPicker.cardIndex = 0; }).cardsInUse(); assert.equal(used.has("KD"), false, "re-picking what is already there is not a duplicate"); assert.equal(used.has("9C"), true); }); test("the same goes for a seat's own slot", () => { const state = table(); const used = picker(state, () => { CardPicker.target = "seat"; CardPicker.seatIndex = 0; CardPicker.cardIndex = 0; }).cardsInUse(); assert.equal(used.has("AH"), false); assert.equal(used.has("AS"), true, "the player's other card still is"); }); }); describe("who wins a side pot", () => { // The case a single ranking of the whole table gets wrong. The best hand takes the // main pot and is then out of the question: the side pot goes to the best of who is // left, which is a different answer, and it is the answer the wizard has to ask for // again each round rather than reading off one list. // // Three players, and the short stack has the best hand of the three. const board = ["KD", "9C", "4S", "AH", "2H"]; const hands = { short: ["9H", "9S"], // trips — the best hand at the table big: ["AS", "KC"], // two pair, aces and kings other: ["AC", "3D"], // a pair of aces }; const entries = () => PotCalculator.buildEntries([ { id: "short", contribution: 20, folded: false }, { id: "big", contribution: 100, folded: false }, { id: "other", contribution: 100, folded: false }, ]); // What the wizard does: read the hands still in for *this* round. const readRound = state => HandRank.showdown(board, PotCalculator.getContenders(state).map(c => ({ id: c.id, cards: hands[c.id] }))); test("the short stack takes the main pot with the best hand", () => { const round = readRound(entries()); assert.equal(round.complete, true); assert.deepEqual(round.winners, ["short"]); }); test("and the side pot goes to the best of the two who are left", () => { const first = PotCalculator.resolveRound(entries(), ["short"]); // 20 from each of three: the short stack is paid 60 and is out of it. assert.equal(Money.round(first.perWinner[0].amount), 60); const round = readRound(first.updatedEntries); assert.deepEqual(round.winners, ["big"], "not the trips, which are no longer eligible for this money"); assert.equal(round.players.length, 2, "and the short stack is not even offered"); const second = PotCalculator.resolveRound(first.updatedEntries, ["big"]); assert.equal(Money.round(second.perWinner[0].amount), 160); }); test("every chip is accounted for across the two rounds", () => { const first = PotCalculator.resolveRound(entries(), ["short"]); const second = PotCalculator.resolveRound(first.updatedEntries, ["big"]); assert.equal(Money.round(first.payoutPool + second.payoutPool), 220); assert.equal(PotCalculator.getContenders(second.updatedEntries).length, 0); }); }); describe("what it refuses to answer", () => { const board = ["KD", "9C", "4S", "AH", "2H"]; test("a folded player's cards are not in the running", () => { // They are not a contender, so they are never handed in — which means a hand // that would have won cannot take a pot its owner threw away. const table = PotCalculator.buildEntries([ { id: "live", contribution: 50, folded: false }, { id: "mucked", contribution: 50, folded: true }, ]); const contenders = PotCalculator.getContenders(table); assert.deepEqual(contenders.map(c => c.id), ["live"]); const round = HandRank.showdown(board, contenders.map(c => ({ id: c.id, cards: ["7C", "5D"] }))); assert.deepEqual(round.winners, ["live"], "even holding nothing"); }); test("one hand not turned over and it hands the question back", () => { const round = HandRank.showdown(board, [ { id: "a", cards: ["9H", "9S"] }, { id: "b", cards: ["", ""] }, ]); assert.equal(round.complete, false); assert.deepEqual(round.winners, []); assert.deepEqual(round.unknown, ["b"]); }); test("a board still being dealt is not a showdown", () => { const round = HandRank.showdown(["KD", "9C", "4S", "", ""], [ { id: "a", cards: ["9H", "9S"] }, { id: "b", cards: ["AS", "KC"] }, ]); assert.equal(round.complete, false); assert.deepEqual(round.winners, []); }); test("a card entered twice stops it rather than guessing", () => { // The failure mode that matters: a mis-typed card produces a hand that ranks // perfectly well and is not the one on the table. Refusing is the only honest // answer, and the dealer picks the winner the way they always could. const round = HandRank.showdown(board, [ { id: "a", cards: ["9H", "9S"] }, { id: "b", cards: ["KD", "QC"] }, // the king of diamonds is on the board ]); assert.equal(round.duplicated, true); assert.equal(round.complete, false); assert.deepEqual(round.winners, []); }); test("and two players holding the same card is caught as well", () => { const round = HandRank.showdown(board, [ { id: "a", cards: ["QH", "QS"] }, { id: "b", cards: ["QH", "JC"] }, ]); assert.equal(round.duplicated, true); assert.equal(round.complete, false); }); }); describe("a table that never asked for any of this", () => { test("has no board, tracks nothing, and reads no showdown", () => { const state = HoldemState.createDefault(); assert.equal(state.trackBoard, false); // Which is what the wizard checks before it reads anything at all — see // readShowdown() in potWizard.js. With it off, an empty board would decline // anyway, so the tool cannot start answering a question nobody asked. const round = HandRank.showdown(state.board, [ { id: "a", cards: ["9H", "9S"] }, { id: "b", cards: ["AS", "KC"] }, ]); assert.equal(round.complete, false); assert.deepEqual(round.winners, []); }); });