diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 40c7f2f..36c00b3 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -340,6 +340,33 @@

+
+

One big chip, nothing said

+

+ The most common argument at a home table. A player facing a bet who + pushes out a single oversized chip without saying anything has + called — not raised, however big the chip is. To raise with one + chip you have to say "raise" before it lands; say it without an amount + and the raise is the most that chip allows. +

+

+ Facing no bet, the same silent chip is a bet of the whole chip. And a + clear verbal declaration in turn is binding either way — if words and + chips disagree, whichever came first is the action. +

+
+ +
+

A raise has to be a full raise

+

+ At least as much again as the last raise, or the big blind if there + hasn't been one. Anything between a call and that minimum isn't a raise + a player can make — it's a call, unless they're putting their last chips + in. The raise pad says so if you type one, and won't stop you: your + ruling beats the arithmetic. +

+
+

The odd chip in a split pot

diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 9bb7fbd..d6ee36f 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -2899,3 +2899,43 @@ textarea:focus-visible { position: absolute; inset: -7px; } + +/* A note under the raise entry when the amount typed isn't a legal raise. It informs + and never blocks: this tool records what the humans at the table actually did, and + a dealer overruling the arithmetic is right more often than the arithmetic is. + Under-minimum raises are the single most common thing that stops a home game, and + the bar used to accept one without a word. */ +.raiseNote { + margin: 0.45rem 0 0; + font-size: 0.74rem; + line-height: 1.35; + text-align: center; +} + +.raiseNote.raiseNoteWarn { + color: #f0b46a; +} + +.raiseNote.raiseNoteInfo { + color: #9fc7e8; +} + +/* On a short phone the raise pad already makes the bar tall enough to cover the + lowest seats, and the note must not make that materially worse. Measured at + 360x640: bar 122px closed, 178px with the pad open, 217px with a note. Tightened + here so the note costs a line rather than a paragraph — the bar is a transient + input mode with Cancel and Escape, but it shouldn't grow for the sake of it. */ +@media (max-height: 700px) { + .raiseNote { + margin-top: 0.3rem; + font-size: 0.68rem; + line-height: 1.25; + } + + /* The pot breakdown is reference material — which side pots exist and who can win + them. While an amount is being typed it isn't what the dealer is reading, and + it's the tallest thing competing for the strip. */ + body.hasActionBar #actionBar:has(.raisePad) .potBreakdown { + display: none; + } +} diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 6029bcc..044c895 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -319,12 +319,26 @@ const ActionBar = { // Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a // button, and only reaches for digits when the table does something odd. renderRaisePad() { + // A toast is transient news about the action just taken; the raise pad is the + // dealer deciding the next one. On a short screen they compete for the same + // strip: the pad makes the bar 217px tall on a 640px phone, and the toast — + // which floats just above the bar — gets pushed up over the table, covering + // seats outright. Rather than squeeze both in, the older one gets out of the + // way. Nothing is lost: the toast's Undo is also the bar's own undo control. + if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss(); + const index = this.state.actingSeatIndex; const player = this.state.seats[index]; const stack = Betting.stackOf(this.state, player); const maxTo = Betting.round(Betting.committed(player) + stack); const pot = Betting.potSoFar(this.state); - const minTo = Math.min(Betting.minRaiseTo(this.state), maxTo); + // Two different minimums, and conflating them was a bug. `minTo` is clamped to + // what this player can actually put in, which is what the Min button should + // offer. `legalMin` is what the rules require — for a short stack the two are + // different, and it's the rules one the warnings have to be measured against, + // or a player whose whole stack is under a full raise never gets told that. + const legalMin = Betting.minRaiseTo(this.state); + const minTo = Math.min(legalMin, maxTo); const wrap = document.createElement("div"); wrap.className = "raisePad"; @@ -360,6 +374,7 @@ const ActionBar = { input.setAttribute("aria-label", "Raise to amount"); input.addEventListener("input", () => { this.raiseDraft = input.value.replace(/[^0-9.]/g, ""); + this.updateRaiseNote(wrap, legalMin, maxTo); }); entry.appendChild(input); @@ -376,9 +391,73 @@ const ActionBar = { })); wrap.appendChild(entry); + + const note = document.createElement("p"); + note.className = "raiseNote"; + note.hidden = true; + wrap.appendChild(note); + this.updateRaiseNote(wrap, legalMin, maxTo); + return wrap; }, + // Says something when the amount typed isn't a legal raise. It never blocks — + // a dealer overruling the arithmetic is right more often than the arithmetic is, + // and the whole tool is built on tapping what the humans actually did. + // + // But an under-minimum raise from a player with chips behind them is the single + // most common thing that stops a home game dead, and until now the bar took it + // without a word: type 13 when the minimum is 18 and it was simply accepted. The + // engine then treats it as a short raise and correctly doesn't reopen the betting, + // which is right for an all-in and wrong for somebody who just misheard the bet. + updateRaiseNote(wrap, minTo, maxTo) { + const note = wrap.querySelector(".raiseNote"); + if (!note) return; + + const typed = Number(this.raiseDraft); + const current = this.state.currentBet || 0; + + if (!Number.isFinite(typed) || typed <= 0) { note.hidden = true; return; } + + // Going all-in for less than a full raise is perfectly legal, and worth saying + // out loud because of what follows from it. + const isAllIn = Math.abs(typed - maxTo) < Betting.EPSILON_CHIPS; + + if (typed > current && typed < minTo - Betting.EPSILON_CHIPS && !isAllIn) { + note.textContent = + `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`; + note.className = "raiseNote raiseNoteWarn"; + note.hidden = false; + return; + } + + if (isAllIn && typed > current && typed < minTo - Betting.EPSILON_CHIPS) { + note.textContent = + `All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`; + note.className = "raiseNote raiseNoteInfo"; + note.hidden = false; + return; + } + + if (typed > current && typed < current + Betting.EPSILON_CHIPS) { note.hidden = true; return; } + + if (typed > maxTo + Betting.EPSILON_CHIPS) { + note.textContent = `More than they have. This goes in as all-in for ${Money.format(maxTo)}.`; + note.className = "raiseNote raiseNoteInfo"; + note.hidden = false; + return; + } + + if (typed <= current) { + note.textContent = `${Money.format(typed)} is not above the ${Money.format(current)} already bet — that's a call, not a raise.`; + note.className = "raiseNote raiseNoteWarn"; + note.hidden = false; + return; + } + + note.hidden = true; + }, + renderStreetDone() { const row = document.createElement("div"); row.className = "actionButtons";