Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
286a924fe4 | ||
|
|
3e9e2e899d | ||
|
|
96cd7684f0 | ||
|
|
1af82dfddf | ||
|
|
2cc2c66976 | ||
|
|
a92a389245 | ||
|
|
3d1ce1f45c | ||
|
|
29188d6049 |
@@ -130,6 +130,12 @@
|
||||
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||
</div>
|
||||
|
||||
<div id="settleUp" hidden>
|
||||
<h3 class="panelSubhead">Settle up</h3>
|
||||
<ul id="settleUpList" class="settleUpList"></ul>
|
||||
<p id="settleUpNote" class="fieldHint"></p>
|
||||
</div>
|
||||
|
||||
<h3 class="panelSubhead">Hands</h3>
|
||||
<div id="handHistoryList"></div>
|
||||
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
|
||||
@@ -443,6 +449,7 @@
|
||||
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
|
||||
|
||||
@@ -212,6 +212,20 @@ button,
|
||||
}
|
||||
|
||||
.holdemBtn {
|
||||
/* Stated, because this class is worn by both <button> and <a> and the browser
|
||||
does not give them the same box. Chromium's own stylesheet makes a button
|
||||
border-box and leaves a link content-box, so `min-height: 46px` on the header
|
||||
row came out as 46px on the buttons and 64px on the two links — and the row
|
||||
stretches to its tallest item, so every button became 64px too. A 46px touch
|
||||
target was quietly costing 64px of a phone screen, twice over, because the
|
||||
header wraps to two rows. */
|
||||
box-sizing: border-box;
|
||||
/* And the touch floor is stated here rather than only under a width breakpoint.
|
||||
It used to arrive by accident — the content-box links were 64px tall and the
|
||||
row stretched everything to match — so once the boxes agree, a landscape phone
|
||||
(wider than the phone breakpoint, still a thumb) would have been left with 42px
|
||||
buttons. 44px is the floor everywhere; the narrow layout still asks for 46. */
|
||||
min-height: 44px;
|
||||
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||
color: #eee;
|
||||
border: 1px solid #3a3a3a;
|
||||
@@ -308,9 +322,22 @@ button,
|
||||
|
||||
#pokerTableWrap {
|
||||
position: relative;
|
||||
/* The shape is stated once, and both the height and the cap below are derived from
|
||||
it. It used to be a `padding-bottom: 72%` box, which has a trap in it: percentage
|
||||
padding resolves against the *containing block's* width, not the element's own,
|
||||
so narrowing the wrap left it exactly as tall as before. Capping the width was
|
||||
therefore no way to shorten the table — measured, after trying it: the felt went
|
||||
822px wide to 746px and stayed 617px tall, and the bottom seat sat as far into
|
||||
the action bar as it had. aspect-ratio ties the two together properly. */
|
||||
--feltAspect: 0.72;
|
||||
width: 100%;
|
||||
padding-bottom: 72%;
|
||||
aspect-ratio: 1 / var(--feltAspect);
|
||||
margin: 0 auto;
|
||||
/* Capping the width is how the felt is told to stay above the action bar.
|
||||
--feltRoomPx is published by actionBar.js with the bar's real height and the
|
||||
bottom seat's overhang already taken out; the fallback leaves the table its full
|
||||
size if that ever fails to arrive. */
|
||||
max-width: calc(var(--feltRoomPx, 100vh) / var(--feltAspect));
|
||||
}
|
||||
|
||||
|
||||
@@ -682,6 +709,23 @@ button,
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Seats on the bottom of the ring wear their badge underneath, because that's the
|
||||
edge the pot isn't on. Bets sit between a seat and the pot, so on the bottom arc
|
||||
the chip comes down onto the seat's top edge — which is exactly where this badge
|
||||
was, and the chip is a layer above it. See render.js for the measurement. */
|
||||
.seat.seatBelowCentre .blindBadge {
|
||||
top: auto;
|
||||
bottom: -9px;
|
||||
}
|
||||
|
||||
/* And the badge outranks the chips regardless, so a layout this doesn't anticipate
|
||||
can't hide it again. Which blind a seat is posting is the one thing on that puck
|
||||
nothing else on the felt repeats — a bet chip's amount is on the seat's own total
|
||||
line as well. Measured across 12 table sizes: no chip loses its amount to this. */
|
||||
.blindBadge {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
/* Both badges failed WCAG AA against their own gradients — measured at the worst
|
||||
stop, the small blind was 3.52:1 and the big blind 2.15:1, in 9px bold text a
|
||||
dealer is meant to read at a glance across a table.
|
||||
@@ -1642,6 +1686,46 @@ button,
|
||||
color: #f0b46a;
|
||||
}
|
||||
|
||||
/* The settle-up list is read out loud, one line per payment, so it's laid out like
|
||||
the bank rows above it: name on the left, money hard right on tabular figures so a
|
||||
column of amounts lines up on the decimal point. */
|
||||
.settleUpList {
|
||||
list-style: none;
|
||||
margin: 0.35rem 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settleUpRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.18rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.settleUpRow:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.settleUpWho {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.settleUpAmount {
|
||||
color: #f2d489;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Same amber the bank check uses when the columns don't agree — it's the same fault
|
||||
being reported twice, and it should look like it. */
|
||||
.fieldHint.settleUpOff {
|
||||
color: #f0b46a;
|
||||
}
|
||||
|
||||
.standingsHeader {
|
||||
color: #8a8a8a;
|
||||
font-size: 0.7rem;
|
||||
@@ -2126,6 +2210,23 @@ body.hasActionBar #holdemSidebar {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* "ALL IN" takes the stack line's place, so it has to not read as a stack. Wider
|
||||
letters and a warm colour separate it from the grey money above it at a glance —
|
||||
which is the whole job, because the dealer is scanning the ring for who can still
|
||||
be bet at. */
|
||||
.seatPuckStack.seatPuckAllIn {
|
||||
color: #ffcf6b;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* And the seat itself carries a hint of it, so the mark survives being read out of
|
||||
the corner of an eye. Deliberately quieter than .seatActing — all-in is a standing
|
||||
fact about a player, not a request for the dealer's attention. */
|
||||
.seat.seatAllIn {
|
||||
border-color: rgba(255, 207, 107, 0.45);
|
||||
}
|
||||
|
||||
/* Tournament-sized amounts. A number has no spaces to break on, so an amount too
|
||||
wide for the 64px puck neither wraps nor ellipsises — it draws straight out over
|
||||
the seat next to it. These step the size down so every digit stays on its own
|
||||
@@ -2504,6 +2605,16 @@ body.hasActionBar #toastHost {
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
/* The narrow layout already had an answer for the bar: it reserves the bar's height
|
||||
as scroll room below the table, and a phone is a thing you scroll. Capping the
|
||||
felt here as well was measured and rejected — at 360x640 it took the table from
|
||||
321px to 226px, which fixed the six-seat ring but broke the nine-seat one, two
|
||||
covered seats traded for two seats whose centre landed on a neighbour. The cap is
|
||||
for the wide layout, which never consulted the bar's height at all. */
|
||||
#pokerTableWrap {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
|
||||
.potWizardActions {
|
||||
flex-direction: column-reverse;
|
||||
@@ -2519,8 +2630,9 @@ body.hasActionBar #toastHost {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* One column, so the table has the full width and can afford to be round. */
|
||||
#pokerTableWrap {
|
||||
padding-bottom: 100%;
|
||||
--feltAspect: 1;
|
||||
}
|
||||
|
||||
.seat {
|
||||
@@ -2845,7 +2957,11 @@ body.hasActionBar #toastHost {
|
||||
width: 100%;
|
||||
height: var(--feltRoom);
|
||||
min-height: 7rem;
|
||||
padding-bottom: 0;
|
||||
/* This branch sets its own height outright, so neither the aspect ratio nor the
|
||||
cap derived from it has anything to say here — and the cap would only narrow
|
||||
a table that is already as short as the screen allows. */
|
||||
aspect-ratio: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* The header ate 148px of a 390px-tall window, which was most of the reason the
|
||||
|
||||
@@ -29,6 +29,7 @@ const ActionBar = {
|
||||
const visible = !host.hidden;
|
||||
const height = visible ? host.getBoundingClientRect().height : 0;
|
||||
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
|
||||
this.publishFeltRoom(height);
|
||||
};
|
||||
|
||||
if (typeof ResizeObserver === "function") {
|
||||
@@ -40,6 +41,36 @@ const ActionBar = {
|
||||
publish();
|
||||
},
|
||||
|
||||
// Half a seat, the gap left under the lowest one, and the smallest felt worth
|
||||
// drawing. A seat is about 73px tall at every width — the puck is text-sized, not
|
||||
// table-sized — so the overhang is a constant rather than something read back off
|
||||
// the layout this is about to change. Measuring it would mean shrinking the felt,
|
||||
// moving the seats, and getting a different answer on the next pass.
|
||||
SEAT_OVERHANG_PX: 37,
|
||||
FELT_GAP_PX: 8,
|
||||
FELT_MIN_PX: 220,
|
||||
|
||||
// How tall the felt can be and still leave the whole ring of seats above the bar.
|
||||
//
|
||||
// Publishing the bar's height was only half the job. The wide layout never used it:
|
||||
// the felt sizes itself from its own width — a box 72% as tall as it is wide — and
|
||||
// so has no idea what is underneath it. The bar meanwhile grows with the hand, from
|
||||
// 117px to 172px the moment there's a side pot to show, which is exactly when the
|
||||
// dealer most needs to see who is still in. Measured with two side pots, the bottom
|
||||
// seat sat 22px into the bar at 1280x900, 54px at 1600x1000, 23px at 768x1024.
|
||||
//
|
||||
// The wrap's offset is taken in document space rather than viewport space, so
|
||||
// scrolling the page doesn't resize the table under the dealer's hands.
|
||||
publishFeltRoom(barHeight) {
|
||||
const wrap = document.getElementById("pokerTableWrap");
|
||||
if (!wrap) return;
|
||||
const topInDocument = wrap.getBoundingClientRect().top + window.scrollY;
|
||||
const room = window.innerHeight - barHeight - topInDocument -
|
||||
this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
|
||||
document.documentElement.style.setProperty(
|
||||
"--feltRoomPx", `${Math.max(this.FELT_MIN_PX, Math.round(room))}px`);
|
||||
},
|
||||
|
||||
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
|
||||
// still holds during setup, because a seated player is a live seat whether or not
|
||||
// the betting has been opened. An empty table stays uncluttered.
|
||||
@@ -98,8 +129,12 @@ const ActionBar = {
|
||||
row.appendChild(pot);
|
||||
|
||||
// How many are still in the hand — part of the standard per-street
|
||||
// announcement, and the number a dealer is asked for most often.
|
||||
const remaining = Betting.liveSeats(this.state).length;
|
||||
// announcement, and the number a dealer is asked for most often. Before the
|
||||
// deal it counts the table as it will be dealt, so a busted player waiting on a
|
||||
// rebuy isn't announced as one of the players. Read-only: the sitting-out
|
||||
// itself happens when the hand is dealt, not while the bar is being drawn.
|
||||
const counted = this.state.bettingOpen ? this.state : Betting.tableAsDealt(this.state);
|
||||
const remaining = Betting.liveSeats(counted).length;
|
||||
const players = document.createElement("span");
|
||||
players.className = "actionPlayers";
|
||||
players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`;
|
||||
@@ -310,7 +345,10 @@ const ActionBar = {
|
||||
}
|
||||
|
||||
if (stack > owed) {
|
||||
row.appendChild(this.button(owed > 0 ? "Raise" : "Bet", "actionRaise", () => {
|
||||
// Not "does this player owe anything" — the big blind owes nothing and is
|
||||
// still raising. It's whether there is a live bet to raise.
|
||||
const raising = Betting.wagerVerb(this.state) === "raise";
|
||||
row.appendChild(this.button(raising ? "Raise" : "Bet", "actionRaise", () => {
|
||||
this.raiseOpen = true;
|
||||
this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack);
|
||||
this.render();
|
||||
@@ -343,6 +381,7 @@ const ActionBar = {
|
||||
// or a player whose whole stack is under a full raise never gets told that.
|
||||
const legalMin = Betting.minRaiseTo(this.state);
|
||||
const minTo = Math.min(legalMin, maxTo);
|
||||
const raising = Betting.wagerVerb(this.state) === "raise";
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "raisePad";
|
||||
@@ -375,7 +414,7 @@ const ActionBar = {
|
||||
input.inputMode = "decimal";
|
||||
input.className = "raiseInput";
|
||||
input.value = this.raiseDraft ?? minTo;
|
||||
input.setAttribute("aria-label", "Raise to amount");
|
||||
input.setAttribute("aria-label", raising ? "Raise to amount" : "Bet amount");
|
||||
input.addEventListener("input", () => {
|
||||
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
|
||||
this.updateRaiseNote(wrap, legalMin, maxTo);
|
||||
@@ -387,11 +426,16 @@ const ActionBar = {
|
||||
this.render();
|
||||
}));
|
||||
|
||||
entry.appendChild(this.button("Raise to", "raiseConfirm", () => {
|
||||
// "Raise to $60" and "Bet $10" — a bet isn't *to* anything, there's nothing
|
||||
// under it to raise past.
|
||||
entry.appendChild(this.button(raising ? "Raise to" : "Bet", "raiseConfirm", () => {
|
||||
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
|
||||
this.raiseOpen = false;
|
||||
const who = HoldemState.displayName(player, index);
|
||||
this.commit(`${who}'s raise to ${Money.format(target)}`, () => Betting.raiseTo(this.state, index, target));
|
||||
const label = raising
|
||||
? `${who}'s raise to ${Money.format(target)}`
|
||||
: `${who}'s bet of ${Money.format(target)}`;
|
||||
this.commit(label, () => Betting.raiseTo(this.state, index, target));
|
||||
}));
|
||||
|
||||
wrap.appendChild(entry);
|
||||
@@ -420,6 +464,7 @@ const ActionBar = {
|
||||
|
||||
const typed = Number(this.raiseDraft);
|
||||
const current = this.state.currentBet || 0;
|
||||
const raising = Betting.wagerVerb(this.state) === "raise";
|
||||
|
||||
if (!Number.isFinite(typed) || typed <= 0) { note.hidden = true; return; }
|
||||
|
||||
@@ -428,16 +473,20 @@ const ActionBar = {
|
||||
const isAllIn = Math.abs(typed - maxTo) < Betting.EPSILON_CHIPS;
|
||||
|
||||
if (typed > current && typed < minTo - Betting.EPSILON_CHIPS && !isAllIn) {
|
||||
note.textContent =
|
||||
`${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`;
|
||||
// The way out of an under-minimum differs: with a bet in front of you the
|
||||
// alternative is calling it, and with an empty pot there's nothing to call.
|
||||
note.textContent = raising
|
||||
? `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`
|
||||
: `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal bet is ${Money.format(minTo)} or more — otherwise it's a check, or all-in.`;
|
||||
note.className = "raiseNote raiseNoteWarn";
|
||||
note.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAllIn && typed > current && typed < minTo - Betting.EPSILON_CHIPS) {
|
||||
note.textContent =
|
||||
`All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`;
|
||||
note.textContent = raising
|
||||
? `All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`
|
||||
: `All-in for less than a full bet. Legal, but it doesn't reopen the betting — anyone who has already checked can call it or fold, not raise.`;
|
||||
note.className = "raiseNote raiseNoteInfo";
|
||||
note.hidden = false;
|
||||
return;
|
||||
@@ -472,22 +521,35 @@ const ActionBar = {
|
||||
|
||||
const preview = Betting.blindPreview(this.state);
|
||||
const seated = Betting.liveSeats(this.state).length;
|
||||
const broke = Betting.brokeSeats(this.state);
|
||||
const name = i => HoldemState.displayName(this.state.seats[i], i);
|
||||
const listNames = indexes => indexes.length === 1
|
||||
? name(indexes[0])
|
||||
: `${indexes.slice(0, -1).map(name).join(", ")} and ${name(indexes[indexes.length - 1])}`;
|
||||
|
||||
const line = document.createElement("p");
|
||||
line.className = "dealPromptLine";
|
||||
|
||||
if (!preview) {
|
||||
// Two players is the least you can deal. Say what's missing rather than
|
||||
// offering a button that would quietly do nothing.
|
||||
line.textContent = seated === 0
|
||||
? "Nobody is dealt in yet. Tap a seat to add a player."
|
||||
: "Only one player is dealt in — a hand needs two.";
|
||||
// offering a button that would quietly do nothing — and say the *right*
|
||||
// thing: "only one player is dealt in" is wrong when four are sitting there
|
||||
// and three of them are just out of chips.
|
||||
const withChips = seated - broke.length;
|
||||
if (broke.length > 0 && withChips < 2) {
|
||||
line.textContent = withChips === 1
|
||||
? `Only ${listNames(Betting.liveSeats(this.state).map(s => s.index).filter(i => !broke.includes(i)))} has chips — ${listNames(broke)} ${broke.length === 1 ? "needs" : "need"} to buy in.`
|
||||
: "Nobody at the table has chips left. Add a buy-in to deal.";
|
||||
} else {
|
||||
line.textContent = seated === 0
|
||||
? "Nobody is dealt in yet. Tap a seat to add a player."
|
||||
: "Only one player is dealt in — a hand needs two.";
|
||||
}
|
||||
line.classList.add("dealPromptBlocked");
|
||||
wrap.appendChild(line);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const name = i => HoldemState.displayName(this.state.seats[i], i);
|
||||
line.textContent =
|
||||
`${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
|
||||
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
|
||||
@@ -505,6 +567,16 @@ const ActionBar = {
|
||||
wrap.appendChild(moved);
|
||||
}
|
||||
|
||||
// Dealing sits out anyone with nothing behind. Said before it happens, for the
|
||||
// same reason the button move is: a dealer shouldn't find out from the felt.
|
||||
if (preview.satOut.length > 0) {
|
||||
const out = document.createElement("p");
|
||||
out.className = "dealPromptLine dealPromptAside";
|
||||
out.textContent = `${listNames(preview.satOut)} ${preview.satOut.length === 1 ? "has" : "have"} no chips — ` +
|
||||
`${preview.satOut.length === 1 ? "sitting" : "they sit"} out this hand.`;
|
||||
wrap.appendChild(out);
|
||||
}
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
|
||||
|
||||
@@ -32,28 +32,102 @@ const Betting = {
|
||||
// Who would post if the hand were dealt right now. Used to preview the blinds
|
||||
// while the table is still being set, so a dealer can see where they'll land
|
||||
// before any money moves.
|
||||
// A seat that is dealt in, has nothing in the pot, and has no chips behind.
|
||||
//
|
||||
// Not the same as all-in: an all-in player has money riding on the hand. This is
|
||||
// somebody who busted and hasn't bought back in yet, which a home game produces
|
||||
// about once an hour. A seat with no stack *recorded* isn't broke either —
|
||||
// stackOf falls back to the table's starting stack for those — only an explicit
|
||||
// zero counts.
|
||||
brokeSeats(state) {
|
||||
const broke = [];
|
||||
state.seats.forEach((player, index) => {
|
||||
if (!player.inHand) return;
|
||||
if (this.handTotal(player) > this.EPSILON_CHIPS) return;
|
||||
if (this.stackOf(state, player) > this.EPSILON_CHIPS) return;
|
||||
broke.push(index);
|
||||
});
|
||||
return broke;
|
||||
},
|
||||
|
||||
// The table as it will be once the seats that can't post are dealt around. A view,
|
||||
// not a change: the preview has to ask the same question the deal will answer, and
|
||||
// asking it of a different shape of table is how the two came apart last time.
|
||||
tableAsDealt(state) {
|
||||
const broke = this.brokeSeats(state);
|
||||
if (broke.length === 0) return state;
|
||||
const skip = new Set(broke);
|
||||
return {
|
||||
...state,
|
||||
seats: state.seats.map((player, index) =>
|
||||
skip.has(index) ? { ...player, inHand: false, folded: false } : player),
|
||||
};
|
||||
},
|
||||
|
||||
blindPreview(state) {
|
||||
// Dealing relocates a button parked on an empty chair before it works out who
|
||||
// posts, so the preview has to answer for the button where it *will* be. Asking
|
||||
// from where it currently sits gave a different pair of blinds than the deal
|
||||
// produced — the preview promising one thing and doing another is exactly the
|
||||
// guessing this whole step exists to remove.
|
||||
const buttonIndex = HoldemState.buttonSeatForDeal(state);
|
||||
const asDealt = buttonIndex === state.dealerSeatIndex
|
||||
? state
|
||||
: { ...state, dealerSeatIndex: buttonIndex };
|
||||
// guessing this whole step exists to remove. Same reason the broke seats come
|
||||
// out first: the preview once promised a small blind from a seat with no chips.
|
||||
const dealt = this.tableAsDealt(state);
|
||||
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
|
||||
const asDealt = buttonIndex === dealt.dealerSeatIndex
|
||||
? dealt
|
||||
: { ...dealt, dealerSeatIndex: buttonIndex };
|
||||
|
||||
const satOut = this.brokeSeats(state);
|
||||
const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt);
|
||||
if (sbIndex === null || bbIndex === null) return null;
|
||||
return {
|
||||
sbIndex, bbIndex, headsUp, manual: !!manual,
|
||||
buttonIndex,
|
||||
buttonMoves: buttonIndex !== state.dealerSeatIndex,
|
||||
satOut,
|
||||
smallBlind: this.round(state.smallBlind || 0),
|
||||
bigBlind: this.round(state.bigBlind || 0),
|
||||
};
|
||||
},
|
||||
|
||||
// Does handing this seat chips also put them back in the game?
|
||||
//
|
||||
// At a real table those are one event, and here they were two: a busted player got
|
||||
// their chips and stayed sat out, silently skipped hand after hand until somebody
|
||||
// noticed and found "Deal in" in the seat menu. Nothing said so — the toast just
|
||||
// read "Ben added $100".
|
||||
//
|
||||
// Not for a seat that was sat out while *holding* chips. That's a dealer who sat
|
||||
// somebody out on purpose and is topping them up, and dealing them back in would be
|
||||
// the tool guessing at something it wasn't told. Only a seat with no stack of its
|
||||
// own: busted at zero, or a chair being bought into for the first time.
|
||||
//
|
||||
// Deliberately reads player.stack rather than stackOf(), which substitutes the
|
||||
// table's starting stack for a seat that has none — the question here is what this
|
||||
// seat was actually recorded as holding.
|
||||
buyInDealsThemIn(player) {
|
||||
if (!player || player.inHand) return false;
|
||||
const own = player.stack;
|
||||
const recorded = own !== null && own !== undefined && String(own).trim() !== "";
|
||||
return !recorded || Number(own) <= this.EPSILON_CHIPS;
|
||||
},
|
||||
|
||||
// "bet" or "raise" — what putting more in is actually called right now.
|
||||
//
|
||||
// A wager into a pot nobody has wagered into is a bet; everything after that is a
|
||||
// raise. The bar decided this from what the acting player still owes, which gets
|
||||
// the big blind's option backwards: they owe nothing, so it offered "Bet", but
|
||||
// their own blind is a live bet and putting more in is a raise. It gets the other
|
||||
// half wrong too — the raise pad said "Raise to" for an opening bet on the flop,
|
||||
// and wrote "Ann's raise to $10" into the undo control for it.
|
||||
//
|
||||
// The arithmetic was always right (minRaiseTo returns two big blinds for the big
|
||||
// blind's option and one for an opening bet). This is only what it's called, which
|
||||
// for a tool a dealer announces the action from is not a small thing.
|
||||
wagerVerb(state) {
|
||||
return (state.currentBet || 0) > this.EPSILON_CHIPS ? "raise" : "bet";
|
||||
},
|
||||
|
||||
committed(player) {
|
||||
return this.round(player.committed || 0);
|
||||
},
|
||||
@@ -117,6 +191,20 @@ const Betting = {
|
||||
return null;
|
||||
},
|
||||
|
||||
// A player still in the hand with chips in the pot and nothing left behind. This is
|
||||
// the same stack test nextToAct uses to pass over them, which is the point: the
|
||||
// seat the felt marks all-in is exactly the seat the dealer will never be asked to
|
||||
// act on again.
|
||||
//
|
||||
// The "chips in the pot" half matters. A seat with an empty stack who hasn't put
|
||||
// anything in is a busted player who shouldn't have been dealt in, not a player
|
||||
// who moved all-in, and calling them all-in on the felt would be a lie.
|
||||
isAllIn(state, player) {
|
||||
if (!player || !player.inHand || player.folded) return false;
|
||||
return this.handTotal(player) > this.EPSILON_CHIPS &&
|
||||
this.stackOf(state, player) <= this.EPSILON_CHIPS;
|
||||
},
|
||||
|
||||
// Which chair sits `step` places along in the direction of play. The table owns
|
||||
// this — walking the seats is exactly where the button, the blinds and the action
|
||||
// used to drift apart from each other, so there is one implementation of it.
|
||||
@@ -289,6 +377,11 @@ const Betting = {
|
||||
// True when the hand is still live but nobody can bet again — the dealer is just
|
||||
// running the board out.
|
||||
isRunningOut(state) {
|
||||
// Only while a hand is actually being played. Before the deal, a table of
|
||||
// busted players who haven't rebought looks exactly like a table of all-ins to
|
||||
// this test, and the bar was announcing a run-out over a felt with no cards and
|
||||
// no chips on it.
|
||||
if (!state.bettingOpen) return false;
|
||||
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
|
||||
},
|
||||
|
||||
@@ -326,6 +419,21 @@ const Betting = {
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
|
||||
// Deal around anyone with no chips, before anything else reads the table.
|
||||
//
|
||||
// A busted player who hasn't rebought used to be dealt in anyway, and the
|
||||
// result was a hand nobody could finish: the table promised a small blind from
|
||||
// a seat with nothing to post it with, took it from nobody, put the action on
|
||||
// the big blind with only their own money in front of them, and announced
|
||||
// "3 players — all-in, running out the board" before a card was dealt.
|
||||
//
|
||||
// Sitting them out is what a dealer does. Seat, name and buy-in record are
|
||||
// untouched, so dealing them back in is one tap when they buy chips.
|
||||
this.brokeSeats(state).forEach(index => {
|
||||
state.seats[index].inHand = false;
|
||||
state.seats[index].folded = false;
|
||||
});
|
||||
|
||||
// Position is read off the button all through the hand, so it has to be on
|
||||
// somebody before the blinds are worked out — not on the empty seat 1 a
|
||||
// brand-new table starts on.
|
||||
|
||||
@@ -53,6 +53,27 @@ const HandHistoryExport = {
|
||||
Money.forSheet(row.cashPosition || 0),
|
||||
]);
|
||||
});
|
||||
|
||||
// And the answer the standings are read to get to. Whoever opens this file
|
||||
// wants the payment list, not to redo the arithmetic in a spreadsheet.
|
||||
const plan = SettleUp.plan(standings);
|
||||
if (plan.payments.length > 0) {
|
||||
rows.push([]);
|
||||
rows.push(["Settle Up"]);
|
||||
rows.push(["Pays", "Receives", "Amount"]);
|
||||
plan.payments.forEach(payment => {
|
||||
rows.push([payment.from, payment.to, Money.forSheet(payment.amount)]);
|
||||
});
|
||||
if (!plan.balanced) {
|
||||
// The file is the paper trail, so the caveat travels with it rather
|
||||
// than living only in a sidebar nobody exported.
|
||||
rows.push([
|
||||
plan.shortBy > 0
|
||||
? `Unpaid: ${Money.forSheet(plan.shortBy)} — more chips in play than cash taken`
|
||||
: `Unclaimed: ${Money.forSheet(plan.overBy)} — more cash taken than chips in play`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
|
||||
|
||||
@@ -140,11 +140,27 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
editSeat: index => Render.toggleSeatEditor(index),
|
||||
|
||||
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
|
||||
seatAction(seatIndex, `${nameOf(seatIndex)} added ${Money.format(amount)}.`, player => {
|
||||
// Buying chips puts you back in the game — see Betting.buyInDealsThemIn.
|
||||
const dealBackIn = Betting.buyInDealsThemIn(state.seats[seatIndex]);
|
||||
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
|
||||
|
||||
const message = `${nameOf(seatIndex)} added ${Money.format(amount)}` +
|
||||
(dealBackIn
|
||||
? handRunning ? " — in from the next hand." : " — dealt in."
|
||||
: ".");
|
||||
|
||||
seatAction(seatIndex, message, p => {
|
||||
// Chips in front and cash in are different questions — the stack moves
|
||||
// every hand, what they've handed over only moves when they buy.
|
||||
player.stack = Money.round(Betting.stackOf(state, player) + amount);
|
||||
player.buyIns = Money.round((Number(player.buyIns) || 0) + amount);
|
||||
p.stack = Money.round(Betting.stackOf(state, p) + amount);
|
||||
p.buyIns = Money.round((Number(p.buyIns) || 0) + amount);
|
||||
|
||||
if (dealBackIn) {
|
||||
p.inHand = true;
|
||||
// Same rule "Deal in" follows: you can't buy into a pot you haven't
|
||||
// been paying for, and `folded` is exactly that state for one hand.
|
||||
p.folded = handRunning;
|
||||
}
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -171,6 +171,16 @@ const Render = {
|
||||
node.style.top = `${pos.top}%`;
|
||||
node.dataset.seatIndex = String(index);
|
||||
|
||||
// Which side of the seat the pot is on, which is the side its bet chip
|
||||
// arrives from — the chip sits on the same spoke, closer in. For a seat on
|
||||
// the top of the ring that's from below; on the bottom, from above.
|
||||
//
|
||||
// The blind badge has to be on the other side or the chip lands on top of
|
||||
// it. Measured on a 390px phone: the big blind's own $2 chip covered 196 of
|
||||
// the badge's 288 square pixels, and it sits a layer higher, so the marker
|
||||
// telling the dealer who the big blind is was simply gone.
|
||||
node.classList.toggle("seatBelowCentre", pos.top > 50);
|
||||
|
||||
node.querySelector(".seatChairNumber").textContent = index + 1;
|
||||
|
||||
const dealerChip = node.querySelector(".dealerChip");
|
||||
@@ -279,11 +289,18 @@ const Render = {
|
||||
totalEl.textContent = total > 0 ? Money.format(total) : "";
|
||||
this.fitAmount(totalEl);
|
||||
|
||||
// A player with nothing behind used to leave this line blank, so the felt drew
|
||||
// an all-in exactly like a seat whose stack nobody had entered. On a table with
|
||||
// side pots that's the one distinction the dealer is working from — who can
|
||||
// still be bet at, and who is already committed for everything they have.
|
||||
const stackEl = node.querySelector(".seatPuckStack");
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
|
||||
const allIn = Betting.isAllIn(this.state, player);
|
||||
stackEl.textContent = empty ? "" : allIn ? "ALL IN" : stack > 0 ? Money.format(stack) : "";
|
||||
stackEl.classList.toggle("seatPuckAllIn", allIn);
|
||||
this.fitAmount(stackEl);
|
||||
|
||||
node.classList.toggle("seatAllIn", allIn);
|
||||
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
||||
node.classList.toggle("seatEmpty", empty);
|
||||
node.classList.toggle("seatInactive", !player.inHand && !empty);
|
||||
@@ -723,6 +740,58 @@ const Render = {
|
||||
});
|
||||
|
||||
this.renderBankReconciliation();
|
||||
this.renderSettleUp(standings);
|
||||
},
|
||||
|
||||
// The last step the standings stopped short of. Six signed numbers at 2am is not
|
||||
// the answer to "who pays whom" — it's the arithmetic the dealer still has to do,
|
||||
// and it's the part of the night people actually get wrong.
|
||||
renderSettleUp(standings) {
|
||||
const host = document.getElementById("settleUp");
|
||||
if (!host) return;
|
||||
|
||||
const plan = SettleUp.plan(standings);
|
||||
const note = document.getElementById("settleUpNote");
|
||||
const list = document.getElementById("settleUpList");
|
||||
|
||||
// Nothing to show before anyone is up or down. A panel that says "no payments"
|
||||
// to a table that hasn't played a hand is just noise in the sidebar.
|
||||
host.hidden = plan.payments.length === 0 && plan.balanced;
|
||||
if (host.hidden) return;
|
||||
|
||||
list.innerHTML = "";
|
||||
plan.payments.forEach(payment => {
|
||||
const row = document.createElement("li");
|
||||
row.className = "settleUpRow";
|
||||
|
||||
const who = document.createElement("span");
|
||||
who.className = "settleUpWho";
|
||||
// An arrow rather than "pays": the direction is the whole content of the
|
||||
// line, and it survives being read at a glance from across the table.
|
||||
who.textContent = `${payment.from} → ${payment.to}`;
|
||||
row.appendChild(who);
|
||||
|
||||
const amount = document.createElement("span");
|
||||
amount.className = "settleUpAmount";
|
||||
amount.textContent = Money.format(payment.amount);
|
||||
row.appendChild(amount);
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
if (plan.balanced) {
|
||||
const count = plan.payments.length;
|
||||
note.textContent = count === 1
|
||||
? "One payment and the night is square."
|
||||
: `${count} payments and the night is square.`;
|
||||
} else if (plan.shortBy > 0) {
|
||||
note.textContent =
|
||||
`${Money.format(plan.shortBy)} of winnings has nothing to pay it — there are more chips on the table than cash was taken for. Fix that above before anyone cashes out.`;
|
||||
} else {
|
||||
note.textContent =
|
||||
`${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
|
||||
}
|
||||
note.classList.toggle("settleUpOff", !plan.balanced);
|
||||
},
|
||||
|
||||
// Does the cash box cover the chips on the table? Nothing asked this before, and
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Who hands cash to whom at the end of the night.
|
||||
//
|
||||
// The standings panel already works out what everyone is up or down. It then stopped,
|
||||
// which leaves the dealer with six signed numbers at 2am and the actual job still to
|
||||
// do: turning them into payments. That's the step people get wrong — not the poker.
|
||||
//
|
||||
// Everything is done in cents. A settlement that has to come out exactly right is the
|
||||
// last place to let a float land on 41.99999999.
|
||||
const SettleUp = {
|
||||
// Largest debt against largest win, repeatedly. It's greedy rather than optimal,
|
||||
// and for this problem that's the right trade: finding the true minimum number of
|
||||
// transfers is NP-hard, while this is guaranteed to need at most one payment fewer
|
||||
// than there are people, and each payment it does emit clears somebody out of the
|
||||
// list entirely. A dealer wants a short list they can read down, not a proof.
|
||||
//
|
||||
// Payers are the outer loop so that everything one person owes is printed together
|
||||
// — the list is read out loud, one player at a time.
|
||||
plan(standings) {
|
||||
const owed = [];
|
||||
const owing = [];
|
||||
|
||||
(standings || []).forEach(row => {
|
||||
const cents = Money.toCents(row.cashPosition);
|
||||
if (cents > 0) owed.push({ name: row.name, cents });
|
||||
else if (cents < 0) owing.push({ name: row.name, cents: -cents });
|
||||
});
|
||||
|
||||
owed.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
|
||||
owing.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
|
||||
|
||||
const payments = [];
|
||||
let creditor = 0;
|
||||
owing.forEach(payer => {
|
||||
while (payer.cents > 0 && creditor < owed.length) {
|
||||
const taker = owed[creditor];
|
||||
const amount = Math.min(payer.cents, taker.cents);
|
||||
payments.push({ from: payer.name, to: taker.name, amount: Money.fromCents(amount) });
|
||||
payer.cents -= amount;
|
||||
taker.cents -= amount;
|
||||
if (taker.cents === 0) creditor++;
|
||||
}
|
||||
});
|
||||
|
||||
// What's left over when the chips on the table don't match the cash that came
|
||||
// in. It is never a rounding artifact — the arithmetic is integer cents — so it
|
||||
// is always the thing the bank check is already warning about: a rebuy nobody
|
||||
// wrote down, or a miscount. Reported rather than quietly spread across the
|
||||
// payments, because inventing a transfer to make the columns agree is how a
|
||||
// real person ends up short.
|
||||
const unpaid = Money.fromCents(owed.reduce((sum, row) => sum + row.cents, 0));
|
||||
const unspent = Money.fromCents(owing.reduce((sum, row) => sum + row.cents, 0));
|
||||
|
||||
return {
|
||||
payments,
|
||||
// Only one of these can be non-zero: either there are more chips than cash
|
||||
// or less, never both.
|
||||
shortBy: unpaid,
|
||||
overBy: unspent,
|
||||
balanced: unpaid === 0 && unspent === 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = SettleUp;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Who the felt marks "ALL IN".
|
||||
//
|
||||
// The mark's whole value is that a dealer can scan the ring and see who can still be
|
||||
// bet at, so the test that matters most is the last one: the seats it marks are
|
||||
// exactly the seats the betting round refuses to ask for another chip.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
test("a player who moved all-in is marked", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[3].stack = 12;
|
||||
Betting.beginHand(state);
|
||||
Betting.allIn(state, state.actingSeatIndex);
|
||||
|
||||
assert.equal(Betting.isAllIn(state, state.seats[3]), true);
|
||||
assert.equal(state.seats[3].stack, 0);
|
||||
});
|
||||
|
||||
test("a player with chips behind is not", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.isAllIn(state, state.seats[0]), false);
|
||||
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
|
||||
});
|
||||
|
||||
test("a busted player who was dealt in but has put nothing in is not", () => {
|
||||
// An empty stack alone isn't all-in — it's someone who shouldn't have been dealt
|
||||
// in. Calling that "ALL IN" on the felt would be a lie about a live pot.
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
|
||||
});
|
||||
|
||||
test("a folded player with an empty stack is not", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[1].committed = 5;
|
||||
state.seats[1].stack = 0;
|
||||
state.seats[1].folded = true;
|
||||
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
|
||||
});
|
||||
|
||||
test("an empty chair is not", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
assert.equal(Betting.isAllIn(state, state.seats[4]), false);
|
||||
});
|
||||
|
||||
test("a seat with no stack of its own falls back to the table's and isn't marked", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||
state.defaultStack = 200;
|
||||
state.seats[2].stack = null;
|
||||
state.seats[2].committed = 10;
|
||||
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
|
||||
});
|
||||
|
||||
test("the seats marked are exactly the ones the action passes over", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[1].stack = 8;
|
||||
state.seats[3].stack = 30;
|
||||
Betting.beginHand(state);
|
||||
|
||||
// Everyone in, two of them for less than the others can cover.
|
||||
for (let step = 0; step < 4; step++) Betting.allIn(state, state.actingSeatIndex);
|
||||
|
||||
const marked = state.seats
|
||||
.map((player, index) => (Betting.isAllIn(state, player) ? index : null))
|
||||
.filter(index => index !== null);
|
||||
const skipped = state.seats
|
||||
.map((player, index) =>
|
||||
player.inHand && !player.folded && Betting.nextToAct(state, index - 1) !== index ? index : null)
|
||||
.filter(index => index !== null);
|
||||
|
||||
assert.deepEqual(marked, skipped);
|
||||
assert.ok(marked.length > 0, "the scenario should actually produce all-in players");
|
||||
});
|
||||
|
||||
test("nobody is marked once the next hand starts", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 5;
|
||||
Betting.beginHand(state);
|
||||
Betting.allIn(state, state.actingSeatIndex);
|
||||
assert.ok(state.seats.some(player => Betting.isAllIn(state, player)));
|
||||
|
||||
// The short stack got there by winning nothing; give them chips back the way a
|
||||
// dealer would before the next deal.
|
||||
HoldemState.startNextHand(state);
|
||||
state.seats[2].stack = 50;
|
||||
assert.equal(state.seats.filter(player => Betting.isAllIn(state, player)).length, 0);
|
||||
});
|
||||
|
||||
test("marking nobody doesn't move a chip", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[3].stack = 12;
|
||||
Betting.beginHand(state);
|
||||
const before = chipsOnTable(state);
|
||||
state.seats.forEach(player => Betting.isAllIn(state, player));
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// Dealing around a player who busted and hasn't bought back in.
|
||||
//
|
||||
// A home game produces one of these about once an hour. The table used to deal them
|
||||
// in anyway, promise a blind from a seat with nothing to post it with, take it from
|
||||
// nobody, and then sit on a hand that could never finish.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const postedBy = state =>
|
||||
state.seats
|
||||
.map((player, index) => (player.committed > 0 ? { seat: index + 1, amount: player.committed } : null))
|
||||
.filter(Boolean);
|
||||
|
||||
test("a seat with no chips and nothing in the pot is broke", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
assert.deepEqual(Betting.brokeSeats(state), [2]);
|
||||
});
|
||||
|
||||
test("an all-in player is not broke — they have money riding on the hand", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 12;
|
||||
Betting.beginHand(state);
|
||||
Betting.allIn(state, 2);
|
||||
|
||||
assert.equal(Betting.stackOf(state, state.seats[2]), 0);
|
||||
assert.equal(Betting.isAllIn(state, state.seats[2]), true);
|
||||
assert.deepEqual(Betting.brokeSeats(state), []);
|
||||
});
|
||||
|
||||
test("a seat with no stack recorded falls back to the table's and isn't broke", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.defaultStack = 200;
|
||||
state.seats[2].stack = null;
|
||||
assert.deepEqual(Betting.brokeSeats(state), []);
|
||||
});
|
||||
|
||||
test("a chair nobody is sitting in isn't broke, it's empty", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
assert.deepEqual(Betting.brokeSeats(state), []);
|
||||
});
|
||||
|
||||
test("dealing sits the broke seat out and leaves everything else about it alone", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
state.seats[2].name = "Cal";
|
||||
state.seats[2].buyIns = 100;
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[2].inHand, false);
|
||||
assert.equal(state.seats[2].folded, false, "sat out, not folded — they didn't muck anything");
|
||||
assert.equal(state.seats[2].name, "Cal");
|
||||
assert.equal(state.seats[2].buyIns, 100);
|
||||
});
|
||||
|
||||
test("the blinds skip the broke seat and land on players who can post", () => {
|
||||
// Button on seat 1, so the blinds would be seats 2 and 3. Seat 2 is busted.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[1].stack = 0;
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.deepEqual(postedBy(state), [
|
||||
{ seat: 3, amount: state.smallBlind },
|
||||
{ seat: 4, amount: state.bigBlind },
|
||||
]);
|
||||
});
|
||||
|
||||
test("the preview promises what the deal produces", () => {
|
||||
// The fault this whole step exists to prevent: the preview once read a table the
|
||||
// deal was about to change underneath it.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[1].stack = 0;
|
||||
state.bettingOpen = false;
|
||||
|
||||
const preview = Betting.blindPreview(state);
|
||||
assert.deepEqual(preview.satOut, [1]);
|
||||
|
||||
Betting.beginHand(state);
|
||||
const posted = postedBy(state);
|
||||
assert.equal(posted.find(p => p.amount === state.smallBlind).seat, preview.sbIndex + 1);
|
||||
assert.equal(posted.find(p => p.amount === state.bigBlind).seat, preview.bbIndex + 1);
|
||||
});
|
||||
|
||||
test("asking for the preview doesn't sit anybody out on its own", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[1].stack = 0;
|
||||
|
||||
Betting.blindPreview(state);
|
||||
|
||||
assert.equal(state.seats[1].inHand, true, "the preview is a view, not a change");
|
||||
});
|
||||
|
||||
test("with only one player left holding chips there is nothing to preview", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[1].stack = 0;
|
||||
state.seats[2].stack = 0;
|
||||
|
||||
assert.equal(Betting.blindPreview(state), null);
|
||||
});
|
||||
|
||||
test("no chips move when the broke seats are dealt around", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
|
||||
test("the board isn't running out before the hand is dealt", () => {
|
||||
// Two busted players and one with chips looks exactly like a table of all-ins to
|
||||
// the run-out test, and the bar was announcing one over an empty felt.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[1].stack = 0;
|
||||
state.seats[2].stack = 0;
|
||||
state.bettingOpen = false;
|
||||
|
||||
assert.equal(Betting.isRunningOut(state), false);
|
||||
});
|
||||
|
||||
test("buying back in puts them in the next hand", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.seats[2].inHand, false);
|
||||
|
||||
// What the dealer does when they hand over cash: chips, and back in.
|
||||
state.seats[2].stack = 100;
|
||||
state.seats[2].inHand = true;
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[2].inHand, true);
|
||||
assert.equal(Betting.liveSeats(state).length, 3);
|
||||
});
|
||||
|
||||
test("the table dealt around them counts the right number of players", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[3].stack = 0;
|
||||
|
||||
assert.equal(Betting.liveSeats(state).length, 4);
|
||||
assert.equal(Betting.liveSeats(Betting.tableAsDealt(state)).length, 3);
|
||||
assert.equal(state.seats[3].inHand, true, "the view didn't touch the table");
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// Whether handing a seat chips also puts them back in the game.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, makeTable } = require("./load");
|
||||
|
||||
test("a busted player who has been sat out comes back in", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[1].stack = 0;
|
||||
Betting.beginHand(state); // the deal sits them out
|
||||
|
||||
assert.equal(state.seats[1].inHand, false);
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[1]), true);
|
||||
});
|
||||
|
||||
test("a chair being bought into for the first time comes in", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
state.seats[4].name = "Eve";
|
||||
state.seats[4].stack = null;
|
||||
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
|
||||
});
|
||||
|
||||
test("a seat sat out while holding chips stays out", () => {
|
||||
// A dealer sat them out on purpose and is topping them up. Dealing them back in
|
||||
// would be the tool guessing at something it wasn't told.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].inHand = false;
|
||||
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
|
||||
});
|
||||
|
||||
test("somebody already in the hand is left exactly where they are", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[0]), false);
|
||||
|
||||
// Including a player who is in but out of chips — they're all-in, not sat out.
|
||||
state.seats[2].stack = 0;
|
||||
assert.equal(state.seats[2].inHand, true);
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
|
||||
});
|
||||
|
||||
test("a blank stack field reads as nothing recorded, not as a number", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
state.seats[3].name = "Dee";
|
||||
state.seats[3].stack = "";
|
||||
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[3]), true);
|
||||
});
|
||||
|
||||
test("it doesn't ask the table what a missing stack would have been", () => {
|
||||
// stackOf substitutes the table's starting stack for a seat that has none, which
|
||||
// would make a seat with nothing recorded look like it was holding chips.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
state.defaultStack = 500;
|
||||
state.seats[4].name = "Eve";
|
||||
state.seats[4].stack = null;
|
||||
|
||||
assert.equal(Betting.stackOf(state, state.seats[4]), 500);
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
|
||||
});
|
||||
|
||||
test("asking doesn't change anything", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
state.seats[2].inHand = false;
|
||||
const before = JSON.stringify(state);
|
||||
|
||||
Betting.buyInDealsThemIn(state.seats[2]);
|
||||
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
});
|
||||
|
||||
test("nothing to decide about a seat that isn't there", () => {
|
||||
assert.equal(Betting.buyInDealsThemIn(null), false);
|
||||
assert.equal(Betting.buyInDealsThemIn(undefined), false);
|
||||
});
|
||||
@@ -18,6 +18,7 @@ const Money = load("money");
|
||||
const PotCalculator = load("potCalculator");
|
||||
const SplitCalculator = load("splitCalculator");
|
||||
const Betting = load("betting");
|
||||
const SettleUp = load("settleUp");
|
||||
const HandHistoryExport = load("handHistoryExport");
|
||||
|
||||
// render.js exports { Render } rather than the namespace directly.
|
||||
@@ -77,6 +78,7 @@ module.exports = {
|
||||
HandHistoryExport,
|
||||
PotCalculator,
|
||||
SplitCalculator,
|
||||
SettleUp,
|
||||
Betting,
|
||||
HoldemState,
|
||||
makeTable,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Turning the standings into "who hands cash to whom".
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, HoldemState, SettleUp, makeTable } = require("./load");
|
||||
|
||||
const standings = pairs => pairs.map(([name, cashPosition]) => ({ name, cashPosition }));
|
||||
const lines = plan => plan.payments.map(p => `${p.from} -> ${p.to} ${Money.format(p.amount)}`);
|
||||
const paidTo = (plan, name) =>
|
||||
plan.payments.filter(p => p.to === name).reduce((sum, p) => sum + p.amount, 0);
|
||||
const paidBy = (plan, name) =>
|
||||
plan.payments.filter(p => p.from === name).reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
test("one loser, one winner", () => {
|
||||
const plan = SettleUp.plan(standings([["Ann", 40], ["Ben", -40]]));
|
||||
assert.deepEqual(lines(plan), ["Ben -> Ann $40"]);
|
||||
assert.equal(plan.balanced, true);
|
||||
});
|
||||
|
||||
test("everybody flat means nothing to do", () => {
|
||||
const plan = SettleUp.plan(standings([["Ann", 0], ["Ben", 0]]));
|
||||
assert.deepEqual(plan.payments, []);
|
||||
assert.equal(plan.balanced, true);
|
||||
});
|
||||
|
||||
test("nobody at all means nothing to do", () => {
|
||||
const plan = SettleUp.plan([]);
|
||||
assert.deepEqual(plan.payments, []);
|
||||
assert.equal(plan.balanced, true);
|
||||
});
|
||||
|
||||
test("each player ends up square", () => {
|
||||
const table = standings([
|
||||
["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
|
||||
]);
|
||||
const plan = SettleUp.plan(table);
|
||||
|
||||
table.forEach(row => {
|
||||
const settled = paidTo(plan, row.name) - paidBy(plan, row.name);
|
||||
assert.equal(settled, row.cashPosition, `${row.name} should end up square`);
|
||||
});
|
||||
assert.equal(plan.balanced, true);
|
||||
});
|
||||
|
||||
test("at most one payment fewer than there are people", () => {
|
||||
const table = standings([
|
||||
["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
|
||||
]);
|
||||
const plan = SettleUp.plan(table);
|
||||
assert.ok(plan.payments.length <= table.length - 1,
|
||||
`${plan.payments.length} payments for ${table.length} players`);
|
||||
});
|
||||
|
||||
test("a player who owes two people has both lines together", () => {
|
||||
// The list gets read out one player at a time, so a payer's obligations must not
|
||||
// be split up by somebody else's.
|
||||
const plan = SettleUp.plan(standings([["Ann", 50], ["Cal", 30], ["Ben", -80]]));
|
||||
assert.deepEqual(lines(plan), ["Ben -> Ann $50", "Ben -> Cal $30"]);
|
||||
});
|
||||
|
||||
test("cents survive", () => {
|
||||
const plan = SettleUp.plan(standings([["Ann", 0.1], ["Ben", 0.2], ["Cal", -0.3]]));
|
||||
assert.deepEqual(lines(plan), ["Cal -> Ben $0.20", "Cal -> Ann $0.10"]);
|
||||
assert.equal(plan.balanced, true);
|
||||
});
|
||||
|
||||
test("more chips than cash leaves somebody unpaid, and says so", () => {
|
||||
// The table is $25 up on the bank — an unrecorded rebuy or a miscount. The winners
|
||||
// cannot all be paid, and pretending otherwise is how a real person ends up short.
|
||||
const plan = SettleUp.plan(standings([["Ann", 100], ["Ben", -75]]));
|
||||
assert.deepEqual(lines(plan), ["Ben -> Ann $75"]);
|
||||
assert.equal(plan.shortBy, 25);
|
||||
assert.equal(plan.overBy, 0);
|
||||
assert.equal(plan.balanced, false);
|
||||
});
|
||||
|
||||
test("less chips than cash leaves a payer with nowhere to send it", () => {
|
||||
const plan = SettleUp.plan(standings([["Ann", 50], ["Ben", -75]]));
|
||||
assert.deepEqual(lines(plan), ["Ben -> Ann $50"]);
|
||||
assert.equal(plan.overBy, 25);
|
||||
assert.equal(plan.shortBy, 0);
|
||||
assert.equal(plan.balanced, false);
|
||||
});
|
||||
|
||||
test("no payment is ever for nothing", () => {
|
||||
const plan = SettleUp.plan(standings([
|
||||
["Ann", 60], ["Ben", -60], ["Cal", 0], ["Dee", 0],
|
||||
]));
|
||||
assert.ok(plan.payments.every(p => p.amount > 0));
|
||||
assert.equal(plan.payments.length, 1);
|
||||
});
|
||||
|
||||
test("nobody pays themselves", () => {
|
||||
const plan = SettleUp.plan(standings([
|
||||
["Ann", 90], ["Ben", -30], ["Cal", -30], ["Dee", -30],
|
||||
]));
|
||||
assert.ok(plan.payments.every(p => p.from !== p.to));
|
||||
});
|
||||
|
||||
test("the payments total what the winners are owed", () => {
|
||||
const table = standings([["Ann", 42.5], ["Ben", 17.5], ["Cal", -60]]);
|
||||
const plan = SettleUp.plan(table);
|
||||
const moved = plan.payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
assert.equal(Money.round(moved), 60);
|
||||
});
|
||||
|
||||
test("it settles a real table's standings, not just hand-made numbers", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[0].buyIns = 100;
|
||||
state.seats[1].buyIns = 100;
|
||||
state.seats[2].buyIns = 100;
|
||||
state.seats[0].stack = 175;
|
||||
state.seats[1].stack = 90;
|
||||
state.seats[2].stack = 35;
|
||||
|
||||
const rows = HoldemState.sessionStandings(state);
|
||||
const plan = SettleUp.plan(rows);
|
||||
|
||||
// Bank balances — 300 in, 300 on the table — so everyone can be paid.
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
assert.equal(plan.balanced, true);
|
||||
rows.forEach(row => {
|
||||
assert.equal(paidTo(plan, row.name) - paidBy(plan, row.name), row.cashPosition);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// Whether putting more in is called a bet or a raise.
|
||||
//
|
||||
// The arithmetic was always right — minRaiseTo returns two big blinds for the big
|
||||
// blind's option and one for an opening bet. This is only what it's called, which for
|
||||
// a tool a dealer announces the action from is not a small thing.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, makeTable } = require("./load");
|
||||
|
||||
// Walks the preflop action round to the big blind, who is owed nothing and gets to
|
||||
// choose between checking and putting more in.
|
||||
function toTheBigBlindsOption() {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
const bigBlind = state.seats.findIndex(player => Betting.committed(player) === state.bigBlind);
|
||||
while (state.actingSeatIndex !== null && state.actingSeatIndex !== bigBlind) {
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
}
|
||||
return { state, bigBlind };
|
||||
}
|
||||
|
||||
test("the big blind's option is a raise, not a bet", () => {
|
||||
const { state, bigBlind } = toTheBigBlindsOption();
|
||||
|
||||
assert.equal(state.actingSeatIndex, bigBlind);
|
||||
assert.equal(Betting.toCall(state, state.seats[bigBlind]), 0, "they owe nothing");
|
||||
assert.equal(Betting.wagerVerb(state), "raise", "but their own blind is a live bet");
|
||||
});
|
||||
|
||||
test("and the minimum there is a real raise, not a call of the blind", () => {
|
||||
const { state } = toTheBigBlindsOption();
|
||||
assert.equal(Betting.minRaiseTo(state), state.bigBlind * 2);
|
||||
});
|
||||
|
||||
test("facing the blinds preflop is a raise", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.wagerVerb(state), "raise");
|
||||
});
|
||||
|
||||
test("first in on the flop is a bet", () => {
|
||||
const { state, bigBlind } = toTheBigBlindsOption();
|
||||
Betting.check(state, bigBlind);
|
||||
Betting.closeStreet(state);
|
||||
|
||||
assert.equal(state.street, "flop");
|
||||
assert.equal(Betting.wagerVerb(state), "bet");
|
||||
assert.equal(Betting.minRaiseTo(state), state.bigBlind, "a first bet is one big blind");
|
||||
});
|
||||
|
||||
test("after that bet it's a raise again", () => {
|
||||
const { state, bigBlind } = toTheBigBlindsOption();
|
||||
Betting.check(state, bigBlind);
|
||||
Betting.closeStreet(state);
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 10);
|
||||
|
||||
assert.equal(Betting.wagerVerb(state), "raise");
|
||||
});
|
||||
|
||||
test("and a bet again on the next street once it's checked around", () => {
|
||||
const { state, bigBlind } = toTheBigBlindsOption();
|
||||
Betting.check(state, bigBlind);
|
||||
Betting.closeStreet(state);
|
||||
while (state.actingSeatIndex !== null) Betting.check(state, state.actingSeatIndex);
|
||||
Betting.closeStreet(state);
|
||||
|
||||
assert.equal(state.street, "turn");
|
||||
assert.equal(Betting.wagerVerb(state), "bet");
|
||||
});
|
||||
|
||||
test("a table playing without blinds opens with a bet", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, smallBlind: 0, bigBlind: 0 });
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.currentBet, 0);
|
||||
assert.equal(Betting.wagerVerb(state), "bet");
|
||||
});
|
||||
|
||||
test("asking doesn't change anything", () => {
|
||||
const { state } = toTheBigBlindsOption();
|
||||
const before = JSON.stringify(state);
|
||||
Betting.wagerVerb(state);
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
});
|
||||
Reference in New Issue
Block a user