Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3be93cdadf | ||
|
|
40fa16920b |
@@ -143,7 +143,9 @@ button,
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -184,7 +186,9 @@ button,
|
||||
height: 44px;
|
||||
border: 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;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -209,11 +213,86 @@ button,
|
||||
.unevenPot {
|
||||
display: flex;
|
||||
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;
|
||||
padding: 0.3rem 0;
|
||||
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 {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
@@ -510,7 +589,9 @@ button,
|
||||
|
||||
.splitFootnote {
|
||||
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;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
|
||||
@@ -532,11 +532,26 @@ const Betting = {
|
||||
//
|
||||
// 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));
|
||||
return this.round((state.currentBet || 0) + potAfterCall * fraction);
|
||||
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.
|
||||
|
||||
@@ -63,6 +63,29 @@ const Money = {
|
||||
// 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
|
||||
// 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) {
|
||||
let cents = this.toCents(amount);
|
||||
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() {
|
||||
const players = this.entries
|
||||
.map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded }))
|
||||
@@ -218,7 +292,7 @@ const QuickSplit = {
|
||||
}
|
||||
empty.hidden = true;
|
||||
|
||||
const addRow = (label, amount, who, className) => {
|
||||
const addRow = (label, amount, who, className, chopWays) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = `unevenPot ${className || ""}`;
|
||||
const name = document.createElement("span");
|
||||
@@ -234,9 +308,19 @@ const QuickSplit = {
|
||||
row.appendChild(value);
|
||||
row.appendChild(eligible);
|
||||
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"));
|
||||
|
||||
const total = players.reduce((sum, p) => sum + p.contribution, 0);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, makeTable } = require("./load");
|
||||
const { Betting, Money, makeTable } = require("./load");
|
||||
|
||||
describe("pre-flop at 1/2", () => {
|
||||
const dealt = () => {
|
||||
@@ -25,11 +25,13 @@ describe("pre-flop at 1/2", () => {
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7);
|
||||
});
|
||||
|
||||
test("half pot is $4.50, which is above the $4 minimum rather than equal to it", () => {
|
||||
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.
|
||||
// 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), 4.5);
|
||||
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");
|
||||
});
|
||||
@@ -111,3 +113,68 @@ describe("it stays sane at the edges", () => {
|
||||
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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user