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 @@
-
+ + + + +
$ @@ -28,12 +44,12 @@
-
+
Split between
-
+
Smallest chip in play
@@ -43,12 +59,30 @@ Full dealer table → -

- Uneven pots — someone all-in for less — need the side-pot walkthrough on the - full table. +

+ If somebody was all-in for less than the others, switch to + Someone's all-in above. +

+ + + + @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) {