diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem/QuickSplit.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem/QuickSplit.cshtml
index 0dfc7a6..0fb0660 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem/QuickSplit.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem/QuickSplit.cshtml
@@ -11,6 +11,11 @@
Chop a pot without setting up a table.
+
+
+
+
+
Each player gets
$0.00
@@ -18,7 +23,18 @@
-
+
+
+
Enter what each player put in.
+
+
+
+ What each player put in
+
+
+
+
+
$
@@ -28,12 +44,12 @@
-
+
-
+
@@ -43,12 +59,30 @@
Full dealer table →
-
+
+
+
+
+
+
+ $
+
+
+
+
+
+
+
@section Styles {
}
@@ -56,5 +90,6 @@
@section Scripts {
+
}
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 8d9ca2e..c4579b5 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -146,7 +146,7 @@ body {
}
.tableHint {
- color: #888;
+ color: #949494;
font-size: 0.8rem;
text-align: center;
margin-top: 1rem;
@@ -357,7 +357,7 @@ body {
}
.seatEmptyLabel {
- color: #6d6d6d;
+ color: #8a8a8a;
font-size: 0.6rem;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -373,7 +373,7 @@ body {
top: 2px;
left: 6px;
font-size: 0.58rem;
- color: #777;
+ color: #8a8a8a;
}
.seatPuckName {
@@ -578,7 +578,7 @@ body {
.cardSlot.cardSlotEmpty {
background: #191919;
- color: #5c5c5c;
+ color: #8a8a8a;
border-style: dashed;
border-color: #3f3f3f;
box-shadow: none;
@@ -694,7 +694,7 @@ body {
}
.addChipsCurrency {
- color: #777;
+ color: #8a8a8a;
font-size: 1.3rem;
}
@@ -735,7 +735,7 @@ body {
background: none;
border: none;
padding: 0;
- color: #a9782f;
+ color: #d8b46a;
font-family: inherit;
font-size: 0.78rem;
text-decoration: underline;
@@ -834,7 +834,7 @@ body {
margin: 0;
padding-top: 0.6rem;
border-top: 1px solid #2c2c2c;
- color: #7a7a7a;
+ color: #8a8a8a;
font-size: 0.72rem;
line-height: 1.45;
}
@@ -1213,7 +1213,7 @@ body {
.subPanel summary {
cursor: pointer;
- color: #8f8f8f;
+ color: #9a9a9a;
font-size: 0.78rem;
}
@@ -1249,7 +1249,7 @@ body {
.fieldHint {
font-size: 0.75rem;
- color: #888;
+ color: #949494;
line-height: 1.4;
margin: 0;
}
@@ -1306,7 +1306,7 @@ body {
}
.standingsHeader {
- color: #777;
+ color: #8a8a8a;
font-size: 0.7rem;
letter-spacing: 0.04em;
text-transform: uppercase;
@@ -1374,7 +1374,7 @@ body {
.handHistoryTime {
margin-left: auto;
- color: #777;
+ color: #8a8a8a;
font-size: 0.72rem;
}
@@ -1480,6 +1480,12 @@ body.hasActionBar #holdemSidebar {
font-variant-numeric: tabular-nums;
}
+.actionRunout {
+ margin-left: auto;
+ color: #d8b46a;
+ font-size: 0.74rem;
+}
+
.actionWho {
margin-left: auto;
color: #9c9c9c;
@@ -1494,7 +1500,7 @@ body.hasActionBar #holdemSidebar {
flex: none;
min-width: 3.1rem;
text-align: right;
- color: #7d7d7d;
+ color: #949494;
font-variant-numeric: tabular-nums;
transition: color 0.3s ease;
}
@@ -1732,7 +1738,7 @@ body.hasActionBar #holdemSidebar {
.seatPuckStack {
font-size: 0.58rem;
- color: #8f8f8f;
+ color: #9a9a9a;
font-variant-numeric: tabular-nums;
margin-top: 0.1rem;
}
@@ -1836,7 +1842,7 @@ body.hasActionBar #toastHost {
border-radius: 50%;
border: none;
background: transparent;
- color: #888;
+ color: #949494;
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
@@ -1962,20 +1968,75 @@ body.hasActionBar #toastHost {
margin-bottom: 0.6rem;
}
+/* This is the screen where the money gets decided, so it holds the same 44px+
+ targets as the rest of the tool rather than the smallest ones in it. Picking the
+ wrong winner is the one mistake here that costs somebody real money. */
.potWizardChoice {
display: flex;
align-items: center;
- gap: 0.5rem;
+ gap: 0.7rem;
+ min-height: 56px;
background: #141414;
border: 1px solid #333;
- border-radius: 6px;
- padding: 0.5rem 0.7rem;
+ border-radius: 10px;
+ padding: 0.6rem 0.8rem;
cursor: pointer;
- font-size: 0.85rem;
+ font-size: 0.95rem;
+ transition: border-color 0.12s ease, background 0.12s ease;
}
.potWizardChoice:hover {
border-color: #555;
+ background: #191919;
+}
+
+.potWizardChoice input {
+ flex: none;
+ width: 22px;
+ height: 22px;
+ accent-color: #1e8449;
+}
+
+/* Selected has to be obvious at a glance — a dealer confirms this with players
+ watching, and a checkbox alone is easy to misread across a table. */
+.potWizardChoice.chosen {
+ border-color: #1e8449;
+ background: linear-gradient(180deg, #16301f, #12241a);
+}
+
+.potWizardChoice.chosen .potWizardChoiceName {
+ color: #d6ffe0;
+}
+
+.potWizardChoiceText {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+}
+
+.potWizardChoiceName {
+ color: #f0f0f0;
+ font-weight: 600;
+}
+
+.potWizardChoiceUpTo {
+ color: #9a9a9a;
+ font-size: 0.76rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.potWizardChoiceCards {
+ flex: none;
+ font-family: var(--font-mono, monospace);
+ font-size: 1rem;
+ font-weight: 700;
+ color: #ddd;
+}
+
+.potWizardChoiceCards .cardRed {
+ color: #ff6b6b;
}
.potWizardSummary ul {
@@ -1996,6 +2057,37 @@ body.hasActionBar #toastHost {
gap: 0.6rem;
}
+/* Every button in this modal is one a dealer presses with players waiting. */
+#potWizardBody .holdemBtn {
+ min-height: 48px;
+ justify-content: center;
+}
+
+/* The confirm is the whole point of the step, so it gets the whole width rather
+ than being sized to the length of its own label. */
+.potWizardRound > .holdemBtn {
+ width: 100%;
+}
+
+.potWizardActions .holdemBtn {
+ flex: 1;
+}
+
+.potWizardSummary li {
+ font-variant-numeric: tabular-nums;
+}
+
+@media (max-width: 700px) {
+ /* Stacked, with the action that ends the hand furthest from a stray thumb. */
+ .potWizardActions {
+ flex-direction: column-reverse;
+ }
+
+ .potWizardChoice {
+ min-height: 60px;
+ }
+}
+
@media (max-width: 900px) {
#holdemLayout {
grid-template-columns: 1fr;
@@ -2140,11 +2232,10 @@ body.hasActionBar #toastHost {
whole phone width to work with, so everything grows to thumb size — and
every field goes to 16px, below which iOS zooms the page on focus and the
dealer has to pinch back out mid-hand. */
- .seatEditorForm .seatNameInput,
- .seatEditorForm .amountOverrideInput,
- .seatEditorForm .chipCountInput,
- .seatEditorForm .customFieldLabel,
- .seatEditorForm .customFieldValue {
+ /* Every text or number field in the form, rather than a list of the ones that
+ existed when this was written. The stack field was added later and sat at 28px
+ next to its 44px neighbours precisely because it wasn't on that list. */
+ .seatEditorForm input:not([type="checkbox"]):not([type="color"]) {
font-size: 16px;
min-height: 44px;
padding: 0.4rem 0.6rem;
@@ -2208,6 +2299,77 @@ body.hasActionBar #toastHost {
margin-top: 0.4rem;
}
+ /* The sidebar's own controls never got the touch pass the seat form did — these
+ are settings a dealer changes at the table, on the same phone. Written against
+ the panel rather than each field, so a setting added later inherits it. */
+ .panelBody input:not([type="checkbox"]):not([type="color"]),
+ .subPanelBody input:not([type="checkbox"]):not([type="color"]),
+ .panelBody select {
+ font-size: 16px;
+ min-height: 44px;
+ padding: 0.4rem 0.6rem;
+ }
+
+ .panelBody .holdemBtnSmall,
+ .handHistoryActions .holdemBtn {
+ min-height: 44px;
+ }
+
+ /* Destructive and tiny is the worst combination: these delete a chip colour or a
+ custom field, and were 28px wide. */
+ .removeChipColorBtn,
+ .removeFieldBtn {
+ min-width: 44px;
+ min-height: 44px;
+ }
+
+ /* flex-shrink would take a stated width straight back off again in a row this
+ narrow, which is why it stayed 32px wide with the height applied. */
+ .chipColorInput {
+ flex: none;
+ width: 44px;
+ height: 44px;
+ }
+
+ /* A bare checkbox can't grow into a target, but the row it sits in can — the
+ label is what a tap actually lands on. */
+ .panelBody .fieldRow {
+ min-height: 44px;
+ }
+
+ /* A bare checkbox with no label box around it to grow. */
+ #dedicatedDealerToggle {
+ width: 24px;
+ height: 24px;
+ }
+
+ /* An underlined word is a 14px-tall target; give it room without making it look
+ like a button. */
+ .linkBtn {
+ display: inline-flex;
+ align-items: center;
+ min-height: 44px;
+ padding: 0 0.4rem;
+ }
+
+ /* The dealer button has to stay a 22px disc — it's a chip sitting on a seat, and
+ drawing it thumb-sized would swallow the puck. So the disc keeps its size and
+ an invisible pad around it takes the tap. It reaches outward, away from the
+ puck's own tap target, so pressing the seat still opens the seat. */
+ .dealerChip::after {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 44px;
+ height: 44px;
+ transform: translate(-50%, -50%);
+ }
+
+ .dealerChip {
+ overflow: visible;
+ }
+
.toastAction {
min-height: 44px;
}
diff --git a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css
index 0f15fd8..157f9c9 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css
@@ -45,6 +45,171 @@ body {
font-size: 0.85rem;
}
+/* ---------- Mode toggle ---------- */
+
+#modeToggle {
+ margin-bottom: 1.1rem;
+}
+
+/* ---------- Uneven amounts ---------- */
+
+#unevenRows {
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+ margin-bottom: 0.6rem;
+}
+
+.unevenRow {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.unevenRowLabel {
+ flex: none;
+ width: 2rem;
+ color: #8f8f8f;
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+.unevenAmountWrap {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+ background: #141414;
+ border: 1px solid #3a3a3a;
+ border-radius: 10px;
+ padding: 0 0.6rem;
+}
+
+.unevenAmountWrap:focus-within {
+ border-color: #d8b46a;
+}
+
+.unevenCurrency {
+ color: #777;
+}
+
+.unevenAmount {
+ flex: 1;
+ min-width: 0;
+ background: none;
+ border: none;
+ outline: none;
+ color: #f2f2f2;
+ font-family: inherit;
+ font-size: 16px;
+ font-variant-numeric: tabular-nums;
+ padding: 0.6rem 0;
+}
+
+.unevenFoldBtn {
+ flex: none;
+ min-height: 44px;
+ padding: 0 0.6rem;
+ border-radius: 9px;
+ border: 1px solid #3a3a3a;
+ background: #232323;
+ color: #8f8f8f;
+ font-family: inherit;
+ font-size: 0.72rem;
+ cursor: pointer;
+}
+
+.unevenFoldBtn.selected {
+ background: linear-gradient(180deg, #3d1f1f, #2c1515);
+ border-color: #5b2626;
+ color: #ffd6d6;
+}
+
+.unevenRemoveBtn {
+ flex: none;
+ width: 30px;
+ height: 44px;
+ border: none;
+ background: none;
+ color: #6a6a6a;
+ font-size: 0.85rem;
+ cursor: pointer;
+}
+
+.unevenRemoveBtn:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
+
+#unevenResult {
+ background: linear-gradient(160deg, #1f2a21, #161c17);
+ border: 1px solid #2c3a2f;
+ border-radius: 14px;
+ padding: 0.9rem 1rem;
+ margin-bottom: 1.5rem;
+}
+
+#unevenResult[hidden] {
+ display: none;
+}
+
+.unevenPot {
+ display: flex;
+ align-items: baseline;
+ gap: 0.6rem;
+ padding: 0.3rem 0;
+ font-size: 0.85rem;
+}
+
+.unevenPot + .unevenPot {
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.unevenPotLabel {
+ flex: none;
+ color: #d8b46a;
+ font-weight: 600;
+}
+
+.unevenPotAmount {
+ flex: none;
+ color: #f2f2f2;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+.unevenPotWho {
+ flex: 1;
+ min-width: 0;
+ text-align: right;
+ color: #9a9a9a;
+ font-size: 0.78rem;
+}
+
+.unevenPotReturn .unevenPotLabel,
+.unevenPotReturn .unevenPotAmount {
+ color: #9a9a9a;
+ font-weight: 500;
+}
+
+.unevenPotTotal {
+ margin-top: 0.15rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.14) !important;
+}
+
+.unevenPotTotal .unevenPotLabel {
+ color: #8f9d92;
+ font-weight: 500;
+}
+
+.unevenEmpty {
+ margin: 0;
+ color: #8f9d92;
+ font-size: 0.82rem;
+ line-height: 1.45;
+}
+
/* ---------- The answer ---------- */
#splitResult {
@@ -104,6 +269,12 @@ body {
margin-bottom: 1.35rem;
}
+/* A `display` declaration outranks the browser's own [hidden] rule, so anything
+ given one has to opt back out explicitly or toggling `hidden` does nothing. */
+[hidden] {
+ display: none !important;
+}
+
.splitLabel {
display: block;
margin-bottom: 0.5rem;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index d440315..3673f21 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -74,6 +74,16 @@ const ActionBar = {
players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`;
row.appendChild(players);
+ // Nobody is being asked to act because nobody can — say so, or the bar just
+ // looks like it's waiting for something.
+ if (Betting.isRunningOut(this.state) && !Betting.isHandOver(this.state)) {
+ const runout = document.createElement("span");
+ runout.className = "actionRunout";
+ const stillIn = Betting.liveSeats(this.state).length;
+ runout.textContent = `All-in — running out the board (${stillIn} players)`;
+ row.appendChild(runout);
+ }
+
const index = this.state.actingSeatIndex;
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
const player = this.state.seats[index];
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index bb2f628..234b85e 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -70,7 +70,19 @@ const Betting = {
for (let step = 1; step <= count; step++) {
const index = this.seatAt(state, fromIndex, step);
const player = state.seats[index];
- if (player.inHand && !player.folded && this.stackOf(state, player) >= 0) return index;
+ if (player.inHand && !player.folded) return index;
+ }
+ return null;
+ },
+
+ // Who the dealer asks next. A player who is all-in is still in the hand and still
+ // wins pots, but has no decision left, so the action passes over them.
+ nextToAct(state, fromIndex) {
+ const count = state.seatCount;
+ for (let step = 1; step <= count; step++) {
+ const index = this.seatAt(state, fromIndex, step);
+ const player = state.seats[index];
+ if (player.inHand && !player.folded && this.stackOf(state, player) > this.EPSILON_CHIPS) return index;
}
return null;
},
@@ -156,7 +168,7 @@ const Betting = {
},
passAction(state, index) {
- state.actingSeatIndex = this.isStreetComplete(state) ? null : this.nextLiveIndex(state, index);
+ state.actingSeatIndex = this.isStreetComplete(state) ? null : this.nextToAct(state, index);
},
// ---- Street and hand boundaries ----
@@ -184,15 +196,52 @@ const Betting = {
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
state.street = this.STREETS[nextStreetIndex] || "river";
- state.actingSeatIndex = this.liveSeats(state).length > 1
- ? this.nextLiveIndex(state, state.dealerSeatIndex)
- : null;
+
+ // It takes two players with chips to have a bet. Once everyone left is all-in
+ // the rest of the board is just dealt out.
+ if (!this.canStillBet(state)) {
+ state.actingSeatIndex = null;
+ return;
+ }
+
+ // After the flop the first live player left of the button acts — which heads-up
+ // is the big blind, since the button is the small blind. Naming the big blind
+ // directly also covers a button parked on a seat nobody is sitting in, and it
+ // still has to be someone with chips left to bet.
+ const { bbIndex, headsUp } = HoldemState.getBlindSeats(state);
+ const bigBlindCanAct =
+ headsUp &&
+ bbIndex !== null &&
+ !state.seats[bbIndex].folded &&
+ this.stackOf(state, state.seats[bbIndex]) > this.EPSILON_CHIPS;
+
+ state.actingSeatIndex = bigBlindCanAct
+ ? bbIndex
+ : this.nextToAct(state, state.dealerSeatIndex);
},
isHandOver(state) {
return this.liveSeats(state).length <= 1;
},
+ // Players still in the hand who have chips left to put in. Fewer than two of them
+ // means the betting is finished even though the hand isn't.
+ seatsWithChips(state) {
+ return this.liveSeats(state).filter(s => this.stackOf(state, s.player) > this.EPSILON_CHIPS);
+ },
+
+ EPSILON_CHIPS: 0.004,
+
+ canStillBet(state) {
+ return this.liveSeats(state).length > 1 && this.seatsWithChips(state).length > 1;
+ },
+
+ // True when the hand is still live but nobody can bet again — the dealer is just
+ // running the board out.
+ isRunningOut(state) {
+ return this.liveSeats(state).length > 1 && !this.canStillBet(state);
+ },
+
// 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.
beginHand(state) {
@@ -207,15 +256,26 @@ const Betting = {
const sb = Number(state.smallBlind) || 0;
const bb = Number(state.bigBlind) || 0;
- const sbIndex = this.seatAt(state, state.dealerSeatIndex, 1);
- const bbIndex = this.seatAt(state, state.dealerSeatIndex, 2);
+ const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
- if (sb > 0 && state.seats[sbIndex].inHand) this.putIn(state, sbIndex, sb);
- if (bb > 0 && state.seats[bbIndex].inHand) this.putIn(state, bbIndex, bb);
+ if (sbIndex === null || bbIndex === null) {
+ state.currentBet = 0;
+ state.actingSeatIndex = null;
+ return;
+ }
+
+ if (sb > 0) this.putIn(state, sbIndex, sb);
+ if (bb > 0) this.putIn(state, bbIndex, bb);
state.currentBet = this.round(Math.max(this.committed(state.seats[sbIndex]), this.committed(state.seats[bbIndex])));
state.lastRaiseSize = bb || 0;
- state.actingSeatIndex = this.nextLiveIndex(state, bbIndex);
+
+ // Heads-up the button is the small blind and acts first before the flop; with
+ // three or more it's the seat after the big blind. Either way, a blind big
+ // enough to put that player all-in leaves them nothing to decide.
+ state.actingSeatIndex = headsUp
+ ? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
+ : this.nextToAct(state, bbIndex);
},
// Winners get their share put back in front of them.
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index f6915ae..30137cc 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -183,10 +183,38 @@ document.addEventListener("DOMContentLoaded", () => {
});
};
- document.getElementById("seatCountInput").addEventListener("change", e => {
- HoldemState.resizeSeats(state, Number(e.target.value));
- hooks.save(state);
- Render.renderAll();
+ // A seat that has anything of a player in it — a name, chips, cash in, or a hand
+ // in progress. Shrinking the table throws these away permanently, and it also
+ // orphans them in the session standings, where they keep their winnings but lose
+ // the stack and buy-in the settle-up is worked out from.
+ const seatHasSomeoneIn = player =>
+ !!(player.name && player.name.trim()) ||
+ player.inHand ||
+ (Number(player.stack) || 0) > 0 ||
+ (Number(player.buyIns) || 0) > 0 ||
+ (Number(player.amountOverride) || 0) > 0;
+
+ document.getElementById("seatCountInput").addEventListener("change", async e => {
+ const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount));
+ const losing = state.seats.slice(requested).filter(seatHasSomeoneIn);
+
+ if (losing.length > 0) {
+ const who = losing
+ .map((p, i) => HoldemState.displayName(p, requested + state.seats.slice(requested).indexOf(p)))
+ .join(", ");
+ const ok = await Dialog.confirm({
+ title: `Remove ${losing.length} ${losing.length === 1 ? "player" : "players"}?`,
+ message: `Going down to ${requested} seats takes ${who} off the table along with their chips and buy-ins, and their session totals stop adding up.`,
+ confirmLabel: "Remove them",
+ danger: true,
+ });
+ if (!ok) {
+ e.target.value = state.seatCount;
+ return;
+ }
+ }
+
+ runUndoable(`Table set to ${requested} seats.`, () => HoldemState.resizeSeats(state, requested));
});
document.getElementById("directionSelect").addEventListener("change", e => {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
index ab36c9a..866658c 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
@@ -18,6 +18,15 @@ const PotWizard = {
this.playerLabelById = new Map(
players.map(p => [p.id, this.labelFor(p, state.seats.indexOf(p))])
);
+ this.seatIndexById = new Map(players.map(p => [p.id, state.seats.indexOf(p)]));
+ // The label carries name and cards together for the log and the payout lines;
+ // the choice rows want them separately so each can be styled and read on its own.
+ this.nameById = new Map(
+ players.map(p => [p.id, HoldemState.displayName(p, state.seats.indexOf(p))])
+ );
+ this.cardsById = new Map(
+ players.map(p => [p.id, (p.cards || []).filter(Boolean)])
+ );
const contributions = players.map(p => ({
id: p.id,
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
@@ -49,6 +58,20 @@ const PotWizard = {
this.totals.set(id, (this.totals.get(id) || 0) + amount);
},
+ // A chopped pot rarely divides exactly, and the odd chip goes to the first player
+ // left of the button — not to whoever the dealer happened to tick first. The
+ // calculator hands leftovers out in the order it's given the winners, so ordering
+ // them here is what makes the payout match the rule.
+ orderFromButton(ids) {
+ const distance = new Map();
+ for (let step = 1; step <= this.state.seatCount; step++) {
+ distance.set(HoldemState.nextSeatIndex(this.state, this.state.dealerSeatIndex, step), step);
+ }
+ return [...ids].sort((a, b) =>
+ (distance.get(this.seatIndexById.get(a)) ?? 0) - (distance.get(this.seatIndexById.get(b)) ?? 0)
+ );
+ },
+
renderStep() {
// Auto-resolve any round where only one player is left standing — nothing to ask.
let contenders = PotCalculator.getContenders(this.entries);
@@ -105,13 +128,46 @@ const PotWizard = {
contenders.forEach(c => {
const label = document.createElement("label");
label.className = "potWizardChoice";
+
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = c.id;
- const text = document.createElement("span");
- text.textContent = `${this.playerLabelById.get(c.id)} — up to ${Money.format(c.remaining)}`;
+ // The row is the target, not the box — so the row has to look chosen too.
+ checkbox.addEventListener("change", () => {
+ label.classList.toggle("chosen", checkbox.checked);
+ });
label.appendChild(checkbox);
+
+ const text = document.createElement("span");
+ text.className = "potWizardChoiceText";
+
+ const name = document.createElement("span");
+ name.className = "potWizardChoiceName";
+ name.textContent = this.nameById.get(c.id);
+ text.appendChild(name);
+
+ const upTo = document.createElement("span");
+ upTo.className = "potWizardChoiceUpTo";
+ upTo.textContent = `up to ${Money.format(c.remaining)}`;
+ text.appendChild(upTo);
label.appendChild(text);
+
+ // Cards sit apart from the name rather than inside it, so a dealer reading
+ // a row across the table sees the hand, not a sentence.
+ const cards = this.cardsById.get(c.id) || [];
+ if (cards.length > 0) {
+ const cardsEl = document.createElement("span");
+ cardsEl.className = "potWizardChoiceCards";
+ cards.forEach((card, i) => {
+ if (i > 0) cardsEl.appendChild(document.createTextNode(" "));
+ const span = document.createElement("span");
+ if (/[HD]$/i.test(card)) span.className = "cardRed";
+ span.textContent = Render.formatCard(card);
+ cardsEl.appendChild(span);
+ });
+ label.appendChild(cardsEl);
+ }
+
choices.appendChild(label);
});
round.appendChild(choices);
@@ -127,7 +183,7 @@ const PotWizard = {
confirmBtn.addEventListener("click", () => {
const selected = Array.from(choices.querySelectorAll("input:checked")).map(i => i.value);
if (selected.length === 0) return;
- const result = PotCalculator.resolveRound(this.entries, selected);
+ const result = PotCalculator.resolveRound(this.entries, this.orderFromButton(selected));
this.recordRound(result);
this.entries = result.updatedEntries;
this.renderStep();
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js
index aa4c164..2cf84f6 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js
@@ -15,12 +15,28 @@ const QuickSplit = {
ways: 2,
chip: 1,
+ // "even" is the common case — one pot, everyone matched. "uneven" is the one this
+ // screen used to send people back to the full table for: somebody all-in for less,
+ // so the money splits into a main pot and side pots.
+ mode: "even",
+ entries: [],
+
mount() {
this.potInput = document.getElementById("potInput");
this.clearPotBtn = document.getElementById("clearPotBtn");
+ this.entries = [{ amount: "", folded: false }, { amount: "", folded: false }, { amount: "", folded: false }];
this.renderWayChoices();
this.renderChipChoices();
+ this.renderUnevenRows();
+
+ document.getElementById("modeEvenBtn").addEventListener("click", () => this.setMode("even"));
+ document.getElementById("modeUnevenBtn").addEventListener("click", () => this.setMode("uneven"));
+ document.getElementById("addPlayerBtn").addEventListener("click", () => {
+ this.entries.push({ amount: "", folded: false });
+ this.renderUnevenRows();
+ this.update();
+ });
this.potInput.addEventListener("input", () => {
this.sanitizePotInput();
@@ -127,7 +143,112 @@ const QuickSplit = {
});
},
+ setMode(mode) {
+ this.mode = mode;
+ document.getElementById("modeEvenBtn").classList.toggle("selected", mode === "even");
+ document.getElementById("modeUnevenBtn").classList.toggle("selected", mode === "uneven");
+
+ document.querySelectorAll(".evenOnly").forEach(el => { el.hidden = mode !== "even"; });
+ document.querySelectorAll(".unevenOnly").forEach(el => { el.hidden = mode !== "uneven"; });
+ document.getElementById("splitResult").hidden = mode !== "even";
+ document.getElementById("unevenResult").hidden = mode !== "uneven";
+
+ this.update();
+ },
+
+ renderUnevenRows() {
+ const host = document.getElementById("unevenRows");
+ const template = document.getElementById("unevenRowTemplate");
+ host.innerHTML = "";
+
+ this.entries.forEach((entry, index) => {
+ const row = template.content.firstElementChild.cloneNode(true);
+ row.querySelector(".unevenRowLabel").textContent = `P${index + 1}`;
+
+ const amount = row.querySelector(".unevenAmount");
+ amount.value = entry.amount;
+ amount.setAttribute("aria-label", `Player ${index + 1} amount`);
+ amount.addEventListener("input", () => {
+ entry.amount = amount.value.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1");
+ if (entry.amount !== amount.value) amount.value = entry.amount;
+ this.update();
+ });
+
+ // Folded money stays in the pot as dead money, but its owner can't win any
+ // of it — which is exactly the distinction that makes side pots tricky.
+ const fold = row.querySelector(".unevenFoldBtn");
+ fold.classList.toggle("selected", entry.folded);
+ fold.setAttribute("aria-pressed", String(entry.folded));
+ fold.addEventListener("click", () => {
+ entry.folded = !entry.folded;
+ this.renderUnevenRows();
+ this.update();
+ });
+
+ const remove = row.querySelector(".unevenRemoveBtn");
+ remove.disabled = this.entries.length <= 2;
+ remove.addEventListener("click", () => {
+ this.entries.splice(index, 1);
+ this.renderUnevenRows();
+ this.update();
+ });
+
+ host.appendChild(row);
+ });
+ },
+
+ updateUneven() {
+ const players = this.entries
+ .map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded }))
+ .filter(p => p.contribution > 0);
+
+ const pots = PotCalculator.layers(players);
+ const returns = PotCalculator.uncalledReturns(players);
+
+ const host = document.getElementById("unevenPots");
+ const empty = document.getElementById("unevenEmpty");
+ host.innerHTML = "";
+
+ if (pots.length === 0 && returns.length === 0) {
+ empty.hidden = false;
+ empty.textContent = players.length === 0
+ ? "Enter what each player put in."
+ : "Nobody left who can win — un-fold whoever should take it.";
+ return;
+ }
+ empty.hidden = true;
+
+ const addRow = (label, amount, who, className) => {
+ const row = document.createElement("div");
+ row.className = `unevenPot ${className || ""}`;
+ const name = document.createElement("span");
+ name.className = "unevenPotLabel";
+ name.textContent = label;
+ const value = document.createElement("span");
+ value.className = "unevenPotAmount";
+ value.textContent = Money.format(amount);
+ const eligible = document.createElement("span");
+ eligible.className = "unevenPotWho";
+ eligible.textContent = who;
+ row.appendChild(name);
+ row.appendChild(value);
+ row.appendChild(eligible);
+ host.appendChild(row);
+ };
+
+ pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", ")));
+ returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
+
+ const total = players.reduce((sum, p) => sum + p.contribution, 0);
+ addRow("On the table", Math.round(total * 100) / 100, "", "unevenPotTotal");
+ },
+
update() {
+ if (this.mode === "uneven") {
+ this.updateUneven();
+ return;
+ }
+
const potValue = Number(this.potInput.value);
const pot = Number.isNaN(potValue) ? 0 : potValue;
this.clearPotBtn.hidden = this.potInput.value === "";
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index 67b4a4e..f73b8ab 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -159,14 +159,53 @@ const HoldemState = {
return ((fromIndex + delta) % count + count) % count;
},
+ // The button passes to the next player, not the next chair. Moving it one seat at
+ // a time parks it on empty chairs — with four players spread around ten seats it
+ // sits on nobody most hands — and that isn't only untidy: position and blinds are
+ // supposed to come round to each player at the same rate, and they don't if the
+ // button is rotating past chairs nobody is sitting in.
advanceButton(state) {
+ for (let step = 1; step <= state.seatCount; step++) {
+ const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
+ if (state.seats[index].inHand) {
+ state.dealerSeatIndex = index;
+ return;
+ }
+ }
+ // Nobody is dealt in; move it along anyway so it doesn't look stuck.
state.dealerSeatIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
},
+ // Seats dealt into this hand, in blind order starting from the seat after the
+ // button. Blinds follow the players, not the chairs — the seats immediately left
+ // of the button are empty often enough that counting chairs posts them to nobody.
+ liveOrderFromButton(state) {
+ const order = [];
+ for (let step = 1; step <= state.seatCount; step++) {
+ const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
+ if (state.seats[index].inHand) order.push(index);
+ }
+ return order;
+ },
+
+ // 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.
getBlindSeats(state) {
- const sbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
- const bbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 2);
- return { dealerIndex: state.dealerSeatIndex, sbIndex, bbIndex };
+ const live = this.liveOrderFromButton(state);
+ const dealerIndex = state.dealerSeatIndex;
+
+ if (live.length < 2) {
+ return { dealerIndex, sbIndex: null, bbIndex: null, headsUp: false };
+ }
+
+ if (live.length === 2) {
+ const buttonIsPlaying = state.seats[dealerIndex].inHand;
+ const sbIndex = buttonIsPlaying ? dealerIndex : live[0];
+ const bbIndex = live.find(i => i !== sbIndex);
+ return { dealerIndex, sbIndex, bbIndex, headsUp: true };
+ }
+
+ return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false };
},
startNextHand(state) {