From fc866597eb3e9d0f8988754af550f07856d11a4b Mon Sep 17 00:00:00 2001
From: JoshHeaps
+ The player left of the big blind may put out twice it before the cards, + without looking. It is a blind bet, not a raise made in turn, so it does + three things at once: everyone else acts first, the minimum raise becomes + twice the straddle, and the straddler still has the option when the + action comes back — they may raise even if nobody raised into them. +
++ Re-straddles carry on to the left, each doubling again, and stop before + the small blind. Tap Straddle on the deal prompt and the tool + posts it, puts the right player on the clock and prices the raise; + the seat wears an STR badge for the hand. +
+diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index c752e8f..4903923 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -796,6 +796,19 @@ button, color: #241a05; } +/* The straddle. It takes the big blind's slot on the puck, which is free by + construction: the chain starts left of the big blind and stops short of the small + one, so no seat ever wears two of these. + + Violet rather than a third shade of gold or blue, because the badge a dealer is + looking for mid-hand is the one telling them where the action comes back to. + Dark text on the light end of the ramp, measured 5.76:1 against the darker stop. */ +.strBadge { + left: 22px; + background: linear-gradient(180deg, #cbaaf0, #a97fd6); + color: #1b1030; +} + /* 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 { @@ -3520,6 +3533,38 @@ textarea:focus-visible { font-weight: 600; } +/* The straddle rides on the deal row, so it must not take half of it: .actionBtn is + flex: 1 and would have split the row evenly with the button the dealer presses every + hand. It sizes to its own words instead, and stays quiet next to the green one — + choosing a straddle is the exception, dealing is the rule. */ +.actionBtn.actionStraddle { + flex: 0 1 auto; + background: linear-gradient(180deg, #33304a, #24223a); + border-color: #4b4770; + color: #e6e1ff; + font-size: 0.82rem; + padding-left: 0.7rem; + padding-right: 0.7rem; +} + +.actionBtn.actionStraddleOff { + background: linear-gradient(180deg, #2e2e2e, #232323); + border-color: #4a4a4a; + color: #dcdcdc; +} + +/* Three buttons across a 360px phone is a squeeze, and the two ways out are both + worse than this one. Letting the row wrap put the deal button on its own line and + made the bar 64px taller — measured at 360x640, that is the bottom seat three fifths + behind the bar, and this layout deliberately doesn't shrink the felt to suit (see the + max-width: none override, where capping it was tried and rejected). Letting flex + squash freely took the deal button to 84px with its label over three lines. + A floor of 7rem keeps the label to two lines and the row to one, so arming a straddle + costs no felt at all. */ +.dealPromptButtons .actionDeal { + min-width: 7rem; +} + /* The quieter half of the deal prompt: things the deal is about to do that a dealer would otherwise only discover afterwards. */ .dealPromptLine.dealPromptAside { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 0ec7dbd..5455772 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -592,6 +592,43 @@ const ActionBar = { // by itself the instant the previous hand ended, which meant blinds were committed // while people were still sitting down — and the blind badges would drift away from // the money already posted as seats changed. Now the dealer says when. + // ", Dee straddles $4" — read out as part of what goes in before the cards, because + // that is what it is. Two of them read as "Dee straddles $4, Eve $8": the second + // name doesn't need the verb again, and the bar has no room for it. + straddleClause(preview) { + const straddles = preview.straddles || []; + if (straddles.length === 0) return ""; + const name = i => HoldemState.displayName(this.state.seats[i], i); + const [first, ...rest] = straddles; + return `, ${name(first.index)} straddles ${Money.format(first.amount)}` + + rest.map(s => `, ${name(s.index)} ${Money.format(s.amount)}`).join(""); + }, + + // Turning it on, doubling it, and calling it off. Heads-up there is no seat left of + // the big blind to straddle from, so nothing is offered rather than a button that + // would do nothing. + straddleButtons(preview) { + const on = (preview.straddles || []).length; + const max = preview.maxStraddles || 0; + if (max <= 0) return []; + + const buttons = []; + const nextAmount = Money.format(this.state.bigBlind * Math.pow(2, on + 1)); + if (on < max) { + buttons.push(this.button( + on === 0 ? `Straddle ${nextAmount}` : `Re-straddle ${nextAmount}`, + "actionStraddle", + () => this.commit(on === 0 ? "the straddle" : "the re-straddle", + () => { this.state.straddleCount = on + 1; }))); + } + if (on > 0) { + buttons.push(this.button("No straddle", "actionStraddle actionStraddleOff", + () => this.commit("calling the straddle off", + () => { this.state.straddleCount = 0; }))); + } + return buttons; + }, + renderDealPrompt() { const wrap = document.createElement("div"); wrap.className = "dealPrompt"; @@ -689,11 +726,14 @@ const ActionBar = { // A dead small blind is a hand with only one blind in it. Saying "nobody posts // the small blind" out loud is the whole point — a dealer who reads a preview // with a blind missing and isn't told why will assume the tool has lost track. - line.textContent = preview.deadSmallBlind + const blinds = document.createElement("span"); + blinds.textContent = (preview.deadSmallBlind ? `No small blind this hand, ${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` : `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` + - `${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` + - `${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`; + `${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`) + + this.straddleClause(preview) + + `${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`; + line.appendChild(blinds); wrap.appendChild(line); // Why a position is missing. Both of these are the dead button rule doing its @@ -767,7 +807,12 @@ const ActionBar = { } const row = document.createElement("div"); - row.className = "actionButtons"; + row.className = "actionButtons dealPromptButtons"; + // Beside the deal button rather than on a line of its own. Measured at 390x844: + // its own line cost 44px of felt on every deal prompt, straddle or no straddle, + // and this row is already there and already 44px tall. It reads in the order it + // happens, too — say the straddle, then deal. + this.straddleButtons(preview).forEach(button => row.appendChild(button)); row.appendChild(this.button("Post blinds & deal", "actionDeal", () => { this.commit("dealing the hand", () => Betting.beginHand(this.state)); })); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index baa240e..07f9b21 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -93,9 +93,60 @@ const Betting = { satOut, smallBlind: this.round(state.smallBlind || 0), bigBlind: this.round(state.bigBlind || 0), + // What the straddles will be if the hand is dealt as it stands, and how + // many more this table could take. + straddles: this.straddleSeats(state), + maxStraddles: this.maxStraddles(state), }; }, + // ---- Straddles ---- + // + // A straddle is a blind raise put out before the cards, by the player left of the + // big blind, for twice it. It buys the last word before the flop: everyone else + // acts first, and the straddler still has the option when it comes back round even + // if nobody raised. Re-straddles carry on left, each doubling again. + // + // Home games run them constantly and a dealer has to get three things right at + // once — the amount, who is first to act, and what the minimum raise becomes. The + // tool used to do none of them, so a dealer's only way through was to deal, have + // the straddler "raise" to $4, and hand the action to the wrong player. + + // How many straddles this table could take. Everyone but the two blinds can put + // one out; heads-up that is nobody, which is the right answer — the button is + // already the small blind and there is no seat left of the big blind to straddle + // from. + maxStraddles(state) { + return Math.max(0, this.liveSeats(this.tableAsDealt(state)).length - 2); + }, + + // Who straddles and for how much, walking left from the big blind and doubling. + // Asked of the table as it will be dealt, so a seat about to be sat out for having + // no chips is never handed a straddle it can't post. + straddleSeats(state, count = state.straddleCount) { + const wanted = Math.min(Math.max(0, Number(count) || 0), this.maxStraddles(state)); + if (wanted <= 0) return []; + + const dealt = this.tableAsDealt(state); + const buttonIndex = HoldemState.buttonSeatForDeal(dealt); + const asDealt = buttonIndex === dealt.dealerSeatIndex + ? dealt + : { ...dealt, dealerSeatIndex: buttonIndex }; + const { bbIndex } = HoldemState.getBlindSeats(asDealt); + if (bbIndex === null) return []; + + const straddles = []; + let index = bbIndex; + let amount = this.round((Number(state.bigBlind) || 0) * 2); + for (let n = 0; n < wanted; n++) { + index = this.nextToAct(asDealt, index); + if (index === null || index === bbIndex) break; + straddles.push({ index, amount }); + amount = this.round(amount * 2); + } + return straddles; + }, + // Does handing this seat chips also put them back in the game? // // At a real table those are one event, and here they were two: a busted player got @@ -475,6 +526,7 @@ const Betting = { state.currentBet = 0; state.actingSeatIndex = null; state.postedBlinds = null; + state.postedStraddles = []; return; } @@ -505,17 +557,35 @@ const Betting = { // from one who has just walked up and had the blinds go past them. state.dealtLastHand = state.seats.filter(p => p.inHand).map(p => p.id); - state.currentBet = this.round(Math.max( - sbIndex === null ? 0 : this.committed(state.seats[sbIndex]), - this.committed(state.seats[bbIndex]))); - state.lastRaiseSize = bb || 0; + // Straddles go out after the blinds and before the cards, each one left of the + // last. Posted here rather than left to the dealer to fake as a raise, because + // a raise from that seat would hand the action to the wrong player. + const straddles = this.straddleSeats(state); + straddles.forEach(({ index, amount }) => this.putIn(state, index, amount)); + state.postedStraddles = straddles.map(({ index }) => index); + + state.currentBet = this.round(state.seats.reduce( + (most, player) => Math.max(most, this.committed(player)), 0)); + // A straddle is a blind raise, so what has to be matched to raise again is the + // straddle itself, not the big blind underneath it: $2 blinds with a $4 + // straddle make the next raise $8, the same way the $2 blind makes it $4. + // Left alone, the minimum would come out at $6 and the dealer would be talked + // out of it by every player at the table. + state.lastRaiseSize = straddles.length > 0 ? state.currentBet : (bb || 0); // 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. + // + // A straddle moves that seat along: first to act is left of the last straddler, + // and the straddler keeps the option, which falls out of nobody having acted + // yet. + const lastPosted = straddles.length > 0 + ? straddles[straddles.length - 1].index + : bbIndex; state.actingSeatIndex = headsUp ? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex)) - : this.nextToAct(state, bbIndex); + : this.nextToAct(state, lastPosted); }, // What a pot-sized raise actually is, as a "raise to" total. diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index e64bc97..3ec0850 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -310,6 +310,14 @@ const Render = { sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex); bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex); + // A straddle is a blind too, and the seat wearing it is the one the action + // comes back to. Marked from what the deal actually posted rather than from + // the count, so the badge can't outlive the chips it stands for — before + // the cards there is nothing out there yet. + const straddleBadge = node.querySelector(".strBadge"); + const straddled = (this.state.postedStraddles || []).includes(index); + straddleBadge.classList.toggle("visible", this.state.bettingOpen && straddled); + this.updatePuckContent(node, player, index); node.classList.toggle("seatSelected", index === this.selectedSeatIndex); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index fdc7e4a..d3404ae 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -100,6 +100,14 @@ const HoldemState = { // the big blind rather than waiting for it. Cleared every hand: it's a // decision about one deal, not a standing arrangement. postingIn: [], + // How many straddles are on for the hand about to be dealt. A straddle is + // announced hand by hand, so this is cleared with every other one-deal + // decision rather than standing until somebody turns it off. + straddleCount: 0, + // Which seats actually put a straddle out when the hand was dealt, so the + // felt can badge them. Read off the deal rather than worked out again + // later, the same way the posted blinds are. + postedStraddles: [], // Set when somebody leaves out of the blinds and the rotation has to hold // to the seats to keep the big blind moving one player at a time. The // button then sits on the chair they vacated, and no small blind is posted. @@ -201,6 +209,11 @@ const HoldemState = { // asking the whole table to post. if (!Array.isArray(parsed.dealtLastHand)) parsed.dealtLastHand = []; if (!Array.isArray(parsed.postingIn)) parsed.postingIn = []; + // None on is the right reading of a save from before straddles existed: + // whatever is in front of those players is already in their committed + // chips, and nothing is owed on top of it. + if (!(Number(parsed.straddleCount) > 0)) parsed.straddleCount = 0; + if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = []; // A table saved before dealing and betting were separate has no flag, and // guessing false would show "deal the hand" over a hand already in @@ -643,6 +656,10 @@ const HoldemState = { // Posting to come in is a decision about one deal. Carrying it forward would // charge somebody an extra big blind every hand for the rest of the night. state.postingIn = []; + // So is a straddle. It is announced hand by hand at a table, and a straddle + // that stayed on would take chips off somebody who never said the word. + state.straddleCount = 0; + state.postedStraddles = []; state.seats.forEach(p => { p.folded = false; p.cards = ["", ""]; diff --git a/tests/holdem/straddle.test.js b/tests/holdem/straddle.test.js new file mode 100644 index 0000000..e50693f --- /dev/null +++ b/tests/holdem/straddle.test.js @@ -0,0 +1,188 @@ +// The straddle: a blind raise put out before the cards by the player left of the big +// blind, for twice it. +// +// Home games run them constantly, and it is three rules at once — the amount, who is +// first to act, and what the minimum raise becomes. Faking one by dealing and having +// the straddler "raise" gets the last of those right and the other two wrong. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load"); + +// Six seats, everyone in, button on seat 0: small blind seat 1, big blind seat 2, +// straddle seat 3. +const table = (straddles = 0, extra = {}) => { + const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0, ...extra }); + state.straddleCount = straddles; + return state; +}; + +const dealt = (straddles = 0, extra = {}) => { + const state = table(straddles, extra); + Betting.beginHand(state); + return state; +}; + +describe("what goes in the middle", () => { + test("the seat left of the big blind puts out twice it", () => { + const state = dealt(1); + assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]); + }); + + test("a re-straddle doubles again, one seat further round", () => { + const state = dealt(2); + assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 8, 0]); + }); + + test("none of it comes from anywhere but the straddler's stack", () => { + const before = chipsOnTable(table(2)); + assert.equal(chipsOnTable(dealt(2)), before); + }); + + test("the felt is told who straddled, not left to work it out", () => { + assert.deepEqual(dealt(2).postedStraddles, [3, 4]); + assert.deepEqual(dealt(0).postedStraddles, []); + }); +}); + +describe("who is on the clock", () => { + test("action starts left of the straddler, not left of the big blind", () => { + assert.equal(dealt(0).actingSeatIndex, 3, "without a straddle it is the seat after the big blind"); + assert.equal(dealt(1).actingSeatIndex, 4); + assert.equal(dealt(2).actingSeatIndex, 5); + }); + + test("the straddler still has the option when it comes back to them", () => { + const state = dealt(1); + // Everyone calls the straddle round to the big blind. + [4, 5, 0, 1, 2].forEach(seat => { + assert.equal(state.actingSeatIndex, seat, `expected seat ${seat} to be up`); + Betting.call(state, seat); + }); + assert.equal(state.actingSeatIndex, 3, "back to the straddler"); + assert.equal(Betting.toCall(state, state.seats[3]), 0, "nothing to call"); + assert.equal(Betting.isStreetComplete(state), false, "and the street isn't over until they say so"); + }); + + test("the straddler checking closes it", () => { + const state = dealt(1); + [4, 5, 0, 1, 2].forEach(seat => Betting.call(state, seat)); + Betting.check(state, 3); + assert.equal(Betting.isStreetComplete(state), true); + assert.equal(state.actingSeatIndex, null); + }); + + test("the big blind acts in turn, not last", () => { + const state = dealt(1); + Betting.call(state, 4); + Betting.call(state, 5); + Betting.call(state, 0); + Betting.call(state, 1); + assert.equal(state.actingSeatIndex, 2, "the big blind is up before the straddler"); + assert.equal(Betting.toCall(state, state.seats[2]), 2, "and owes the difference to the straddle"); + }); +}); + +describe("what it costs to raise", () => { + test("the minimum raise is twice the straddle, not the straddle plus a blind", () => { + // $2 blinds, $4 straddle: the next raise is to $8. Measured off the big blind + // instead it comes out at $6, and every player at the table would argue. + assert.equal(Betting.minRaiseTo(dealt(1)), 8); + assert.equal(Betting.minRaiseTo(dealt(2)), 16); + assert.equal(Betting.minRaiseTo(dealt(0)), 4); + }); + + test("what the table owes is measured off the straddle", () => { + const state = dealt(1); + assert.equal(state.currentBet, 4); + assert.equal(Betting.toCall(state, state.seats[4]), 4, "a fresh player calls the straddle"); + assert.equal(Betting.toCall(state, state.seats[1]), 3, "the small blind owes the rest"); + assert.equal(Betting.toCall(state, state.seats[2]), 2, "so does the big blind"); + assert.equal(Betting.toCall(state, state.seats[3]), 0, "the straddler is already good"); + }); + + test("a raise over the straddle is a raise, and the straddler can still re-raise", () => { + const state = dealt(1); + Betting.raiseTo(state, 4, 12); + assert.equal(state.currentBet, 12); + assert.equal(Betting.minRaiseTo(state), 20, "twice again over a raise of eight"); + assert.equal(state.seats[3].hasActed, false, "the straddler has not acted yet"); + }); +}); + +describe("how many a table can take", () => { + test("everyone but the two blinds", () => { + assert.equal(Betting.maxStraddles(table(0)), 4); + assert.equal(Betting.maxStraddles(table(0, { seatCount: 3, seated: [0, 1, 2] })), 1); + }); + + test("heads-up, none — there is no seat left of the big blind to straddle from", () => { + const heads = table(1, { seatCount: 6, seated: [0, 1] }); + assert.equal(Betting.maxStraddles(heads), 0); + assert.deepEqual(Betting.straddleSeats(heads), []); + Betting.beginHand(heads); + assert.deepEqual(heads.seats.map(p => p.committed), [1, 2, 0, 0, 0, 0]); + }); + + test("asking for more than the table has stops at the last seat", () => { + const state = table(0, { seatCount: 4, seated: [0, 1, 2, 3] }); + state.straddleCount = 99; + assert.equal(Betting.straddleSeats(state).length, 2); + Betting.beginHand(state); + assert.deepEqual(state.seats.map(p => p.committed), [8, 1, 2, 4]); + assert.equal(state.actingSeatIndex, 1, "round to the small blind, who is first to act"); + }); + + test("a seat with no chips is never handed one", () => { + const state = table(1); + state.seats[3].stack = 0; + // Seat 3 is sat out for the deal, so the straddle passes to seat 4. + assert.deepEqual(Betting.straddleSeats(state), [{ index: 4, amount: 4 }]); + }); +}); + +describe("what it doesn't outlive", () => { + test("the hand it was called for", () => { + const state = dealt(1); + HoldemState.startNextHand(state); + assert.equal(state.straddleCount, 0, "nobody straddles again by accident"); + assert.deepEqual(state.postedStraddles, []); + }); + + test("but it survives the button being pushed around before the deal", () => { + // repostBlinds takes every posted chip back and deals again; a straddle that + // vanished there would have been paid once and never returned. + const state = dealt(1); + const before = chipsOnTable(state); + Betting.repostBlinds(state); + assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]); + assert.equal(chipsOnTable(state), before); + }); + + test("a table saved before straddles existed reads as having none", () => { + const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 }))); + delete parsed.straddleCount; + delete parsed.postedStraddles; + const migrated = HoldemState.migrate(parsed); + assert.equal(migrated.straddleCount, 0); + assert.deepEqual(migrated.postedStraddles, []); + }); +}); + +describe("what the dealer is shown before dealing", () => { + test("the straddles that are coming, and how many more it could take", () => { + const preview = Betting.blindPreview(table(1)); + assert.deepEqual(preview.straddles, [{ index: 3, amount: 4 }]); + assert.equal(preview.maxStraddles, 4); + }); + + test("and the preview matches what dealing actually does", () => { + const state = table(2); + const preview = Betting.blindPreview(state); + Betting.beginHand(state); + assert.deepEqual(preview.straddles.map(s => s.index), state.postedStraddles); + preview.straddles.forEach(({ index, amount }) => { + assert.equal(state.seats[index].committed, amount, `seat ${index + 1}`); + }); + }); +});