Handle the messy table: joining, leaving, and arguing about blinds #45
@@ -306,8 +306,10 @@ button,
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-bottom: 72%;
|
padding-bottom: 72%;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#pokerTable {
|
#pokerTable {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 6%;
|
inset: 6%;
|
||||||
@@ -627,6 +629,13 @@ button,
|
|||||||
color: #fff7e6;
|
color: #fff7e6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A blind the dealer named by hand rather than one the button handed out. Without
|
||||||
|
the ring the table just looks like it's put the blinds in the wrong place. */
|
||||||
|
.blindBadge.blindManual {
|
||||||
|
outline: 1.5px solid #f2f2f2;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
|
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
|
||||||
|
|
||||||
.seatEditorPanel {
|
.seatEditorPanel {
|
||||||
@@ -1955,8 +1964,13 @@ body.hasActionBar #holdemSidebar {
|
|||||||
|
|
||||||
/* Sit above the action bar rather than on top of it, so an Undo never covers the
|
/* Sit above the action bar rather than on top of it, so an Undo never covers the
|
||||||
Fold button the dealer is reaching for. */
|
Fold button the dealer is reaching for. */
|
||||||
|
/* Sit the toast directly on top of the action bar, whatever height it happens to be.
|
||||||
|
This was a hardcoded 9rem, which is roughly right for the bar at rest and wrong the
|
||||||
|
moment the raise pad opens — and on a short phone it put the toast, Undo button and
|
||||||
|
all, straight over the bottom seats of the table, so a tap meant for a player hit
|
||||||
|
"Undo" instead. */
|
||||||
body.hasActionBar #toastHost {
|
body.hasActionBar #toastHost {
|
||||||
bottom: calc(9rem + env(safe-area-inset-bottom, 0px));
|
bottom: calc(var(--actionBarHeight, 9rem) + 0.75rem + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
@@ -2287,8 +2301,13 @@ body.hasActionBar #toastHost {
|
|||||||
seat looks like it did nothing. Inset the ring by half a puck, and float the
|
seat looks like it did nothing. Inset the ring by half a puck, and float the
|
||||||
editor up as a bottom sheet over the table. */
|
editor up as a bottom sheet over the table. */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
|
/* The bottom padding has to clear the fixed action bar, plus room for a toast to
|
||||||
|
sit above it. Without it the table just carries on underneath the bar and the
|
||||||
|
lower seats of the oval are behind the Fold button — measured at 360x640, where
|
||||||
|
62px of felt was covered. `--actionBarHeight` is published by actionBar.js and
|
||||||
|
is 0 when no hand is running, so the page doesn't hold a gap it doesn't need. */
|
||||||
#holdemApp {
|
#holdemApp {
|
||||||
padding: 1rem 0.75rem 2rem;
|
padding: 1rem 0.75rem calc(var(--actionBarHeight, 0px) + 5rem + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
#holdemHeader {
|
#holdemHeader {
|
||||||
@@ -2560,3 +2579,96 @@ body.hasActionBar #toastHost {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- A phone on its side ----------
|
||||||
|
Deliberately last in the file. The felt is an aspect-ratio box whose height comes
|
||||||
|
from `padding-bottom`, and the `max-width: 900px` block above re-sets that to 100%
|
||||||
|
— so a landscape rule placed any earlier is overridden at equal specificity and
|
||||||
|
does nothing, which is exactly what happened on the first attempt.
|
||||||
|
|
||||||
|
Landscape is where this layout falls apart worst: sized from width, the table came
|
||||||
|
out 765px tall in a 390px-tall window, putting six of the nine seats below the fold
|
||||||
|
and the rest behind the action bar. Sizing it from the height that's actually left
|
||||||
|
makes every seat reachable without scrolling. The table ends up small, but a seat
|
||||||
|
you have to go hunting for is worse than a seat that's small. */
|
||||||
|
@media (orientation: landscape) and (max-height: 560px) {
|
||||||
|
/* Set outright rather than via padding-bottom: a percentage padding resolves
|
||||||
|
against the containing block's width, not the element's own, so narrowing the
|
||||||
|
wrap on its own left it full height regardless. */
|
||||||
|
#pokerTableWrap {
|
||||||
|
--feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 8.5rem);
|
||||||
|
width: min(100%, calc(var(--feltRoom) / 0.72));
|
||||||
|
height: var(--feltRoom);
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The header ate 148px of a 390px-tall window, which was most of the reason the
|
||||||
|
table started below the fold. Here it only needs to be a title bar. */
|
||||||
|
#holdemHeader {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#holdemHeader h1 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
padding-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holdemSubtitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* There's no vertical room left, but plenty either side of a table this narrow,
|
||||||
|
so the toast moves out of the middle and sits beside the felt instead of on
|
||||||
|
top of it. Centred, it was covering up to six seats at once — and it carries
|
||||||
|
an Undo button, so a tap meant for a player was landing on "Undo". */
|
||||||
|
body.hasActionBar #toastHost,
|
||||||
|
#toastHost {
|
||||||
|
left: auto;
|
||||||
|
right: 0.5rem;
|
||||||
|
max-width: 17rem;
|
||||||
|
bottom: calc(var(--actionBarHeight, 0px) + 0.5rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nine seats around a table this small overlap each other, so they shrink to suit. */
|
||||||
|
.seat {
|
||||||
|
width: 62px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Short phones, upright ----------
|
||||||
|
A 640px-tall screen spends ~150px on the header before the felt even starts, which
|
||||||
|
left the bottom of the table sitting behind the action bar until you scrolled. The
|
||||||
|
header carries no information a dealer needs mid-hand, so it gives the space up. */
|
||||||
|
@media (max-height: 700px) {
|
||||||
|
/* Nowhere on a screen this short is the toast clear of both the table and the
|
||||||
|
action bar, so it overlaps the one that costs least: it sits over the bar's
|
||||||
|
status line, which is information a dealer can re-read a second later, rather
|
||||||
|
than over the seats and buttons, which are things they need to hit. Overlapping
|
||||||
|
a readout beats covering a control. */
|
||||||
|
body.hasActionBar #toastHost {
|
||||||
|
bottom: calc(var(--actionBarHeight, 0px) - 3.1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) and (max-height: 700px) {
|
||||||
|
#holdemHeader {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#holdemHeader h1 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
padding-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holdemSubtitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,9 +11,35 @@ const ActionBar = {
|
|||||||
mount(state, hooks) {
|
mount(state, hooks) {
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.hooks = hooks;
|
this.hooks = hooks;
|
||||||
|
this.watchHeight();
|
||||||
this.render();
|
this.render();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The bar is fixed to the bottom of the screen, so everything else has to know how
|
||||||
|
// tall it is or the table simply runs underneath it — on a 640px-tall phone the
|
||||||
|
// bottom seats of the oval were sitting behind the Fold button. Its height isn't a
|
||||||
|
// constant either: opening the raise pad grows it. Publishing the measured height
|
||||||
|
// as a custom property lets the layout and the toast both stay clear of it without
|
||||||
|
// anyone guessing a number.
|
||||||
|
watchHeight() {
|
||||||
|
const host = document.getElementById("actionBar");
|
||||||
|
if (!host || this.heightWatcher) return;
|
||||||
|
|
||||||
|
const publish = () => {
|
||||||
|
const visible = !host.hidden;
|
||||||
|
const height = visible ? host.getBoundingClientRect().height : 0;
|
||||||
|
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof ResizeObserver === "function") {
|
||||||
|
this.heightWatcher = new ResizeObserver(publish);
|
||||||
|
this.heightWatcher.observe(host);
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", publish);
|
||||||
|
window.addEventListener("orientationchange", publish);
|
||||||
|
publish();
|
||||||
|
},
|
||||||
|
|
||||||
// Anything to act on? A hand with nobody dealt in doesn't get a bar.
|
// Anything to act on? A hand with nobody dealt in doesn't get a bar.
|
||||||
isRunning() {
|
isRunning() {
|
||||||
return Betting.liveSeats(this.state).length > 0;
|
return Betting.liveSeats(this.state).length > 0;
|
||||||
@@ -27,6 +53,7 @@ const ActionBar = {
|
|||||||
if (!this.isRunning()) {
|
if (!this.isRunning()) {
|
||||||
host.hidden = true;
|
host.hidden = true;
|
||||||
document.body.classList.remove("hasActionBar");
|
document.body.classList.remove("hasActionBar");
|
||||||
|
document.documentElement.style.setProperty("--actionBarHeight", "0px");
|
||||||
this.stopTankTimer();
|
this.stopTankTimer();
|
||||||
this.tankSeatIndex = null;
|
this.tankSeatIndex = null;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -243,6 +243,28 @@ const Betting = {
|
|||||||
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
|
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// True while nothing has happened in this hand except the blinds going in. Up to
|
||||||
|
// that point the dealer can still move the button or rename the blinds and have
|
||||||
|
// it cost nothing — which is the state a home game spends a lot of time in,
|
||||||
|
// arguing about whose turn it is before the cards come out.
|
||||||
|
onlyBlindsIn(state) {
|
||||||
|
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pulls the blinds back off the table and posts them again from wherever the
|
||||||
|
// button and the blind assignment now say they belong. Refunding first is what
|
||||||
|
// keeps the chip count straight when the button gets passed round three or four
|
||||||
|
// times before anybody is happy with it.
|
||||||
|
repostBlinds(state) {
|
||||||
|
state.seats.forEach(player => {
|
||||||
|
const back = this.committed(player);
|
||||||
|
if (back > 0) player.stack = this.round(this.stackOf(state, player) + back);
|
||||||
|
player.committed = 0;
|
||||||
|
player.hasActed = false;
|
||||||
|
});
|
||||||
|
this.beginHand(state);
|
||||||
|
},
|
||||||
|
|
||||||
// Deals a fresh betting round: blinds posted, action to the seat after the big
|
// Deals a fresh betting round: blinds posted, action to the seat after the big
|
||||||
// blind, which is where a dealer expects to be looking when the cards go out.
|
// blind, which is where a dealer expects to be looking when the cards go out.
|
||||||
beginHand(state) {
|
beginHand(state) {
|
||||||
@@ -250,6 +272,11 @@ const Betting = {
|
|||||||
state.currentBet = 0;
|
state.currentBet = 0;
|
||||||
state.lastRaiseSize = 0;
|
state.lastRaiseSize = 0;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
HoldemState.ensureButtonOnPlayer(state);
|
||||||
|
|
||||||
state.seats.forEach(player => {
|
state.seats.forEach(player => {
|
||||||
player.committed = 0;
|
player.committed = 0;
|
||||||
player.hasActed = false;
|
player.hasActed = false;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
|
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
|
||||||
|
onGiveButton: index => seatMenuHooks.makeDealer(index),
|
||||||
};
|
};
|
||||||
|
|
||||||
const seatMenuHooks = {
|
const seatMenuHooks = {
|
||||||
@@ -59,13 +60,71 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
unfold: index => seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; }),
|
unfold: index => seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; }),
|
||||||
allIn: index => seatAction(index, `${nameOf(index)} is all-in.`, () => Betting.allIn(state, index)),
|
allIn: index => seatAction(index, `${nameOf(index)} is all-in.`, () => Betting.allIn(state, index)),
|
||||||
toggleDealtIn: index => {
|
toggleDealtIn: index => {
|
||||||
const dealtIn = state.seats[index].inHand;
|
const player = state.seats[index];
|
||||||
seatAction(index, dealtIn ? `${nameOf(index)} sits out.` : `${nameOf(index)} is dealt in.`, p => {
|
const dealtIn = player.inHand;
|
||||||
|
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
|
||||||
|
const alreadyIn = Betting.handTotal(player);
|
||||||
|
|
||||||
|
// Somebody walking away mid-hand doesn't get their chips back — they're
|
||||||
|
// dead money the winner collects. Say so, because the pot total pointedly
|
||||||
|
// does not go down and a dealer needs to know that's deliberate.
|
||||||
|
const message = dealtIn
|
||||||
|
? (alreadyIn > 0
|
||||||
|
? `${nameOf(index)} is out — their ${Money.format(alreadyIn)} stays in the pot.`
|
||||||
|
: `${nameOf(index)} sits out.`)
|
||||||
|
: (handRunning
|
||||||
|
? `${nameOf(index)} is in from the next hand.`
|
||||||
|
: `${nameOf(index)} is dealt in.`);
|
||||||
|
|
||||||
|
seatAction(index, message, p => {
|
||||||
p.inHand = !dealtIn;
|
p.inHand = !dealtIn;
|
||||||
if (!p.inHand) p.folded = false;
|
if (!p.inHand) {
|
||||||
|
// Their chips stay put; only the fold flag is cleared, since being
|
||||||
|
// out of the hand already covers not winning it.
|
||||||
|
p.folded = false;
|
||||||
|
if (state.actingSeatIndex === index) Betting.passAction(state, index);
|
||||||
|
} else if (handRunning) {
|
||||||
|
// Joining a hand already in progress means sitting this one out —
|
||||||
|
// you can't buy into a pot you haven't been paying for. `folded`
|
||||||
|
// is exactly that state, and the next hand clears it.
|
||||||
|
p.folded = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { state.dealerSeatIndex = index; }),
|
|
||||||
|
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => {
|
||||||
|
state.dealerSeatIndex = index;
|
||||||
|
// Before anyone has acted the blinds are still just sitting there, so
|
||||||
|
// moving the button moves them too — a home game passes it round several
|
||||||
|
// times before the cards come out, and the posted chips have to follow.
|
||||||
|
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||||
|
}),
|
||||||
|
|
||||||
|
assignBlind: (index, which) => {
|
||||||
|
const blinds = HoldemState.getBlindSeats(state);
|
||||||
|
const other = which === "sb" ? blinds.bbIndex : blinds.sbIndex;
|
||||||
|
// Naming one blind pins the other where it already is, so the dealer only
|
||||||
|
// has to state the part they're changing. If that would land both on one
|
||||||
|
// player, the other blind moves to the next seat along.
|
||||||
|
let partner = other !== null && other !== undefined && other !== index
|
||||||
|
? other
|
||||||
|
: Betting.nextLiveIndex(state, index);
|
||||||
|
if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index);
|
||||||
|
|
||||||
|
const label = which === "sb" ? "small blind" : "big blind";
|
||||||
|
seatAction(index, `${nameOf(index)} posts the ${label}.`, () => {
|
||||||
|
state.blindOverride = which === "sb"
|
||||||
|
? { sbIndex: index, bbIndex: partner }
|
||||||
|
: { sbIndex: partner, bbIndex: index };
|
||||||
|
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => {
|
||||||
|
state.blindOverride = null;
|
||||||
|
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||||
|
}),
|
||||||
|
|
||||||
editSeat: index => Render.toggleSeatEditor(index),
|
editSeat: index => Render.toggleSeatEditor(index),
|
||||||
|
|
||||||
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
|
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ const PotWizard = {
|
|||||||
const contributions = players.map(p => ({
|
const contributions = players.map(p => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
|
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||||
folded: p.folded,
|
// A seat switched off mid-hand still has its chips in the pot, but it
|
||||||
|
// can no more win them than a folded one can — same dead money, and the
|
||||||
|
// wizard must not offer it as a winner.
|
||||||
|
folded: p.folded || HoldemState.isDeadMoney(p),
|
||||||
}));
|
}));
|
||||||
this.entries = PotCalculator.buildEntries(contributions);
|
this.entries = PotCalculator.buildEntries(contributions);
|
||||||
// Kept so the wizard can show the whole breakdown up front, not just the layer
|
// Kept so the wizard can show the whole breakdown up front, not just the layer
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ const Render = {
|
|||||||
const layer = document.getElementById("seatsLayer");
|
const layer = document.getElementById("seatsLayer");
|
||||||
layer.innerHTML = "";
|
layer.innerHTML = "";
|
||||||
const template = document.getElementById("seatTemplate");
|
const template = document.getElementById("seatTemplate");
|
||||||
const { dealerIndex, sbIndex, bbIndex } = HoldemState.getBlindSeats(this.state);
|
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
|
||||||
|
|
||||||
this.state.seats.forEach((player, index) => {
|
this.state.seats.forEach((player, index) => {
|
||||||
const node = template.content.firstElementChild.cloneNode(true);
|
const node = template.content.firstElementChild.cloneNode(true);
|
||||||
@@ -172,15 +172,26 @@ const Render = {
|
|||||||
const dealerChip = node.querySelector(".dealerChip");
|
const dealerChip = node.querySelector(".dealerChip");
|
||||||
dealerChip.classList.toggle("active", index === dealerIndex);
|
dealerChip.classList.toggle("active", index === dealerIndex);
|
||||||
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
|
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
|
||||||
|
// Tapping the puck is the same act as "Give the button" in the seat menu,
|
||||||
|
// so it goes through the same hook — otherwise this one path would move
|
||||||
|
// the button without moving the blinds that follow it.
|
||||||
dealerChip.addEventListener("click", e => {
|
dealerChip.addEventListener("click", e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.state.dealerSeatIndex = index;
|
this.hooks.onGiveButton(index);
|
||||||
this.hooks.save(this.state);
|
|
||||||
this.renderSeats();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
node.querySelector(".sbBadge").classList.toggle("visible", index === sbIndex);
|
const sbBadge = node.querySelector(".sbBadge");
|
||||||
node.querySelector(".bbBadge").classList.toggle("visible", index === bbIndex);
|
const bbBadge = node.querySelector(".bbBadge");
|
||||||
|
sbBadge.classList.toggle("visible", index === sbIndex);
|
||||||
|
bbBadge.classList.toggle("visible", index === bbIndex);
|
||||||
|
// A blind the dealer named by hand isn't where the button would have put
|
||||||
|
// it, so it's marked — otherwise the table looks like it's just wrong.
|
||||||
|
// The `!!` matters: getBlindSeats leaves `manual` undefined when the
|
||||||
|
// blinds are positional, and classList.toggle with an undefined second
|
||||||
|
// argument *flips* the class instead of clearing it, so the marker
|
||||||
|
// appeared and disappeared on alternate renders.
|
||||||
|
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
|
||||||
|
bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex);
|
||||||
|
|
||||||
this.updatePuckContent(node, player, index);
|
this.updatePuckContent(node, player, index);
|
||||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||||
|
|||||||
@@ -45,6 +45,24 @@ const SeatMenu = {
|
|||||||
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Naming a blind by hand. Only offered while nothing but the blinds are in —
|
||||||
|
// once somebody has called, moving a blind would mean unpicking a bet that
|
||||||
|
// has already been answered, and that's a decision for the dealer's mouth,
|
||||||
|
// not a menu item. Which blind a seat already has is shown rather than
|
||||||
|
// offered again.
|
||||||
|
if (dealtIn && Betting.onlyBlindsIn(this.state)) {
|
||||||
|
const blinds = HoldemState.getBlindSeats(this.state);
|
||||||
|
if (blinds.sbIndex !== index) {
|
||||||
|
actions.push({ label: "Make small blind", run: () => this.hooks.assignBlind(index, "sb") });
|
||||||
|
}
|
||||||
|
if (blinds.bbIndex !== index) {
|
||||||
|
actions.push({ label: "Make big blind", run: () => this.hooks.assignBlind(index, "bb") });
|
||||||
|
}
|
||||||
|
if (this.state.blindOverride) {
|
||||||
|
actions.push({ label: "Blinds follow the button again", run: () => this.hooks.clearBlindOverride() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
||||||
return actions;
|
return actions;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ const HoldemState = {
|
|||||||
currentBet: 0,
|
currentBet: 0,
|
||||||
lastRaiseSize: 0,
|
lastRaiseSize: 0,
|
||||||
actingSeatIndex: null,
|
actingSeatIndex: null,
|
||||||
|
// Set when the dealer names the blinds by hand instead of letting them
|
||||||
|
// follow the button — { sbIndex, bbIndex }. Home games do this constantly
|
||||||
|
// ("you had it last time", someone arriving late owing a blind), and the
|
||||||
|
// button order is the wrong answer often enough that it has to be
|
||||||
|
// overridable. Cleared at the start of every hand.
|
||||||
|
blindOverride: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -73,7 +79,8 @@ const HoldemState = {
|
|||||||
// Tables saved before live betting existed are missing its fields; fill
|
// Tables saved before live betting existed are missing its fields; fill
|
||||||
// them in rather than making the dealer reset a table mid-session.
|
// them in rather than making the dealer reset a table mid-session.
|
||||||
const defaults = this.createDefault();
|
const defaults = this.createDefault();
|
||||||
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize", "actingSeatIndex"]
|
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
|
||||||
|
"actingSeatIndex", "blindOverride"]
|
||||||
.forEach(key => {
|
.forEach(key => {
|
||||||
if (parsed[key] === undefined) parsed[key] = defaults[key];
|
if (parsed[key] === undefined) parsed[key] = defaults[key];
|
||||||
});
|
});
|
||||||
@@ -124,9 +131,14 @@ const HoldemState = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Contribution the table calculator will treat this player as having put in.
|
// Contribution the table calculator will treat this player as having put in.
|
||||||
|
//
|
||||||
|
// Chips already pushed into the middle count whether or not the player is still
|
||||||
|
// in the hand. Someone who storms off, gets taken out by the dealer, or is
|
||||||
|
// dealt out mid-hand leaves their money on the felt — it's dead money owed to
|
||||||
|
// whoever wins, and it is not theirs to take back. Reading `inHand` first is
|
||||||
|
// how those chips used to disappear out of the pot the instant a seat was
|
||||||
|
// switched off, quietly shrinking the payout by whatever they had posted.
|
||||||
computeContribution(player, chipLegend, basePotAmount) {
|
computeContribution(player, chipLegend, basePotAmount) {
|
||||||
if (!player.inHand) return 0;
|
|
||||||
|
|
||||||
const override = player.amountOverride;
|
const override = player.amountOverride;
|
||||||
if (override !== null && override !== undefined && String(override).trim() !== "") {
|
if (override !== null && override !== undefined && String(override).trim() !== "") {
|
||||||
const parsed = Number(override);
|
const parsed = Number(override);
|
||||||
@@ -139,10 +151,22 @@ const HoldemState = {
|
|||||||
}, 0);
|
}, 0);
|
||||||
if (chipTotal > 0) return chipTotal;
|
if (chipTotal > 0) return chipTotal;
|
||||||
|
|
||||||
|
// The table-wide default is the one thing that stays tied to being dealt in:
|
||||||
|
// it's a stand-in for "everyone put the same amount in", so applying it to
|
||||||
|
// empty chairs would invent a contribution from every seat nobody is using.
|
||||||
|
if (!player.inHand) return 0;
|
||||||
|
|
||||||
const base = Number(basePotAmount);
|
const base = Number(basePotAmount);
|
||||||
return Number.isNaN(base) ? 0 : Math.max(0, base);
|
return Number.isNaN(base) ? 0 : Math.max(0, base);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Anyone whose chips are in the pot, whether or not they're still in the hand.
|
||||||
|
// A seat that's been switched off mid-hand is dead money: it counts towards the
|
||||||
|
// pot but can never be picked as a winner, exactly like a fold.
|
||||||
|
isDeadMoney(player) {
|
||||||
|
return !player.inHand;
|
||||||
|
},
|
||||||
|
|
||||||
totalPot(state) {
|
totalPot(state) {
|
||||||
return state.seats.reduce(
|
return state.seats.reduce(
|
||||||
(sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
|
(sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||||
@@ -188,12 +212,52 @@ const HoldemState = {
|
|||||||
return order;
|
return order;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// A button sitting on an empty chair is only cosmetic until you try to read
|
||||||
|
// position off it. On the very first hand of a fresh table it starts on seat 1
|
||||||
|
// whether or not anybody is sitting there, and a table that empties out around it
|
||||||
|
// leaves it stranded. Move it to a real player before a hand is dealt.
|
||||||
|
ensureButtonOnPlayer(state) {
|
||||||
|
if (state.seats[state.dealerSeatIndex] && state.seats[state.dealerSeatIndex].inHand) return;
|
||||||
|
for (let step = 0; step < state.seatCount; step++) {
|
||||||
|
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||||
|
if (state.seats[index].inHand) {
|
||||||
|
state.dealerSeatIndex = index;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// A hand-picked blind is only usable if that seat is actually in the hand — a
|
||||||
|
// dealer who names a blind and then deals that player out shouldn't have the
|
||||||
|
// table quietly post to nobody.
|
||||||
|
validBlindOverride(state) {
|
||||||
|
const o = state.blindOverride;
|
||||||
|
if (!o) return null;
|
||||||
|
const ok = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i] && state.seats[i].inHand;
|
||||||
|
if (!ok(o.sbIndex) || !ok(o.bbIndex) || o.sbIndex === o.bbIndex) return null;
|
||||||
|
return o;
|
||||||
|
},
|
||||||
|
|
||||||
// Who posts what. Heads-up is its own rule: with two players the button posts the
|
// Who posts what. Heads-up is its own rule: with two players the button posts the
|
||||||
// small blind, which is the opposite of the usual order.
|
// small blind, which is the opposite of the usual order.
|
||||||
getBlindSeats(state) {
|
getBlindSeats(state) {
|
||||||
const live = this.liveOrderFromButton(state);
|
const live = this.liveOrderFromButton(state);
|
||||||
const dealerIndex = state.dealerSeatIndex;
|
const dealerIndex = state.dealerSeatIndex;
|
||||||
|
|
||||||
|
// A blind the dealer named by hand wins over anything positional. Heads-up is
|
||||||
|
// still reported honestly so the betting order stays right — who posts what
|
||||||
|
// is the dealer's call, but two-handed play acting in reverse is a rule.
|
||||||
|
const override = this.validBlindOverride(state);
|
||||||
|
if (override) {
|
||||||
|
return {
|
||||||
|
dealerIndex,
|
||||||
|
sbIndex: override.sbIndex,
|
||||||
|
bbIndex: override.bbIndex,
|
||||||
|
headsUp: live.length === 2,
|
||||||
|
manual: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (live.length < 2) {
|
if (live.length < 2) {
|
||||||
return { dealerIndex, sbIndex: null, bbIndex: null, headsUp: false };
|
return { dealerIndex, sbIndex: null, bbIndex: null, headsUp: false };
|
||||||
}
|
}
|
||||||
@@ -210,6 +274,10 @@ const HoldemState = {
|
|||||||
|
|
||||||
startNextHand(state) {
|
startNextHand(state) {
|
||||||
this.advanceButton(state);
|
this.advanceButton(state);
|
||||||
|
// A hand-picked blind applies to the hand it was picked for. Carrying it into
|
||||||
|
// the next one would freeze the blinds on two players for the rest of the
|
||||||
|
// night, which is the opposite of what naming them once is for.
|
||||||
|
state.blindOverride = null;
|
||||||
state.seats.forEach(p => {
|
state.seats.forEach(p => {
|
||||||
p.folded = false;
|
p.folded = false;
|
||||||
p.cards = ["", ""];
|
p.cards = ["", ""];
|
||||||
@@ -218,6 +286,8 @@ const HoldemState = {
|
|||||||
p.committed = 0;
|
p.committed = 0;
|
||||||
p.hasActed = false;
|
p.hasActed = false;
|
||||||
});
|
});
|
||||||
|
// Anyone who joined mid-hand is a full player again from here.
|
||||||
|
this.ensureButtonOnPlayer(state);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Appends a resolved-pot record to the running session log a dealer can point to
|
// Appends a resolved-pot record to the running session log a dealer can point to
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
// Home games are messy. People walk up mid-hand, storm off mid-hand, argue about
|
||||||
|
// whose blind it is, and pass the button round three times before anyone deals.
|
||||||
|
// These are the cases the positional rules quietly get wrong.
|
||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Betting, HoldemState, PotCalculator, Money, makeTable, chipsOnTable, contributions } = require("./load");
|
||||||
|
|
||||||
|
const potOf = state => HoldemState.totalPot(state);
|
||||||
|
|
||||||
|
describe("a player leaves in the middle of a hand", () => {
|
||||||
|
test("their chips stay in the pot", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
const before = potOf(state);
|
||||||
|
assert.equal(before, 8);
|
||||||
|
|
||||||
|
state.seats[1].inHand = false; // dealer takes them out
|
||||||
|
|
||||||
|
assert.equal(potOf(state), before, "the pot must not shrink when a player is taken out");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("they cannot be paid out of a pot they walked away from", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
state.seats[1].inHand = false;
|
||||||
|
|
||||||
|
const players = state.seats
|
||||||
|
.filter(p => HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount) > 0)
|
||||||
|
.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||||
|
folded: p.folded || HoldemState.isDeadMoney(p),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const layers = PotCalculator.layers(players);
|
||||||
|
assert.ok(!layers[0].eligible.includes(state.seats[1].id), "a departed player can't win");
|
||||||
|
assert.equal(layers.reduce((s, l) => s + l.amount, 0), 6, "but their money is still being played for");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("taking out the player who is to act moves the action on", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const acting = state.actingSeatIndex;
|
||||||
|
|
||||||
|
state.seats[acting].inHand = false;
|
||||||
|
Betting.passAction(state, acting);
|
||||||
|
|
||||||
|
assert.notEqual(state.actingSeatIndex, acting, "the table can't wait on somebody who left");
|
||||||
|
assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty chair still contributes nothing, base pot or not", () => {
|
||||||
|
// The table-wide default must not be read as an ante from seats nobody is in.
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], dealerSeatIndex: 0 });
|
||||||
|
state.basePotAmount = 25;
|
||||||
|
assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, 25), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a player joins in the middle of a hand", () => {
|
||||||
|
test("they sit the hand out rather than joining a pot they never paid into", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
// Walks up and is dealt in — the app marks them folded for this hand.
|
||||||
|
state.seats[4].inHand = true;
|
||||||
|
state.seats[4].stack = 100;
|
||||||
|
state.seats[4].folded = true;
|
||||||
|
|
||||||
|
assert.ok(!Betting.liveSeats(state).some(s => s.index === 4), "not live this hand");
|
||||||
|
assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, state.basePotAmount), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("they are a full player again on the next hand", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
state.seats[4].inHand = true;
|
||||||
|
state.seats[4].stack = 100;
|
||||||
|
state.seats[4].folded = true;
|
||||||
|
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[4].folded, false);
|
||||||
|
assert.ok(Betting.liveSeats(state).some(s => s.index === 4), "dealt in like anyone else");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("passing the button around before the cards come out", () => {
|
||||||
|
test("moving the button re-posts the blinds and keeps the chip count straight", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
const startingChips = chipsOnTable(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[1].committed, 1);
|
||||||
|
assert.equal(state.seats[2].committed, 2);
|
||||||
|
|
||||||
|
state.dealerSeatIndex = 2;
|
||||||
|
Betting.repostBlinds(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[1].committed, 0, "the old small blind got their chip back");
|
||||||
|
assert.equal(state.seats[3].committed, 1, "and the new one has posted");
|
||||||
|
assert.equal(state.seats[0].committed, 2);
|
||||||
|
assert.equal(chipsOnTable(state), startingChips, "no chips created or lost in the shuffle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passing it round many times still balances", () => {
|
||||||
|
const state = makeTable({ seatCount: 8, seated: [0, 2, 3, 5, 7], dealerSeatIndex: 0 });
|
||||||
|
const startingChips = chipsOnTable(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
[2, 5, 7, 3, 0, 5].forEach(seat => {
|
||||||
|
state.dealerSeatIndex = seat;
|
||||||
|
Betting.repostBlinds(state);
|
||||||
|
assert.equal(chipsOnTable(state), startingChips, `chips drifted after passing to seat ${seat}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(Betting.committed(state.seats[sbIndex]), 1);
|
||||||
|
assert.equal(Betting.committed(state.seats[bbIndex]), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("once somebody has acted, the blinds are settled", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(Betting.onlyBlindsIn(state), true, "nothing but blinds so far");
|
||||||
|
|
||||||
|
Betting.call(state, 3);
|
||||||
|
assert.equal(Betting.onlyBlindsIn(state), false, "a call means the blinds are set where they are");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("naming the blinds by hand", () => {
|
||||||
|
test("a named blind wins over the button order", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||||
|
|
||||||
|
const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(sbIndex, 3);
|
||||||
|
assert.equal(bbIndex, 0);
|
||||||
|
assert.equal(manual, true);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(state.seats[3].committed, 1);
|
||||||
|
assert.equal(state.seats[0].committed, 2);
|
||||||
|
assert.equal(state.seats[1].committed, 0, "the positional small blind posts nothing");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a named blind on a player who isn't dealt in is ignored", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
state.blindOverride = { sbIndex: 4, bbIndex: 0 }; // seat 4 is an empty chair
|
||||||
|
|
||||||
|
const { sbIndex, manual } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.ok(!manual, "falls back to the button order rather than posting to nobody");
|
||||||
|
assert.ok(state.seats[sbIndex].inHand);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("both blinds on one player is refused", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
state.blindOverride = { sbIndex: 1, bbIndex: 1 };
|
||||||
|
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.notEqual(sbIndex, bbIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("naming blinds heads-up still acts in the right order", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
|
||||||
|
state.blindOverride = { sbIndex: 5, bbIndex: 2 }; // deliberately the reverse
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(state.seats[5].committed, 1);
|
||||||
|
assert.equal(state.seats[2].committed, 2);
|
||||||
|
assert.equal(state.actingSeatIndex, 5, "the small blind acts first pre-flop heads-up");
|
||||||
|
|
||||||
|
Betting.call(state, 5);
|
||||||
|
Betting.check(state, 2);
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
assert.equal(state.actingSeatIndex, 2, "and the big blind leads after the flop");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the naming only lasts the hand it was made for", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
assert.equal(state.blindOverride, null, "blinds go back to following the button");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the first hand on a fresh table", () => {
|
||||||
|
test("the button never starts on an empty chair", () => {
|
||||||
|
const state = HoldemState.createDefault();
|
||||||
|
HoldemState.resizeSeats(state, 9);
|
||||||
|
[4, 6, 7].forEach(i => { state.seats[i].inHand = true; state.seats[i].stack = 100; });
|
||||||
|
assert.equal(state.dealerSeatIndex, 0, "a fresh table starts the button on seat 1");
|
||||||
|
assert.equal(state.seats[0].inHand, false, "which nobody is sitting in");
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.ok(state.seats[state.dealerSeatIndex].inHand, "the button moved to a real player");
|
||||||
|
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.ok(state.seats[sbIndex].inHand);
|
||||||
|
assert.ok(state.seats[bbIndex].inHand);
|
||||||
|
assert.notEqual(sbIndex, bbIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a table that empties out around the button recovers", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 3 });
|
||||||
|
state.seats[3].inHand = false; // the player holding the button leaves
|
||||||
|
HoldemState.ensureButtonOnPlayer(state);
|
||||||
|
assert.ok(state.seats[state.dealerSeatIndex].inHand);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two players left after everyone else walks is heads-up, blinds reversed", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
[2, 3].forEach(i => { state.seats[i].inHand = false; });
|
||||||
|
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
const { sbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(headsUp, true);
|
||||||
|
assert.equal(sbIndex, state.dealerSeatIndex, "heads-up the button posts the small blind");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chips survive a messy table", () => {
|
||||||
|
test("joining, leaving and passing the button never changes the chip count", () => {
|
||||||
|
const state = makeTable({ seatCount: 8, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||||
|
state.seats[6].stack = 100; // sitting down but not dealt in yet
|
||||||
|
const startingChips = chipsOnTable(state);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
state.dealerSeatIndex = 2;
|
||||||
|
Betting.repostBlinds(state);
|
||||||
|
|
||||||
|
state.seats[6].inHand = true; // walks up mid-hand
|
||||||
|
state.seats[6].folded = true;
|
||||||
|
assert.equal(chipsOnTable(state), startingChips);
|
||||||
|
|
||||||
|
Betting.call(state, state.actingSeatIndex);
|
||||||
|
state.seats[1].inHand = false; // and someone storms off
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
assert.equal(chipsOnTable(state), startingChips, "every chip is still accounted for");
|
||||||
|
assert.equal(
|
||||||
|
Money.toCents(potOf(state) + state.seats.reduce((s, p) => s + (Number(p.stack) || 0), 0)),
|
||||||
|
Money.toCents(startingChips),
|
||||||
|
"and the pot plus the stacks is the whole table"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user