diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 8abed50..647c051 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -345,7 +345,10 @@ const ActionBar = { } if (stack > owed) { - row.appendChild(this.button(owed > 0 ? "Raise" : "Bet", "actionRaise", () => { + // Not "does this player owe anything" — the big blind owes nothing and is + // still raising. It's whether there is a live bet to raise. + const raising = Betting.wagerVerb(this.state) === "raise"; + row.appendChild(this.button(raising ? "Raise" : "Bet", "actionRaise", () => { this.raiseOpen = true; this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack); this.render(); @@ -378,6 +381,7 @@ const ActionBar = { // 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 raising = Betting.wagerVerb(this.state) === "raise"; const wrap = document.createElement("div"); wrap.className = "raisePad"; @@ -410,7 +414,7 @@ const ActionBar = { input.inputMode = "decimal"; input.className = "raiseInput"; input.value = this.raiseDraft ?? minTo; - input.setAttribute("aria-label", "Raise to amount"); + input.setAttribute("aria-label", raising ? "Raise to amount" : "Bet amount"); input.addEventListener("input", () => { this.raiseDraft = input.value.replace(/[^0-9.]/g, ""); this.updateRaiseNote(wrap, legalMin, maxTo); @@ -422,11 +426,16 @@ const ActionBar = { this.render(); })); - entry.appendChild(this.button("Raise to", "raiseConfirm", () => { + // "Raise to $60" and "Bet $10" — a bet isn't *to* anything, there's nothing + // under it to raise past. + entry.appendChild(this.button(raising ? "Raise to" : "Bet", "raiseConfirm", () => { const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo)); this.raiseOpen = false; const who = HoldemState.displayName(player, index); - this.commit(`${who}'s raise to ${Money.format(target)}`, () => Betting.raiseTo(this.state, index, target)); + const label = raising + ? `${who}'s raise to ${Money.format(target)}` + : `${who}'s bet of ${Money.format(target)}`; + this.commit(label, () => Betting.raiseTo(this.state, index, target)); })); wrap.appendChild(entry); @@ -455,6 +464,7 @@ const ActionBar = { const typed = Number(this.raiseDraft); const current = this.state.currentBet || 0; + const raising = Betting.wagerVerb(this.state) === "raise"; if (!Number.isFinite(typed) || typed <= 0) { note.hidden = true; return; } @@ -463,16 +473,20 @@ const ActionBar = { 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.`; + // The way out of an under-minimum differs: with a bet in front of you the + // alternative is calling it, and with an empty pot there's nothing to call. + note.textContent = raising + ? `${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.` + : `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal bet is ${Money.format(minTo)} or more — otherwise it's a check, 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.textContent = raising + ? `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.` + : `All-in for less than a full bet. Legal, but it doesn't reopen the betting — anyone who has already checked can call it or fold, not raise.`; note.className = "raiseNote raiseNoteInfo"; note.hidden = false; return; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index c3e4494..f445e2f 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -90,6 +90,22 @@ const Betting = { }; }, + // "bet" or "raise" — what putting more in is actually called right now. + // + // A wager into a pot nobody has wagered into is a bet; everything after that is a + // raise. The bar decided this from what the acting player still owes, which gets + // the big blind's option backwards: they owe nothing, so it offered "Bet", but + // their own blind is a live bet and putting more in is a raise. It gets the other + // half wrong too — the raise pad said "Raise to" for an opening bet on the flop, + // and wrote "Ann's raise to $10" into the undo control for it. + // + // The arithmetic was always right (minRaiseTo returns two big blinds for the big + // blind's option and one for an opening bet). This is only what it's called, which + // for a tool a dealer announces the action from is not a small thing. + wagerVerb(state) { + return (state.currentBet || 0) > this.EPSILON_CHIPS ? "raise" : "bet"; + }, + committed(player) { return this.round(player.committed || 0); }, diff --git a/tests/holdem/wagerVerb.test.js b/tests/holdem/wagerVerb.test.js new file mode 100644 index 0000000..20604d5 --- /dev/null +++ b/tests/holdem/wagerVerb.test.js @@ -0,0 +1,83 @@ +// Whether putting more in is called a bet or a raise. +// +// The arithmetic was always right — minRaiseTo returns two big blinds for the big +// blind's option and one for an opening bet. This is only what it's called, which for +// a tool a dealer announces the action from is not a small thing. +const test = require("node:test"); +const assert = require("node:assert"); +const { Betting, makeTable } = require("./load"); + +// Walks the preflop action round to the big blind, who is owed nothing and gets to +// choose between checking and putting more in. +function toTheBigBlindsOption() { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 }); + Betting.beginHand(state); + const bigBlind = state.seats.findIndex(player => Betting.committed(player) === state.bigBlind); + while (state.actingSeatIndex !== null && state.actingSeatIndex !== bigBlind) { + Betting.call(state, state.actingSeatIndex); + } + return { state, bigBlind }; +} + +test("the big blind's option is a raise, not a bet", () => { + const { state, bigBlind } = toTheBigBlindsOption(); + + assert.equal(state.actingSeatIndex, bigBlind); + assert.equal(Betting.toCall(state, state.seats[bigBlind]), 0, "they owe nothing"); + assert.equal(Betting.wagerVerb(state), "raise", "but their own blind is a live bet"); +}); + +test("and the minimum there is a real raise, not a call of the blind", () => { + const { state } = toTheBigBlindsOption(); + assert.equal(Betting.minRaiseTo(state), state.bigBlind * 2); +}); + +test("facing the blinds preflop is a raise", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 }); + Betting.beginHand(state); + assert.equal(Betting.wagerVerb(state), "raise"); +}); + +test("first in on the flop is a bet", () => { + const { state, bigBlind } = toTheBigBlindsOption(); + Betting.check(state, bigBlind); + Betting.closeStreet(state); + + assert.equal(state.street, "flop"); + assert.equal(Betting.wagerVerb(state), "bet"); + assert.equal(Betting.minRaiseTo(state), state.bigBlind, "a first bet is one big blind"); +}); + +test("after that bet it's a raise again", () => { + const { state, bigBlind } = toTheBigBlindsOption(); + Betting.check(state, bigBlind); + Betting.closeStreet(state); + Betting.raiseTo(state, state.actingSeatIndex, 10); + + assert.equal(Betting.wagerVerb(state), "raise"); +}); + +test("and a bet again on the next street once it's checked around", () => { + const { state, bigBlind } = toTheBigBlindsOption(); + Betting.check(state, bigBlind); + Betting.closeStreet(state); + while (state.actingSeatIndex !== null) Betting.check(state, state.actingSeatIndex); + Betting.closeStreet(state); + + assert.equal(state.street, "turn"); + assert.equal(Betting.wagerVerb(state), "bet"); +}); + +test("a table playing without blinds opens with a bet", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, smallBlind: 0, bigBlind: 0 }); + Betting.beginHand(state); + assert.equal(state.currentBet, 0); + assert.equal(Betting.wagerVerb(state), "bet"); +}); + +test("asking doesn't change anything", () => { + const { state } = toTheBigBlindsOption(); + const before = JSON.stringify(state); + Betting.wagerVerb(state); + assert.equal(JSON.stringify(state), before); +});