Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a2fc3e540 | ||
|
|
b64da7a787 | ||
|
|
3be93cdadf | ||
|
|
40fa16920b | ||
|
|
09eac536b5 |
@@ -3138,7 +3138,18 @@ body.hasActionBar #toastHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panelBody .holdemBtnSmall,
|
.panelBody .holdemBtnSmall,
|
||||||
.handHistoryActions .holdemBtn {
|
.handHistoryActions .holdemBtn,
|
||||||
|
/* The card picker's Clear and Done sat at 40px: the touch rule was scoped to
|
||||||
|
.panelBody and the picker is a sheet, so it never reached them. Clear wipes a
|
||||||
|
player's cards, which is not a thing to make a dealer aim at. */
|
||||||
|
.cardPickerHeader .holdemBtn,
|
||||||
|
/* Undo was 30px, and it is the control a dealer reaches for having just recorded
|
||||||
|
the wrong thing with five people waiting — the worst place to be fourteen pixels
|
||||||
|
short of a fingertip. Growing the box costs bar height, which comes out of the
|
||||||
|
felt, but measured at 390x844 the felt is 366px against a 220px floor, so there
|
||||||
|
is room for it. A hit-area pad was tried first and does not work here: the pill
|
||||||
|
clips its overflow to keep a long label on one line, which clips the pad too. */
|
||||||
|
.actionUndo {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3525,7 +3536,9 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.panelBody .holdemBtnSmall,
|
.panelBody .holdemBtnSmall,
|
||||||
.handHistoryActions .holdemBtn,
|
.handHistoryActions .holdemBtn,
|
||||||
.toastAction {
|
.toastAction,
|
||||||
|
.cardPickerHeader .holdemBtn,
|
||||||
|
.actionUndo {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,9 @@ button,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.unevenCurrency {
|
.unevenCurrency {
|
||||||
color: #777;
|
/* Was #777 — 4.11:1 on this background, under the 4.5:1 small text needs. It sits
|
||||||
|
against every amount a dealer types, so it is read constantly. */
|
||||||
|
color: #8f8f8f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unevenAmount {
|
.unevenAmount {
|
||||||
@@ -184,7 +186,9 @@ button,
|
|||||||
height: 44px;
|
height: 44px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: #6a6a6a;
|
/* Was #6a6a6a — 3.66:1, the worst on the page, on a control that deletes a
|
||||||
|
player's entry. A destructive action should not be the faintest thing in view. */
|
||||||
|
color: #9a9a9a;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -209,11 +213,86 @@ button,
|
|||||||
.unevenPot {
|
.unevenPot {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
/* Wraps so a chop opens underneath its own pot rather than squeezing in beside the
|
||||||
|
eligible-players list and pushing it off the row. */
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.3rem 0;
|
padding: 0.3rem 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unevenChopToggle {
|
||||||
|
flex: none;
|
||||||
|
order: 3;
|
||||||
|
/* 44px on touch: it moves money, like everything else that does. */
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border: 1px solid rgba(216, 180, 106, 0.45);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(216, 180, 106, 0.1);
|
||||||
|
color: #e3c887;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopToggle:hover {
|
||||||
|
background: rgba(216, 180, 106, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopToggle:focus-visible {
|
||||||
|
outline: 2px solid #d8b46a;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopDetail {
|
||||||
|
order: 4;
|
||||||
|
flex: 0 0 100%;
|
||||||
|
/* Or the padding is added on top of the 100% basis and the panel sits wider than
|
||||||
|
the row it belongs to — measured 328px inside a 309px row on a phone. */
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0.15rem 0 0.2rem;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopWays {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopWaysLabel {
|
||||||
|
color: #cfcfcf;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopEach {
|
||||||
|
margin: 0;
|
||||||
|
color: #f2f2f2;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopNote {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: #9a9a9a;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopDust {
|
||||||
|
color: #e0b169;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px), (pointer: coarse) {
|
||||||
|
.unevenChopToggle {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.unevenPot + .unevenPot {
|
.unevenPot + .unevenPot {
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
@@ -510,7 +589,9 @@ button,
|
|||||||
|
|
||||||
.splitFootnote {
|
.splitFootnote {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
color: #6f6f6f;
|
/* Was #6f6f6f — 3.94:1. It carries the instruction for what to do with the pot
|
||||||
|
list, which is not a decorative line. */
|
||||||
|
color: #8d8d8d;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -430,7 +430,6 @@ const ActionBar = {
|
|||||||
const player = this.state.seats[index];
|
const player = this.state.seats[index];
|
||||||
const stack = Betting.stackOf(this.state, player);
|
const stack = Betting.stackOf(this.state, player);
|
||||||
const maxTo = Betting.round(Betting.committed(player) + stack);
|
const maxTo = Betting.round(Betting.committed(player) + stack);
|
||||||
const pot = Betting.potSoFar(this.state);
|
|
||||||
// Two different minimums, and conflating them was a bug. `minTo` is clamped to
|
// Two different minimums, and conflating them was a bug. `minTo` is clamped to
|
||||||
// what this player can actually put in, which is what the Min button should
|
// what this player can actually put in, which is what the Min button should
|
||||||
// offer. `legalMin` is what the rules require — for a short stack the two are
|
// offer. `legalMin` is what the rules require — for a short stack the two are
|
||||||
@@ -446,10 +445,16 @@ const ActionBar = {
|
|||||||
const quick = document.createElement("div");
|
const quick = document.createElement("div");
|
||||||
quick.className = "raiseQuick";
|
quick.className = "raiseQuick";
|
||||||
|
|
||||||
|
// The pot amounts come from Betting, which knows that a pot raise is measured
|
||||||
|
// off the pot as it stands *after* the caller's own call goes in. The pad used
|
||||||
|
// to work it out here as "the bet plus the pot", which left the call out and
|
||||||
|
// made both amounts short: pre-flop at 1/2 it offered "Pot $5" where a pot
|
||||||
|
// raise is $7, and a half-pot that clamped up to the minimum and sat next to
|
||||||
|
// the Min button showing the same $4 twice.
|
||||||
const options = [
|
const options = [
|
||||||
{ label: "Min", value: minTo },
|
{ label: "Min", value: minTo },
|
||||||
{ label: "½ pot", value: Betting.round(this.state.currentBet + pot / 2) },
|
{ label: "½ pot", value: Betting.potRaiseTo(this.state, index, 0.5) },
|
||||||
{ label: "Pot", value: Betting.round(this.state.currentBet + pot) },
|
{ label: "Pot", value: Betting.potRaiseTo(this.state, index, 1) },
|
||||||
{ label: "All-in", value: maxTo },
|
{ label: "All-in", value: maxTo },
|
||||||
];
|
];
|
||||||
options.forEach(option => {
|
options.forEach(option => {
|
||||||
|
|||||||
@@ -518,6 +518,42 @@ const Betting = {
|
|||||||
: this.nextToAct(state, bbIndex);
|
: this.nextToAct(state, bbIndex);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// What a pot-sized raise actually is, as a "raise to" total.
|
||||||
|
//
|
||||||
|
// Not "the bet plus the pot as it stands". The raiser calls first, and their call
|
||||||
|
// is in the pot before the raise is measured off it, so the pot being matched is
|
||||||
|
// the one that exists after the call:
|
||||||
|
//
|
||||||
|
// raise to = the current bet + (pot + what they owe) × fraction
|
||||||
|
//
|
||||||
|
// Pre-flop at 1/2 that gives $7, not $5: call the $2, which makes the pot $5, then
|
||||||
|
// raise $5. Leaving the call out understates every one of these, and players say
|
||||||
|
// "pot" out loud meaning a definite number.
|
||||||
|
//
|
||||||
|
// Facing no bet this is a plain pot-sized bet — toCall is zero and currentBet is
|
||||||
|
// zero, so it comes out as the pot, which is the right answer there too.
|
||||||
|
// Rounded up to something the table's chips can actually build. Half the pot is
|
||||||
|
// constantly a half-chip amount — pre-flop at 1/2 it is $4.50, and the smallest
|
||||||
|
// chip in the default legend is a dollar, so the button offered a number the
|
||||||
|
// dealer would have to round themselves every single time.
|
||||||
|
//
|
||||||
|
// Only when the legend describes this game. A smallest chip *larger* than the big
|
||||||
|
// blind means the denominations were never set for these stakes, and rounding a
|
||||||
|
// 5c/10c table up to whole dollars would be the tool doing real damage on the
|
||||||
|
// strength of a field somebody forgot to fill in. Left exact there, and the wrong
|
||||||
|
// legend stays visible rather than being quietly applied.
|
||||||
|
potRaiseTo(state, index, fraction = 1) {
|
||||||
|
const player = state.seats[index];
|
||||||
|
if (!player) return 0;
|
||||||
|
const potAfterCall = this.round(this.potSoFar(state) + this.toCall(state, player));
|
||||||
|
const exact = this.round((state.currentBet || 0) + potAfterCall * fraction);
|
||||||
|
|
||||||
|
const step = Money.fromCents(Money.chipStepCents(state.chipLegend));
|
||||||
|
const bigBlind = Number(state.bigBlind) || 0;
|
||||||
|
if (step <= 0 || (bigBlind > 0 && step > bigBlind)) return exact;
|
||||||
|
return Money.upToChip(exact, state.chipLegend);
|
||||||
|
},
|
||||||
|
|
||||||
// Who turns their hand over first.
|
// Who turns their hand over first.
|
||||||
//
|
//
|
||||||
// A call the dealer makes at every showdown that isn't a fold, and one nobody at
|
// A call the dealer makes at every showdown that isn't a fold, and one nobody at
|
||||||
|
|||||||
@@ -63,6 +63,29 @@ const Money = {
|
|||||||
// Anything the smallest chip can't make comes back as `remainder` rather than being
|
// Anything the smallest chip can't make comes back as `remainder` rather than being
|
||||||
// rounded away: a 50c share in a game whose smallest chip is a dollar is a real
|
// rounded away: a 50c share in a game whose smallest chip is a dollar is a real
|
||||||
// problem for the dealer to solve, not a rounding error to hide.
|
// problem for the dealer to solve, not a rounding error to hide.
|
||||||
|
// The smallest chip the table has said it plays with, in cents. Zero when no
|
||||||
|
// denominations are set, which means the tool has been told nothing about what
|
||||||
|
// chips exist and shouldn't pretend otherwise.
|
||||||
|
chipStepCents(legend) {
|
||||||
|
const values = (legend || [])
|
||||||
|
.map(chip => this.toCents(chip.value))
|
||||||
|
.filter(cents => cents > 0);
|
||||||
|
return values.length > 0 ? Math.min(...values) : 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rounds an amount up to something the table can actually build out of its chips.
|
||||||
|
//
|
||||||
|
// Up rather than down or nearest: these are bet sizes, and a pot-sized bet that
|
||||||
|
// has been shaved is no longer a pot-sized bet. Rounding up also can't drop an
|
||||||
|
// amount below a legal minimum, which rounding the other way can.
|
||||||
|
upToChip(amount, legend) {
|
||||||
|
const step = this.chipStepCents(legend);
|
||||||
|
if (step <= 0) return this.round(amount);
|
||||||
|
const cents = this.toCents(amount);
|
||||||
|
if (cents <= 0) return this.round(amount);
|
||||||
|
return this.fromCents(Math.ceil(cents / step) * step);
|
||||||
|
},
|
||||||
|
|
||||||
chipBreakdown(amount, legend) {
|
chipBreakdown(amount, legend) {
|
||||||
let cents = this.toCents(amount);
|
let cents = this.toCents(amount);
|
||||||
if (cents <= 0) return { chips: [], remainder: 0 };
|
if (cents <= 0) return { chips: [], remainder: 0 };
|
||||||
|
|||||||
@@ -197,6 +197,80 @@ const QuickSplit = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Chopping one of the pots listed above, without leaving the screen that worked it
|
||||||
|
// out. Starts closed and at as many ways as there are players who can win the pot,
|
||||||
|
// which is the common case; the stepper covers a chop between fewer of them, which
|
||||||
|
// is the other one.
|
||||||
|
//
|
||||||
|
// The per-pot ways live on the row rather than in `this.ways`, so opening a chop on
|
||||||
|
// the main pot doesn't change what the side pot below it is saying.
|
||||||
|
addChopControl(row, amount, contenderCount) {
|
||||||
|
let ways = contenderCount;
|
||||||
|
let open = false;
|
||||||
|
|
||||||
|
const toggle = document.createElement("button");
|
||||||
|
toggle.type = "button";
|
||||||
|
toggle.className = "unevenChopToggle";
|
||||||
|
|
||||||
|
const detail = document.createElement("div");
|
||||||
|
detail.className = "unevenChopDetail";
|
||||||
|
detail.hidden = true;
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
toggle.textContent = open ? "Hide chop" : "Chop";
|
||||||
|
toggle.setAttribute("aria-expanded", String(open));
|
||||||
|
detail.hidden = !open;
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const result = SplitCalculator.split(amount, ways, this.chip);
|
||||||
|
detail.innerHTML = "";
|
||||||
|
|
||||||
|
const stepper = document.createElement("div");
|
||||||
|
stepper.className = "unevenChopWays";
|
||||||
|
stepper.appendChild(this.makeStepButton("−", () => {
|
||||||
|
if (ways > 2) { ways -= 1; draw(); }
|
||||||
|
}));
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "unevenChopWaysLabel";
|
||||||
|
label.textContent = `${ways} ways`;
|
||||||
|
stepper.appendChild(label);
|
||||||
|
stepper.appendChild(this.makeStepButton("+", () => {
|
||||||
|
if (ways < Math.max(contenderCount, this.MAX_WAYS)) { ways += 1; draw(); }
|
||||||
|
}));
|
||||||
|
detail.appendChild(stepper);
|
||||||
|
|
||||||
|
const each = document.createElement("p");
|
||||||
|
each.className = "unevenChopEach";
|
||||||
|
each.textContent = `${Money.format(result.each)} each`;
|
||||||
|
detail.appendChild(each);
|
||||||
|
|
||||||
|
// Same wording as the main split, because it is the same rule and a dealer
|
||||||
|
// shouldn't have to notice they're reading a different screen.
|
||||||
|
if (result.valid && result.oddChips > 0) {
|
||||||
|
const odd = document.createElement("p");
|
||||||
|
odd.className = "unevenChopNote";
|
||||||
|
odd.textContent = result.oddChips === 1
|
||||||
|
? `plus one odd ${Money.format(result.chip)} chip to the first player left of the button`
|
||||||
|
: `plus ${result.oddChips} odd ${Money.format(result.chip)} chips — one each to the ` +
|
||||||
|
`first ${result.oddChips} players left of the button`;
|
||||||
|
detail.appendChild(odd);
|
||||||
|
}
|
||||||
|
if (result.valid && result.dust > 0) {
|
||||||
|
const dust = document.createElement("p");
|
||||||
|
dust.className = "unevenChopNote unevenChopDust";
|
||||||
|
dust.textContent =
|
||||||
|
`${Money.format(result.dust)} doesn't divide into ${Money.format(result.chip)} chips — ` +
|
||||||
|
`pick a smaller denomination.`;
|
||||||
|
detail.appendChild(dust);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle.addEventListener("click", () => { open = !open; draw(); });
|
||||||
|
draw();
|
||||||
|
row.appendChild(toggle);
|
||||||
|
row.appendChild(detail);
|
||||||
|
},
|
||||||
|
|
||||||
updateUneven() {
|
updateUneven() {
|
||||||
const players = this.entries
|
const players = this.entries
|
||||||
.map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded }))
|
.map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded }))
|
||||||
@@ -218,7 +292,7 @@ const QuickSplit = {
|
|||||||
}
|
}
|
||||||
empty.hidden = true;
|
empty.hidden = true;
|
||||||
|
|
||||||
const addRow = (label, amount, who, className) => {
|
const addRow = (label, amount, who, className, chopWays) => {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = `unevenPot ${className || ""}`;
|
row.className = `unevenPot ${className || ""}`;
|
||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
@@ -234,9 +308,19 @@ const QuickSplit = {
|
|||||||
row.appendChild(value);
|
row.appendChild(value);
|
||||||
row.appendChild(eligible);
|
row.appendChild(eligible);
|
||||||
host.appendChild(row);
|
host.appendChild(row);
|
||||||
|
|
||||||
|
// Two players tying for a side pot is ordinary, and working out each one's
|
||||||
|
// share meant copying the amount into the other mode by hand — retyping a
|
||||||
|
// number the screen had just worked out, which is a transcription error
|
||||||
|
// waiting to happen with money on it. The chop happens here instead.
|
||||||
|
//
|
||||||
|
// Only offered where it means something: a pot with at least two people
|
||||||
|
// who can win it. Not the uncalled row, which goes back to one player, and
|
||||||
|
// not the total.
|
||||||
|
if (chopWays >= 2) this.addChopControl(row, amount, chopWays);
|
||||||
};
|
};
|
||||||
|
|
||||||
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", ")));
|
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", "), "", pot.eligible.length));
|
||||||
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
|
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
|
||||||
|
|
||||||
const total = players.reduce((sum, p) => sum + p.contribution, 0);
|
const total = players.reduce((sum, p) => sum + p.contribution, 0);
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// What a pot-sized raise actually is.
|
||||||
|
//
|
||||||
|
// The raise pad worked it out as "the current bet plus the pot as it stands", which
|
||||||
|
// leaves out the fact that the raiser calls first and their call is in the pot before
|
||||||
|
// the raise is measured off it. Every quick amount came out short — pre-flop at 1/2 it
|
||||||
|
// offered "Pot $5" where a pot raise is $7. Players say "pot" out loud meaning a
|
||||||
|
// definite number, and a dealer tapping the button that claims to be it should record
|
||||||
|
// what was actually said.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { Betting, Money, makeTable } = require("./load");
|
||||||
|
|
||||||
|
describe("pre-flop at 1/2", () => {
|
||||||
|
const dealt = () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("a pot raise from the first player to act is $7", () => {
|
||||||
|
// Call the $2, which makes the pot $5, then raise $5.
|
||||||
|
const state = dealt();
|
||||||
|
assert.equal(Betting.potSoFar(state), 3, "the blinds");
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("half pot is $5 — $4.50 rounded up to something the chips can make", () => {
|
||||||
|
// The old arithmetic gave $3.50, which clamped up to the minimum and put the
|
||||||
|
// same $4 on two buttons side by side. The honest half-pot is $4.50, and the
|
||||||
|
// smallest chip in the default legend is a dollar, so the button would have
|
||||||
|
// offered an amount the dealer had to round themselves every single time.
|
||||||
|
const state = dealt();
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5);
|
||||||
|
assert.notEqual(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5),
|
||||||
|
Betting.minRaiseTo(state), "the two quick buttons say different things");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the small blind owes less, so their pot raise is smaller", () => {
|
||||||
|
// They have $1 in already: call $1, pot becomes $4, raise $4, total $6.
|
||||||
|
const state = dealt();
|
||||||
|
const { sbIndex } = require("./load").HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(Betting.toCall(state, state.seats[sbIndex]), 1);
|
||||||
|
assert.equal(Betting.potRaiseTo(state, sbIndex), 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the big blind owes nothing, so it is just the pot", () => {
|
||||||
|
const state = dealt();
|
||||||
|
const { bbIndex } = require("./load").HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(Betting.toCall(state, state.seats[bbIndex]), 0);
|
||||||
|
assert.equal(Betting.potRaiseTo(state, bbIndex), 5, "the $2 bet plus the $3 pot");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("after the flop", () => {
|
||||||
|
const flopWithBet = () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 500, dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("a pot raise facing a bet is three times the bet plus the dead money", () => {
|
||||||
|
const state = flopWithBet();
|
||||||
|
const potBefore = Betting.potSoFar(state); // $6 carried over
|
||||||
|
const bettor = state.actingSeatIndex;
|
||||||
|
Betting.raiseTo(state, bettor, 10);
|
||||||
|
|
||||||
|
const raiser = state.actingSeatIndex;
|
||||||
|
// Call $10, pot becomes $26, raise $26 → $36. Which is 3×10 + 6.
|
||||||
|
assert.equal(Betting.potRaiseTo(state, raiser), 36);
|
||||||
|
assert.equal(Betting.potRaiseTo(state, raiser), 3 * 10 + potBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("half pot facing that bet is $23", () => {
|
||||||
|
const state = flopWithBet();
|
||||||
|
Betting.raiseTo(state, state.actingSeatIndex, 10);
|
||||||
|
// Call $10, pot becomes $26, raise $13 → $23.
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 23);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("facing no bet at all it is a plain pot-sized bet", () => {
|
||||||
|
const state = flopWithBet();
|
||||||
|
assert.equal(state.currentBet, 0, "nobody has bet the flop");
|
||||||
|
assert.equal(Betting.toCall(state, state.seats[state.actingSeatIndex]), 0);
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), Betting.potSoFar(state));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("it stays sane at the edges", () => {
|
||||||
|
test("a seat that doesn't exist gets zero rather than throwing", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||||
|
assert.equal(Betting.potRaiseTo(state, 99), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the amount is always at least a call", () => {
|
||||||
|
// A raise-to below the current bet would be nonsense to offer. Every fraction
|
||||||
|
// from a tenth up lands at or above what the player owes.
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const index = state.actingSeatIndex;
|
||||||
|
[0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => {
|
||||||
|
assert.ok(Betting.potRaiseTo(state, index, fraction) >= state.currentBet,
|
||||||
|
`${fraction} of the pot should still cover the bet`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cent-sized blinds don't drift", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 20, smallBlind: 0.05, bigBlind: 0.1 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
// Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35.
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the amounts are ones the table can actually build", () => {
|
||||||
|
const CENTS = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }];
|
||||||
|
const DOLLARS = [{ value: 1 }, { value: 5 }, { value: 25 }, { value: 100 }];
|
||||||
|
|
||||||
|
const table = (legend, smallBlind, bigBlind, stack = 200) => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack, smallBlind, bigBlind });
|
||||||
|
state.chipLegend = legend;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("a half-pot that lands on a half chip is rounded up, not offered as it is", () => {
|
||||||
|
const state = table(DOLLARS, 1, 2);
|
||||||
|
// Honestly $4.50. No dollar chip makes fifty cents.
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5);
|
||||||
|
assert.equal(Money.chipBreakdown(5, DOLLARS).remainder, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("up rather than down — a shaved pot bet is not a pot bet", () => {
|
||||||
|
const state = table(DOLLARS, 1, 2);
|
||||||
|
assert.ok(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5) > 4.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an amount the chips already make is left exactly alone", () => {
|
||||||
|
const state = table(DOLLARS, 1, 2);
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7, "the pot raise was already whole");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a cent table keeps its cents", () => {
|
||||||
|
const state = table(CENTS, 0.05, 0.1);
|
||||||
|
// Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35, which cent chips make.
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
|
||||||
|
assert.equal(Money.chipBreakdown(0.35, CENTS).remainder, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legend that plainly isn't for these stakes is not applied", () => {
|
||||||
|
// Dollar chips on a 5c/10c game means somebody never filled the legend in.
|
||||||
|
// Rounding those blinds up to whole dollars would be the tool doing real
|
||||||
|
// damage on the strength of a stale field, so the amounts stay exact and the
|
||||||
|
// wrong legend stays visible.
|
||||||
|
const state = table(DOLLARS, 0.05, 0.1);
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a table with no legend at all gets exact amounts", () => {
|
||||||
|
const state = table([], 1, 2);
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 4.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every fraction comes out buildable, and never shaved", () => {
|
||||||
|
const state = table(DOLLARS, 1, 2);
|
||||||
|
const index = state.actingSeatIndex;
|
||||||
|
const potAfterCall = Betting.potSoFar(state) + Betting.toCall(state, state.seats[index]);
|
||||||
|
|
||||||
|
[0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => {
|
||||||
|
const value = Betting.potRaiseTo(state, index, fraction);
|
||||||
|
const exact = state.currentBet + potAfterCall * fraction;
|
||||||
|
assert.ok(value >= exact, `${fraction} of the pot should never be rounded down`);
|
||||||
|
assert.ok(value - exact < 1, `${fraction} of the pot should round by less than a chip`);
|
||||||
|
assert.equal(Money.chipBreakdown(value, DOLLARS).remainder, 0,
|
||||||
|
`${fraction} of the pot should be buildable from the table's chips`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+82
-4
@@ -93,10 +93,44 @@ const LayoutAudit = {
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The modal that is currently on top, if any.
|
||||||
|
//
|
||||||
|
// Every check below asks "is this thing reachable", and while a sheet or dialog is
|
||||||
|
// open the answer for everything behind it is legitimately no — that is what a
|
||||||
|
// modal is for. Without knowing that, the audit reported the pot wizard covering
|
||||||
|
// the seats as ten faults and the rulings sheet as seven, which is the noise that
|
||||||
|
// made it useless in exactly the states nobody had audited. Now it scopes to what
|
||||||
|
// is actually on top and says so.
|
||||||
|
MODAL_SELECTOR: "#potWizardOverlay, #rulingsOverlay, #seatMenuOverlay, #cardPickerOverlay, " +
|
||||||
|
"#confirmOverlay, #addChipsOverlay, #seatEditorOverlay",
|
||||||
|
|
||||||
|
openModal() {
|
||||||
|
const open = [...document.querySelectorAll(this.MODAL_SELECTOR)]
|
||||||
|
.filter(el => !el.hidden && getComputedStyle(el).display !== "none");
|
||||||
|
// Last in the DOM wins, which is how they stack.
|
||||||
|
return open.length > 0 ? open[open.length - 1] : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// A seat sheet on a phone isn't an overlay element, it's a body class — but it
|
||||||
|
// covers the felt just the same.
|
||||||
|
modalScope() {
|
||||||
|
const overlay = this.openModal();
|
||||||
|
if (overlay) return overlay;
|
||||||
|
if (document.body.classList.contains("seatSheetOpen")) {
|
||||||
|
return document.getElementById("seatEditorPanel") || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
|
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
|
||||||
const offenders = [];
|
const offenders = [];
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
[...document.querySelectorAll(selector)].forEach(el => {
|
// While something is open on top, only what is inside it can be judged. The
|
||||||
|
// rest of the page is meant to be unreachable.
|
||||||
|
const scope = this.modalScope();
|
||||||
|
const candidates = [...document.querySelectorAll(selector)]
|
||||||
|
.filter(el => !scope || scope.contains(el));
|
||||||
|
candidates.forEach(el => {
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
if (rect.width === 0 || rect.height === 0) return;
|
if (rect.width === 0 || rect.height === 0) return;
|
||||||
|
|
||||||
@@ -504,10 +538,25 @@ const LayoutAudit = {
|
|||||||
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
|
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
|
||||||
return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
|
return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
|
||||||
}
|
}
|
||||||
|
const unmeasured = [];
|
||||||
this.visibleControls()
|
this.visibleControls()
|
||||||
.filter(el => el.type !== "color")
|
.filter(el => el.type !== "color")
|
||||||
.filter(el => !(exempt && el.matches(exempt)))
|
.filter(el => !(exempt && el.matches(exempt)))
|
||||||
.forEach(el => {
|
.forEach(el => {
|
||||||
|
// A control inside a scrolling sheet can sit below its container's
|
||||||
|
// visible area while still having a viewport rect. elementFromPoint at
|
||||||
|
// its centre then returns the sheet, effectiveTarget gives up, and the
|
||||||
|
// check used to pass it in silence — which is how "Confirm Winner(s)",
|
||||||
|
// the button that pays the pot out, went unmeasured for 36 rounds.
|
||||||
|
// Bring it into view before probing.
|
||||||
|
const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, #rulingsSheet, #seatEditorPanel");
|
||||||
|
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
|
||||||
|
const box = scroller.getBoundingClientRect();
|
||||||
|
const own = el.getBoundingClientRect();
|
||||||
|
if (own.bottom > box.bottom || own.top < box.top) {
|
||||||
|
el.scrollIntoView({ block: "center" });
|
||||||
|
}
|
||||||
|
}
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
// Drawn big enough is big enough — no need to probe, and probing wide
|
// Drawn big enough is big enough — no need to probe, and probing wide
|
||||||
// elements is where the cap above used to produce nonsense. Seats sit
|
// elements is where the cap above used to produce nonsense. Seats sit
|
||||||
@@ -515,15 +564,35 @@ const LayoutAudit = {
|
|||||||
// measuring their reach would report the crowding as a fault.
|
// measuring their reach would report the crowding as a fault.
|
||||||
if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
|
if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
|
||||||
|
|
||||||
|
// A checkbox inside a label is tapped by tapping the label, so the
|
||||||
|
// label is the target. Measured rather than probed, because probing
|
||||||
|
// fails on anything scrolled off the viewport — which is how a 24px
|
||||||
|
// checkbox with a 335x44 label around it came up as unprobeable
|
||||||
|
// instead of as the perfectly good target it is.
|
||||||
|
const label = el.closest("label");
|
||||||
|
if (label) {
|
||||||
|
const lr = label.getBoundingClientRect();
|
||||||
|
if (lr.width >= minimum - 1 && lr.height >= minimum - 1) return;
|
||||||
|
}
|
||||||
|
|
||||||
const size = this.effectiveTarget(el);
|
const size = this.effectiveTarget(el);
|
||||||
if (!size) return;
|
// Still can't measure it. That is not a pass — it is the check failing
|
||||||
|
// to run, and quietly returning here is exactly the kind of exemption
|
||||||
|
// that hides real faults. Say so instead.
|
||||||
|
if (!size) {
|
||||||
|
unmeasured.push(
|
||||||
|
`${this.describe(el)} :: could not be probed` +
|
||||||
|
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
|
if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
|
||||||
offenders.push(
|
offenders.push(
|
||||||
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
|
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
|
||||||
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
|
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return { name: `touch targets at least ${minimum}px`, offenders };
|
return { name: `touch targets at least ${minimum}px`, offenders, unmeasured };
|
||||||
},
|
},
|
||||||
|
|
||||||
// Tapping the middle of a seat must give you that seat.
|
// Tapping the middle of a seat must give you that seat.
|
||||||
@@ -537,6 +606,13 @@ const LayoutAudit = {
|
|||||||
// Reported separately from occlusion because the cause is different: not an
|
// Reported separately from occlusion because the cause is different: not an
|
||||||
// overlay on top of the table, but two pieces of the table fighting over a tap.
|
// overlay on top of the table, but two pieces of the table fighting over a tap.
|
||||||
seatOwnership() {
|
seatOwnership() {
|
||||||
|
// Seats behind an open sheet aren't fighting each other for taps — nothing is
|
||||||
|
// meant to reach them at all. Asking the question there produced six offenders
|
||||||
|
// per modal, every one of them the modal doing its job.
|
||||||
|
if (this.modalScope()) {
|
||||||
|
return { name: "a seat's centre belongs to that seat", offenders: [],
|
||||||
|
skipped: "a sheet is open over the table" };
|
||||||
|
}
|
||||||
const seats = [...document.querySelectorAll(".seat")];
|
const seats = [...document.querySelectorAll(".seat")];
|
||||||
const offenders = [];
|
const offenders = [];
|
||||||
|
|
||||||
@@ -617,7 +693,9 @@ const LayoutAudit = {
|
|||||||
viewport: `${innerWidth}x${innerHeight}`,
|
viewport: `${innerWidth}x${innerHeight}`,
|
||||||
failed: checks.filter(c => c.offenders.length > 0),
|
failed: checks.filter(c => c.offenders.length > 0),
|
||||||
// Things worth knowing that aren't faults — see occlusion() on toasts.
|
// Things worth knowing that aren't faults — see occlusion() on toasts.
|
||||||
warnings: checks.flatMap(c => c.warnings || []),
|
// Controls a check couldn't probe go here too: not a fault, but not a pass
|
||||||
|
// either, and a silent skip is how faults hide.
|
||||||
|
warnings: checks.flatMap(c => [...(c.warnings || []), ...(c.unmeasured || [])]),
|
||||||
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
|
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
|
||||||
clean: checks.every(c => c.offenders.length === 0),
|
clean: checks.every(c => c.offenders.length === 0),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user