Let the table tell the dealer who won
An option — off by default, and off means the tool is exactly what it was.
Turn it on and the community cards go on the felt. Each street asks for the cards as
it is dealt: the picker opens on the flop the moment the flop button is pressed, and
again on the turn and the river, because the moment a dealer is holding those cards is
the only moment they know what they are without turning the board round. Three taps,
then one, then one. The picker is the same sheet as the hole cards, which now knows
about the middle of the table too — the seven of clubs cannot be on the flop and in
somebody's hand, and until now it could.
Turning the players' cards over is unchanged. That already worked the way it should.
What is new is what happens next. Resolve Pot stops asking who won and says so:
Alexandra wins $50 with three of a kind, nines.
with every hand at the table read out under its player's name, the winner already
ticked, and the button reading "Pay Alexandra". A chop comes out as "Christopher and
Mike from work split it, $22.50 each, with a pair of aces", both ticked. The dealer
still confirms — the tool reads the cards, a person still says the pot is good, and a
hand entered wrong is corrected by unticking rather than by starting over.
It declines rather than guesses. A board that isn't five cards, a player who hasn't
turned over, the same card entered in two places: each says which of those it is and
hands the question back, and the wizard is the one it always was. Who is *ahead* and
who *won* are different claims and only the second one can pay somebody.
Side pots are read per round, not once. 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 the one the tool has to ask for again.
handRank.js is the only module here that can pay the wrong person without anybody
noticing, so it is built to be checkable rather than clever: rank five cards, then take
the best of the twenty-one five-card hands in seven. Beside the written cases — every
category against every other, the wheel and the steel wheel, three kickers deep, the
counterfeited two pair, the board that plays — it is checked against a second evaluator
written a different way, on twenty thousand random seven-card hands and four thousand
random pairs of them. An ace that keeps its value in the wheel, a dropped two-pair
kicker and a flush read only by its top card were each introduced on purpose and each
caught.
The board takes the middle of the felt, sized from the room the seat ring actually
leaves — measured against every seat rather than the ring's widest point, which is only
the answer for a seat sitting exactly on the horizontal axis and had two pucks on top
of the board while claiming 77px of clearance either side. Where there is no middle to
take, it moves to a strip above the table: a phone on its side has a felt 528x83 with
55px pucks, so the ring closes over the centre entirely and a board in it is a board
with players painted on top.
That strip found an older bug. It costs the felt height, and a felt that changes size
had only ever been re-measured, never laid out again: the seats follow it on their own,
being placed in percentages, but a bet chip anchored inside a seat is placed in pixels
off that seat's centre. Measured at 640x360, the chips were positioned for a felt 83px
tall and painted on one of 60 — seat 3's $5 came to rest on seat 4's puck, and two
pairs of seat pucks were overlapping. The bar growing mid-hand does the same thing: a
side pot appearing is 55px off the felt. Both fixed.
The sweep grew a third table — the same five players in the same nine chairs as the
second, with the board on, so the only thing that differs between those two rows is the
board itself. It earned its place on the first run.
728 unit tests. 216 audits: what is left is the seat ring hanging past the rail on a
landscape phone, the known residual, plus five or six pixels more of it on the layouts
where the strip takes the felt's height, and a blind badge over a stack on a packed
twelve-seat portrait table.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
+38
-9
@@ -20,6 +20,8 @@ other in the page, in the load order from `TexasHoldem.cshtml`.
|
||||
| `betting.test.js` | Blinds, street completion, raises reopening the action, all-in run-outs |
|
||||
| `state.test.js` | Button rotation, which seats post blinds, session standings |
|
||||
| `splitCalculator.test.js` | Chopped pots, odd chips, unmakeable denominations |
|
||||
| `handRank.test.js` | What beats what: categories, the wheel, kickers, best five of seven |
|
||||
| `readingTheShowdown.test.js` | The board, the picker that fills it, and who wins a side pot |
|
||||
| `invariants.test.js` | 1000 randomised hands per property (see below) |
|
||||
|
||||
## The layout audit
|
||||
@@ -38,7 +40,7 @@ text is painted outside its box, that no seat covers another seat's writing, and
|
||||
no bet chip is hidden behind a seat or another chip. It
|
||||
runs over the table, all five overlays and Quick Split, at thirteen viewports from a
|
||||
360px phone to a 1600px desktop and the same phones on their side — each viewport
|
||||
twice, once with a mouse and once with a thumb, and at two table shapes, mid-hand:
|
||||
twice, once with a mouse and once with a thumb, and at three table shapes, mid-hand:
|
||||
the sweep posts blinds, deals and calls three times before it measures anything, so
|
||||
the bets layer has chips on it. It used to audit a table nobody had ever bet at,
|
||||
which is a third of what is on the felt during a hand left unlooked at.
|
||||
@@ -49,12 +51,22 @@ at that height, and reporting it that way is a diagnosis of something that isn't
|
||||
happening: the pot wizard's third winner, below the fold in a list, came back for four
|
||||
viewports running as "fully covered by button Confirm Winner(s)".
|
||||
|
||||
Two table shapes is also newer than it sounds. The sweep ran on one — five players at
|
||||
a nine-handed table — for its whole life, and how much room the felt has depends on
|
||||
how many people are sitting down far more than on anything a viewport can report. A
|
||||
full table is where the seats crowd and it had never been looked at: the first sweep
|
||||
that did found one player's name or stack painted under another on eighteen of the
|
||||
configurations, on every phone in the list.
|
||||
More than one table shape is also newer than it sounds. The sweep ran on one — five
|
||||
players at a nine-handed table — for its whole life, and how much room the felt has
|
||||
depends on how many people are sitting down far more than on anything a viewport can
|
||||
report. A full table is where the seats crowd and it had never been looked at: the
|
||||
first sweep that did found one player's name or stack painted under another on eighteen
|
||||
of the configurations, on every phone in the list.
|
||||
|
||||
The third shape is the same five players with the board turned on and five cards on it.
|
||||
It is deliberately the same roster in the same chairs as the second, so the only thing
|
||||
that differs between the two rows is the board — a seventh player would have told us
|
||||
that a seven-handed table crowds, which is true, already known, and not what that row
|
||||
is there to find out. It earned its place immediately: the board takes the middle of
|
||||
the felt, or a strip above it where the middle is too small, and the strip costs the
|
||||
felt height. The seats follow a felt that changes size on their own, being placed in
|
||||
percentages of it; the chips anchored inside them do not, being placed in pixels. They
|
||||
were laid out for a felt 23px taller than the one they were painted on.
|
||||
|
||||
That last part, and two of those checks, are newer than this paragraph claiming them.
|
||||
`runAll()` did not include the contrast check at all, and touch targets sat behind an
|
||||
@@ -71,8 +83,8 @@ offenders, which reads identically to a pass. The same goes for the two exemptio
|
||||
that exist: a disabled control, which WCAG itself excuses, and a link inside a
|
||||
sentence, whose size is set by the line it sits in.
|
||||
|
||||
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
|
||||
this one needs a browser and a running site. Run it after any change that adds or
|
||||
Deliberately not in CI: the unit suite is dependency-free and runs in about five
|
||||
seconds, while this one needs a browser and a running site. Run it after any change that adds or
|
||||
resizes a control. With no Playwright to hand, paste `uiAudit.js` into devtools and
|
||||
call `LayoutAudit.runAll()`.
|
||||
|
||||
@@ -95,6 +107,23 @@ something changed, and each time they only covered whatever had just been touche
|
||||
That is how the standings table shipped with a player-name column zero pixels wide
|
||||
and kept it for two rounds.
|
||||
|
||||
## Why the hand evaluator is tested the way it is
|
||||
|
||||
`handRank.js` is the only module here that can pay the wrong person without anybody
|
||||
noticing. A pot wizard that asks "who won?" is never more wrong than 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 besides the hand-written cases — every category against every other, the wheel, three
|
||||
kickers deep, the counterfeited two pair, the board that plays — it is checked against a
|
||||
second evaluator written a different way, on twenty thousand random seven-card hands and
|
||||
four thousand random pairs of them. The second one is in the test file and shares no
|
||||
helper with the module: it scores a hand by counting ranks into a sortable list rather
|
||||
than by asking about shapes. That pass is most of the suite's running time and it is
|
||||
worth it; three deliberately introduced bugs — an ace that keeps its value in the wheel,
|
||||
a dropped two-pair kicker, a flush read only by its top card — are each caught by it as
|
||||
well as by the cases written for them.
|
||||
|
||||
## Why the randomised suite exists
|
||||
|
||||
Every correctness bug found in this tool so far has been shape-dependent: blinds posted
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// The community cards, as a thing the table remembers.
|
||||
//
|
||||
// The board is the one part of a hand this tool never had a place for: hole cards go on
|
||||
// a seat, chips go in the middle, and the five cards everybody plays lived only on the
|
||||
// felt in front of the dealer. It is opt-in, because most nights nobody wants to enter
|
||||
// cards at all — and everything that reads it has to behave exactly as it always did
|
||||
// when it is off.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable } = require("./load");
|
||||
|
||||
const dealt = (over = {}) => {
|
||||
const state = { ...makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2 }), ...over };
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("a new table", () => {
|
||||
test("has five empty slots and is not tracking them", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
assert.equal(state.trackBoard, false,
|
||||
"off by default: a dealer who never asked for this should not be asked for cards");
|
||||
});
|
||||
});
|
||||
|
||||
describe("how many cards are out", () => {
|
||||
test("none, three, four, five", () => {
|
||||
assert.equal(Betting.boardCardsFor("preflop"), 0);
|
||||
assert.equal(Betting.boardCardsFor("flop"), 3);
|
||||
assert.equal(Betting.boardCardsFor("turn"), 4);
|
||||
assert.equal(Betting.boardCardsFor("river"), 5);
|
||||
});
|
||||
|
||||
test("and nothing at all for a street that isn't one", () => {
|
||||
assert.equal(Betting.boardCardsFor("showdown"), 0);
|
||||
assert.equal(Betting.boardCardsFor(undefined), 0);
|
||||
});
|
||||
|
||||
test("it matches the streets the betting engine actually deals", () => {
|
||||
// If a street is ever added, this is the table that has to hear about it —
|
||||
// a street with no entry silently draws no cards.
|
||||
Betting.STREETS.forEach(street => {
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(Betting.BOARD_CARDS, street),
|
||||
`${street} has no card count`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearing it", () => {
|
||||
test("the next hand takes the board with it", () => {
|
||||
const state = dealt();
|
||||
state.board = ["AH", "KD", "9C", "4S", "2H"];
|
||||
HoldemState.startNextHand(state);
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("so does a misdeal", () => {
|
||||
const state = dealt();
|
||||
state.board = ["AH", "KD", "9C", "", ""];
|
||||
Betting.takeHandBack(state);
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("and so does dealing a hand, however the table got here", () => {
|
||||
// Dealing twice off the same button — after a misdeal, or a table set up and
|
||||
// then dealt again — would otherwise rank the last hand's board against this
|
||||
// hand's cards, and it would look exactly like a real answer.
|
||||
const state = dealt();
|
||||
state.board = ["AH", "KD", "9C", "4S", "2H"];
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("but turning the tracking off does not", () => {
|
||||
// A dealer who taps the toggle mid-hand can tap it back; wiping the flop on
|
||||
// the way past would cost them cards they had already entered.
|
||||
const state = dealt({ trackBoard: true });
|
||||
state.board = ["AH", "KD", "9C", "", ""];
|
||||
state.trackBoard = false;
|
||||
assert.deepEqual(state.board, ["AH", "KD", "9C", "", ""]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a board off a file", () => {
|
||||
const migrated = board => HoldemState.migrate({
|
||||
seats: [HoldemState.makePlayer()], board,
|
||||
}).board;
|
||||
|
||||
test("a table saved before boards existed gets an empty one, not tracked", () => {
|
||||
const parsed = HoldemState.migrate({ seats: [HoldemState.makePlayer()] });
|
||||
assert.deepEqual(parsed.board, ["", "", "", "", ""]);
|
||||
assert.equal(parsed.trackBoard, false);
|
||||
});
|
||||
|
||||
test("it is always five slots, however many arrived", () => {
|
||||
assert.equal(migrated(["AH"]).length, 5);
|
||||
assert.equal(migrated(["AH", "KD", "9C", "4S", "2H", "3D", "5S"]).length, 5);
|
||||
assert.equal(migrated("not an array").length, 5);
|
||||
assert.equal(migrated(null).length, 5);
|
||||
});
|
||||
|
||||
test("anything that isn't a card becomes an empty slot", () => {
|
||||
assert.deepEqual(migrated(["AH", "ZZ", 7, null, "10H"]), ["AH", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("and a real one keeps its place, upper-cased", () => {
|
||||
assert.deepEqual(migrated(["ah", " kd ", "9c", "", ""]), ["AH", "KD", "9C", "", ""]);
|
||||
});
|
||||
|
||||
test("tracking is only on if the file said so, in so many words", () => {
|
||||
const on = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: true });
|
||||
const truthy = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: "yes" });
|
||||
assert.equal(on.trackBoard, true);
|
||||
assert.equal(truthy.trackBoard, false, "a string is a file that has been edited, not a yes");
|
||||
});
|
||||
});
|
||||
@@ -71,3 +71,74 @@ describe("a window with room to spare", () => {
|
||||
assert.equal(room(844.4, 127.6, 223.2) % 1, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the felt changes size, so does everything on it", () => {
|
||||
// The felt is published as a custom property and the seats follow it on their own,
|
||||
// because they are placed in percentages of it. A bet chip anchored inside a seat
|
||||
// is not: it is placed in pixels off that seat's centre, worked out against the felt
|
||||
// as it was, and nothing re-ran that when the felt moved.
|
||||
//
|
||||
// Measured at 640x360 with five players, on a felt that lost 23px of height: the
|
||||
// chips were positioned for the old one and painted on the new, and seat 3's $5 came
|
||||
// to rest on seat 4's puck. The seat pucks had the same problem — they are searched
|
||||
// for a width the felt no longer has — and two pairs of them were overlapping.
|
||||
//
|
||||
// A side pot appearing does this too: it is 55px off the felt, mid-hand.
|
||||
const fakeDom = (windowHeight = 900) => {
|
||||
const root = { style: { values: {},
|
||||
setProperty(k, v) { this.values[k] = v; },
|
||||
getPropertyValue(k) { return this.values[k] || ""; } } };
|
||||
global.document = {
|
||||
documentElement: root,
|
||||
getElementById: id => (id === "pokerTableWrap"
|
||||
? { getBoundingClientRect: () => ({ top: 146 }) }
|
||||
: null),
|
||||
};
|
||||
global.window = { innerHeight: windowHeight, scrollY: 0 };
|
||||
return root;
|
||||
};
|
||||
|
||||
const spyRender = () => {
|
||||
const calls = { density: 0, seats: 0 };
|
||||
global.Render = {
|
||||
state: {},
|
||||
updateSeatDensity() { calls.density++; },
|
||||
renderSeats() { calls.seats++; },
|
||||
};
|
||||
return calls;
|
||||
};
|
||||
|
||||
test.afterEach(() => {
|
||||
delete global.document;
|
||||
delete global.window;
|
||||
delete global.Render;
|
||||
});
|
||||
|
||||
test("a felt that changed height lays the seats out again", () => {
|
||||
fakeDom();
|
||||
const calls = spyRender();
|
||||
ActionBar.publishFeltRoom(117);
|
||||
assert.equal(calls.seats, 1, "first publish sets a height it did not have");
|
||||
|
||||
ActionBar.publishFeltRoom(172);
|
||||
assert.equal(calls.seats, 2, "and a side pot grew the bar by 55px");
|
||||
});
|
||||
|
||||
test("a felt that did not change is left alone", () => {
|
||||
fakeDom();
|
||||
const calls = spyRender();
|
||||
ActionBar.publishFeltRoom(117);
|
||||
ActionBar.publishFeltRoom(117);
|
||||
ActionBar.publishFeltRoom(117);
|
||||
assert.equal(calls.seats, 1,
|
||||
"re-laying the table on every resize tick would be work for nothing");
|
||||
assert.equal(calls.density, 3, "the cheap re-measure still runs each time");
|
||||
});
|
||||
|
||||
test("it publishes the height whether or not anything is listening", () => {
|
||||
const root = fakeDom();
|
||||
delete global.Render;
|
||||
assert.doesNotThrow(() => ActionBar.publishFeltRoom(117));
|
||||
assert.match(root.style.getPropertyValue("--feltRoomPx"), /^\d+px$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ function load(name) {
|
||||
}
|
||||
|
||||
const Money = load("money");
|
||||
const HandRank = load("handRank");
|
||||
const PotCalculator = load("potCalculator");
|
||||
const SplitCalculator = load("splitCalculator");
|
||||
const Betting = load("betting");
|
||||
@@ -33,6 +34,10 @@ globalThis.HoldemState = HoldemState;
|
||||
// its methods reach for both. Loaded last so nothing it touches is still undefined.
|
||||
const ActionBar = load("actionBar");
|
||||
|
||||
// The picker's slot bookkeeping — which slots it is filling, what is already dealt —
|
||||
// is plain state work and testable here. Only render() and open() touch the DOM.
|
||||
const CardPicker = load("cardPicker");
|
||||
|
||||
// ---- Table builders ----------------------------------------------------------
|
||||
|
||||
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
|
||||
@@ -78,6 +83,7 @@ function contributions(state) {
|
||||
|
||||
module.exports = {
|
||||
Money,
|
||||
HandRank,
|
||||
Render,
|
||||
HandHistoryExport,
|
||||
PotCalculator,
|
||||
@@ -86,6 +92,7 @@ module.exports = {
|
||||
Betting,
|
||||
HoldemState,
|
||||
ActionBar,
|
||||
CardPicker,
|
||||
makeTable,
|
||||
chipsOnTable,
|
||||
contributions,
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// 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, []);
|
||||
});
|
||||
});
|
||||
@@ -42,13 +42,24 @@ const OVERLAYS = [
|
||||
// of fifty-four tables had one player's name or stack painted under another.
|
||||
const TABLES = [
|
||||
["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600]]],
|
||||
["Bob", 250], ["Dee", 600]], false],
|
||||
["packed", 12, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480],
|
||||
["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]]],
|
||||
// The half-full table again, with the board turned on and five cards on it. The
|
||||
// board is furniture in the middle of the felt that nothing else was ever measured
|
||||
// against — and where the middle cannot hold it, it moves to a strip above the
|
||||
// table and takes the felt's height with it, which is the seats' problem too.
|
||||
//
|
||||
// Deliberately the same five players in the same nine chairs as the shape above, so
|
||||
// the only thing that differs between them is the board. A seventh player would have
|
||||
// told us that a seven-handed table crowds, which is true, already known, and not
|
||||
// what this row is here to find out.
|
||||
["board", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600]], true],
|
||||
];
|
||||
|
||||
function seedTable([seats, players]) {
|
||||
function seedTable([seats, players, board]) {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, seats);
|
||||
players.forEach(([name, cash], i) => {
|
||||
@@ -68,6 +79,10 @@ function seedTable([seats, players]) {
|
||||
});
|
||||
}
|
||||
state.handNumber = 5;
|
||||
if (board) {
|
||||
state.trackBoard = true;
|
||||
state.board = ["KD", "9C", "4S", "AH", "2H"];
|
||||
}
|
||||
HoldemState.save(state);
|
||||
}
|
||||
|
||||
@@ -101,10 +116,10 @@ async function main() {
|
||||
for (const [width, height, rawLabel] of VIEWPORTS) {
|
||||
await page.setViewportSize({ width, height });
|
||||
|
||||
for (const [shape, seats, players] of TABLES) {
|
||||
for (const [shape, seats, players, board] of TABLES) {
|
||||
const label = `${rawLabel} · ${shape} · ${hand}`;
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.evaluate(seedTable, [seats, players]);
|
||||
await page.evaluate(seedTable, [seats, players, board]);
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
@@ -129,6 +144,20 @@ async function main() {
|
||||
if (!acted) break;
|
||||
await page.waitForTimeout(120);
|
||||
}
|
||||
if (board) {
|
||||
await page.evaluate(() => {
|
||||
const s = HoldemState.load();
|
||||
s.street = "river";
|
||||
s.board = ["KD", "9C", "4S", "AH", "2H"];
|
||||
s.seats.filter(p => p.inHand).forEach((p, i) => {
|
||||
p.cards = [["AS", "KC"], ["AC", "3D"], ["9H", "9S"],
|
||||
["7C", "5D"], ["JD", "TS"]][i] || ["", ""];
|
||||
});
|
||||
HoldemState.save(s);
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user