Merge pull request 'Live betting: tap the action instead of typing totals' (#21) from feature/casino-holdem-live-betting into master
Build and Deploy / build-deploy (push) Successful in 1m11s
Build and Deploy / build-deploy (push) Successful in 1m11s
Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
@@ -67,6 +67,21 @@
|
||||
<input type="checkbox" id="dedicatedDealerToggle" />
|
||||
</label>
|
||||
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
|
||||
<div class="blindsRow">
|
||||
<label class="fieldRow">
|
||||
<span>Small blind</span>
|
||||
<input type="number" id="smallBlindInput" min="0" step="0.5" />
|
||||
</label>
|
||||
<label class="fieldRow">
|
||||
<span>Big blind</span>
|
||||
<input type="number" id="bigBlindInput" min="0" step="0.5" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="fieldRow">
|
||||
<span>Starting stack ($)</span>
|
||||
<input type="number" id="defaultStackInput" min="0" step="1" placeholder="e.g. 200" />
|
||||
</label>
|
||||
<p class="fieldHint">Blinds post themselves each hand, and every seat without its own stack starts here. Set a seat's own stack in its editor.</p>
|
||||
<label class="fieldRow">
|
||||
<span>Base pot amount ($)</span>
|
||||
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
|
||||
@@ -131,6 +146,7 @@
|
||||
<div class="seatPuckName"></div>
|
||||
<div class="seatPuckCards"></div>
|
||||
<div class="seatPuckTotal"></div>
|
||||
<div class="seatPuckStack"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -154,6 +170,10 @@
|
||||
<input type="text" class="cardInput card1Input" maxlength="2" placeholder="__" />
|
||||
<input type="text" class="cardInput card2Input" maxlength="2" placeholder="__" />
|
||||
</div>
|
||||
<label class="fieldRow stackRow">
|
||||
<span>Stack</span>
|
||||
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
|
||||
</label>
|
||||
<div class="seatContribution">
|
||||
<div class="chipCounts"></div>
|
||||
<label class="fieldRow amountOverrideRow">
|
||||
@@ -210,6 +230,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div id="actionBar" aria-live="polite" hidden></div>
|
||||
|
||||
<div id="toastHost" aria-live="polite"></div>
|
||||
|
||||
<div id="confirmOverlay" hidden>
|
||||
@@ -243,6 +265,8 @@
|
||||
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/betting.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/actionBar.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
|
||||
|
||||
@@ -594,6 +594,10 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
/* Grid items default to min-width:auto, so anything with a wide min-content —
|
||||
a side-by-side field row, say — would push this column past the viewport and
|
||||
drag the table out with it. */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebarPanel {
|
||||
@@ -790,16 +794,245 @@ body {
|
||||
|
||||
/* ---------- Pot wizard ---------- */
|
||||
|
||||
/* ---------- Action bar ---------- */
|
||||
|
||||
#actionBar {
|
||||
position: fixed;
|
||||
/* Centred with auto margins rather than 100vw + translate: on a phone with a
|
||||
scrollbar, 100vw is wider than the viewport and the overflow drags the whole
|
||||
table sideways. */
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: 0 auto;
|
||||
z-index: 55;
|
||||
max-width: 720px;
|
||||
background: linear-gradient(180deg, #232323, #191919);
|
||||
border: 1px solid #383838;
|
||||
border-bottom: none;
|
||||
border-radius: 14px 14px 0 0;
|
||||
padding: 0.6rem 0.75rem calc(0.75rem + env(safe-area-inset-bottom, 0px));
|
||||
box-shadow: 0 -12px 34px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
#actionBar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The bar is fixed over the page, so the sidebar needs room to scroll past it. */
|
||||
body.hasActionBar #holdemSidebar {
|
||||
padding-bottom: 9rem;
|
||||
}
|
||||
|
||||
.actionStatus {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.actionStreet {
|
||||
color: #d8b46a;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.actionPot {
|
||||
color: #e9e9e9;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.actionWho {
|
||||
margin-left: auto;
|
||||
color: #9c9c9c;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
min-height: 56px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 11px;
|
||||
border: 1px solid #3a3a3a;
|
||||
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||
color: #eee;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
|
||||
.actionBtn:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
}
|
||||
|
||||
.actionBtn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.actionBtnLabel {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actionBtnSub {
|
||||
font-size: 0.72rem;
|
||||
color: #a8a8a8;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.actionFold {
|
||||
background: linear-gradient(180deg, #3d1f1f, #2c1515);
|
||||
border-color: #5b2626;
|
||||
color: #ffd6d6;
|
||||
}
|
||||
|
||||
.actionCheck,
|
||||
.actionCall {
|
||||
background: linear-gradient(180deg, #16783a, #0d5225);
|
||||
border-color: #1e8449;
|
||||
color: #ecffef;
|
||||
}
|
||||
|
||||
.actionCheck .actionBtnSub,
|
||||
.actionCall .actionBtnSub {
|
||||
color: #bfe8cb;
|
||||
}
|
||||
|
||||
.actionRaise {
|
||||
background: linear-gradient(180deg, #3a2f18, #241d0f);
|
||||
border-color: #a9782f;
|
||||
color: #f0d9a8;
|
||||
}
|
||||
|
||||
.actionAdvance {
|
||||
background: linear-gradient(180deg, #2b3f57, #1b2a3a);
|
||||
border-color: #3d5f83;
|
||||
color: #dceaf8;
|
||||
}
|
||||
|
||||
/* ---------- Raise pad ---------- */
|
||||
|
||||
.raiseQuick {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.raiseQuickBtn {
|
||||
min-height: 48px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.raiseQuickBtn.selected {
|
||||
border-color: #d8b46a;
|
||||
background: linear-gradient(180deg, #3a2f18, #241d0f);
|
||||
color: #f0d9a8;
|
||||
}
|
||||
|
||||
.raiseEntry {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.raiseInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #141414;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 11px;
|
||||
color: #f2f2f2;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.raiseInput:focus {
|
||||
outline: none;
|
||||
border-color: #d8b46a;
|
||||
}
|
||||
|
||||
.raiseCancel {
|
||||
flex: 0 0 auto;
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
.raiseConfirm {
|
||||
flex: 0 0 auto;
|
||||
min-width: 116px;
|
||||
background: linear-gradient(180deg, #3a2f18, #241d0f);
|
||||
border-color: #a9782f;
|
||||
color: #f0d9a8;
|
||||
}
|
||||
|
||||
/* Whose turn it is, shown on the table itself as well as in the bar. */
|
||||
.seat.seatActing {
|
||||
border-color: #d8b46a;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(216, 180, 106, 0.45),
|
||||
0 4px 16px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.seatPuckStack {
|
||||
font-size: 0.58rem;
|
||||
color: #8f8f8f;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.blindsRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.blindsRow .fieldRow {
|
||||
flex: 1 1 120px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.blindsRow input {
|
||||
min-width: 0;
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
/* ---------- Toasts ---------- */
|
||||
|
||||
#toastHost {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 1.25rem;
|
||||
transform: translateX(-50%);
|
||||
margin: 0 auto;
|
||||
z-index: 80;
|
||||
width: min(560px, calc(100vw - 1.5rem));
|
||||
max-width: 560px;
|
||||
padding: 0 0.75rem;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
transition: bottom 0.18s ease;
|
||||
}
|
||||
|
||||
/* Sit above the action bar rather than on top of it, so an Undo never covers the
|
||||
Fold button the dealer is reaching for. */
|
||||
body.hasActionBar #toastHost {
|
||||
bottom: calc(9rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.toast {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// The bar a dealer's thumb lives on while a hand is running: who's to act, what it
|
||||
// costs them, and one tap per decision. Sits at the bottom of the screen on a phone
|
||||
// so it's reachable without looking, and re-renders after every action because the
|
||||
// next player's numbers are different.
|
||||
const ActionBar = {
|
||||
state: null,
|
||||
hooks: null,
|
||||
raiseOpen: false,
|
||||
raiseDraft: null,
|
||||
|
||||
mount(state, hooks) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.render();
|
||||
},
|
||||
|
||||
// Anything to act on? A hand with nobody dealt in doesn't get a bar.
|
||||
isRunning() {
|
||||
return Betting.liveSeats(this.state).length > 0;
|
||||
},
|
||||
|
||||
render() {
|
||||
const host = document.getElementById("actionBar");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
|
||||
if (!this.isRunning()) {
|
||||
host.hidden = true;
|
||||
document.body.classList.remove("hasActionBar");
|
||||
return;
|
||||
}
|
||||
host.hidden = false;
|
||||
document.body.classList.add("hasActionBar");
|
||||
|
||||
host.appendChild(this.renderStatus());
|
||||
|
||||
if (Betting.isHandOver(this.state)) {
|
||||
host.appendChild(this.renderHandOver());
|
||||
} else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) {
|
||||
host.appendChild(this.renderStreetDone());
|
||||
} else if (this.raiseOpen) {
|
||||
host.appendChild(this.renderRaisePad());
|
||||
} else {
|
||||
host.appendChild(this.renderActions());
|
||||
}
|
||||
},
|
||||
|
||||
renderStatus() {
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionStatus";
|
||||
|
||||
const street = document.createElement("span");
|
||||
street.className = "actionStreet";
|
||||
street.textContent = Betting.streetLabel(this.state.street);
|
||||
row.appendChild(street);
|
||||
|
||||
const pot = document.createElement("span");
|
||||
pot.className = "actionPot";
|
||||
pot.textContent = `Pot ${Money.format(Betting.potSoFar(this.state))}`;
|
||||
row.appendChild(pot);
|
||||
|
||||
const index = this.state.actingSeatIndex;
|
||||
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
|
||||
const player = this.state.seats[index];
|
||||
const who = document.createElement("span");
|
||||
who.className = "actionWho";
|
||||
who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`;
|
||||
row.appendChild(who);
|
||||
}
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
button(label, className, onClick, sublabel) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `actionBtn ${className}`;
|
||||
|
||||
const main = document.createElement("span");
|
||||
main.className = "actionBtnLabel";
|
||||
main.textContent = label;
|
||||
btn.appendChild(main);
|
||||
|
||||
if (sublabel) {
|
||||
const sub = document.createElement("span");
|
||||
sub.className = "actionBtnSub";
|
||||
sub.textContent = sublabel;
|
||||
btn.appendChild(sub);
|
||||
}
|
||||
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
},
|
||||
|
||||
commit(mutate) {
|
||||
mutate();
|
||||
this.hooks.save(this.state);
|
||||
this.hooks.rerender();
|
||||
},
|
||||
|
||||
renderActions() {
|
||||
const index = this.state.actingSeatIndex;
|
||||
const player = this.state.seats[index];
|
||||
const owed = Betting.toCall(this.state, player);
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
|
||||
row.appendChild(this.button("Fold", "actionFold", () => this.commit(() => Betting.fold(this.state, index))));
|
||||
|
||||
if (owed <= 0) {
|
||||
row.appendChild(this.button("Check", "actionCheck", () => this.commit(() => Betting.check(this.state, index))));
|
||||
} else if (owed >= stack) {
|
||||
// Calling would put them all-in, so say that rather than quoting a number
|
||||
// they can't cover.
|
||||
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(() => Betting.allIn(this.state, index)), Money.format(stack)));
|
||||
} else {
|
||||
row.appendChild(this.button("Call", "actionCall", () => this.commit(() => Betting.call(this.state, index)), Money.format(owed)));
|
||||
}
|
||||
|
||||
if (stack > owed) {
|
||||
row.appendChild(this.button(owed > 0 ? "Raise" : "Bet", "actionRaise", () => {
|
||||
this.raiseOpen = true;
|
||||
this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack);
|
||||
this.render();
|
||||
}));
|
||||
}
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
// 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() {
|
||||
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);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "raisePad";
|
||||
|
||||
const quick = document.createElement("div");
|
||||
quick.className = "raiseQuick";
|
||||
|
||||
const options = [
|
||||
{ label: "Min", value: minTo },
|
||||
{ label: "½ pot", value: Betting.round(this.state.currentBet + pot / 2) },
|
||||
{ label: "Pot", value: Betting.round(this.state.currentBet + pot) },
|
||||
{ label: "All-in", value: maxTo },
|
||||
];
|
||||
options.forEach(option => {
|
||||
const value = Math.max(minTo, Math.min(option.value, maxTo));
|
||||
const btn = this.button(option.label, "raiseQuickBtn", () => {
|
||||
this.raiseDraft = value;
|
||||
this.render();
|
||||
}, Money.format(value));
|
||||
if (Betting.round(this.raiseDraft) === value) btn.classList.add("selected");
|
||||
quick.appendChild(btn);
|
||||
});
|
||||
wrap.appendChild(quick);
|
||||
|
||||
const entry = document.createElement("div");
|
||||
entry.className = "raiseEntry";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.inputMode = "decimal";
|
||||
input.className = "raiseInput";
|
||||
input.value = this.raiseDraft ?? minTo;
|
||||
input.setAttribute("aria-label", "Raise to amount");
|
||||
input.addEventListener("input", () => {
|
||||
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
|
||||
});
|
||||
entry.appendChild(input);
|
||||
|
||||
entry.appendChild(this.button("Cancel", "raiseCancel", () => {
|
||||
this.raiseOpen = false;
|
||||
this.render();
|
||||
}));
|
||||
|
||||
entry.appendChild(this.button("Raise to", "raiseConfirm", () => {
|
||||
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
|
||||
this.raiseOpen = false;
|
||||
this.commit(() => Betting.raiseTo(this.state, index, target));
|
||||
}));
|
||||
|
||||
wrap.appendChild(entry);
|
||||
return wrap;
|
||||
},
|
||||
|
||||
renderStreetDone() {
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
|
||||
const isRiver = this.state.street === "river";
|
||||
const label = isRiver ? "Showdown — resolve pot" : `Deal ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1])}`;
|
||||
|
||||
row.appendChild(this.button(label, "actionAdvance", () => {
|
||||
if (isRiver) {
|
||||
this.hooks.resolvePot();
|
||||
return;
|
||||
}
|
||||
this.commit(() => Betting.closeStreet(this.state));
|
||||
}));
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
renderHandOver() {
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
const live = Betting.liveSeats(this.state);
|
||||
const winner = live.length === 1 ? HoldemState.displayName(live[0].player, live[0].index) : null;
|
||||
|
||||
row.appendChild(this.button(
|
||||
winner ? `Award pot to ${winner}` : "Resolve pot",
|
||||
"actionAdvance",
|
||||
() => this.hooks.resolvePot()
|
||||
));
|
||||
return row;
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = ActionBar;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Live betting: who's to act, what it costs them, and where the money goes.
|
||||
//
|
||||
// This is bookkeeping, not a game — the dealer taps what the humans at the table
|
||||
// actually did, and the tool keeps the arithmetic straight. It deliberately never
|
||||
// blocks an action it thinks is "illegal"; a dealer overruling the maths is right
|
||||
// more often than the maths is.
|
||||
//
|
||||
// Money model: `player.committed` is what a seat has put in on the current street.
|
||||
// When a street closes it sweeps into `player.amountOverride`, which is the seat's
|
||||
// total for the hand — the same field the pot badge, the pot wizard, the standings
|
||||
// and the CSV already read. So the betting bar is a fast way to fill in a field
|
||||
// that already existed, and nothing downstream had to change.
|
||||
const Betting = {
|
||||
STREETS: ["preflop", "flop", "turn", "river"],
|
||||
|
||||
streetLabel(street) {
|
||||
return { preflop: "Pre-flop", flop: "Flop", turn: "Turn", river: "River" }[street] || "Pre-flop";
|
||||
},
|
||||
|
||||
round(amount) {
|
||||
return Math.round((Number(amount) || 0) * 100) / 100;
|
||||
},
|
||||
|
||||
// ---- Reading the table ----
|
||||
|
||||
liveSeats(state) {
|
||||
return state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(s => s.player.inHand && !s.player.folded);
|
||||
},
|
||||
|
||||
committed(player) {
|
||||
return this.round(player.committed || 0);
|
||||
},
|
||||
|
||||
// Everything this seat has put in across the whole hand, including the street
|
||||
// still in progress.
|
||||
handTotal(player) {
|
||||
return this.round((Number(player.amountOverride) || 0) + this.committed(player));
|
||||
},
|
||||
|
||||
stackOf(state, player) {
|
||||
const own = player.stack;
|
||||
if (own !== null && own !== undefined && String(own).trim() !== "") return this.round(own);
|
||||
const fallback = Number(state.defaultStack);
|
||||
return Number.isNaN(fallback) ? 0 : this.round(fallback);
|
||||
},
|
||||
|
||||
toCall(state, player) {
|
||||
return Math.max(0, this.round((state.currentBet || 0) - this.committed(player)));
|
||||
},
|
||||
|
||||
// A raise has to at least match the last one; before any raise, the big blind
|
||||
// is the yardstick.
|
||||
minRaiseTo(state) {
|
||||
const increment = this.round(state.lastRaiseSize || state.bigBlind || 0);
|
||||
return this.round((state.currentBet || 0) + (increment || 1));
|
||||
},
|
||||
|
||||
potSoFar(state) {
|
||||
return this.round(
|
||||
state.seats.reduce((sum, p) => sum + this.handTotal(p), 0)
|
||||
);
|
||||
},
|
||||
|
||||
// ---- Moving the action ----
|
||||
|
||||
nextLiveIndex(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) >= 0) return index;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
seatAt(state, fromIndex, step) {
|
||||
const count = state.seatCount;
|
||||
const delta = state.direction === "counterclockwise" ? -step : step;
|
||||
return ((fromIndex + delta) % count + count) % count;
|
||||
},
|
||||
|
||||
// A street is done when everyone still live has either matched the bet or has
|
||||
// no chips left to argue with, and everyone has had a turn since the last raise.
|
||||
isStreetComplete(state) {
|
||||
const live = this.liveSeats(state);
|
||||
if (live.length <= 1) return true;
|
||||
|
||||
const unsettled = live.filter(s => {
|
||||
const allIn = this.stackOf(state, s.player) <= 0;
|
||||
const matched = this.toCall(state, s.player) <= 0;
|
||||
return !allIn && (!matched || !s.player.hasActed);
|
||||
});
|
||||
return unsettled.length === 0;
|
||||
},
|
||||
|
||||
// ---- Actions ----
|
||||
|
||||
// Moves `amount` from a seat's stack into the middle for this street. Returns
|
||||
// what actually moved, which is less than asked for if it puts them all-in.
|
||||
putIn(state, index, amount) {
|
||||
const player = state.seats[index];
|
||||
const stack = this.stackOf(state, player);
|
||||
const moved = this.round(Math.min(Math.max(0, amount), stack));
|
||||
|
||||
player.stack = this.round(stack - moved);
|
||||
player.committed = this.round(this.committed(player) + moved);
|
||||
return moved;
|
||||
},
|
||||
|
||||
fold(state, index) {
|
||||
state.seats[index].folded = true;
|
||||
state.seats[index].hasActed = true;
|
||||
this.passAction(state, index);
|
||||
},
|
||||
|
||||
check(state, index) {
|
||||
state.seats[index].hasActed = true;
|
||||
this.passAction(state, index);
|
||||
},
|
||||
|
||||
call(state, index) {
|
||||
const player = state.seats[index];
|
||||
this.putIn(state, index, this.toCall(state, player));
|
||||
player.hasActed = true;
|
||||
this.passAction(state, index);
|
||||
},
|
||||
|
||||
// `total` is what the seat's street commitment becomes — "raise to", the way a
|
||||
// dealer says it out loud, not "raise by".
|
||||
raiseTo(state, index, total) {
|
||||
const player = state.seats[index];
|
||||
const target = this.round(total);
|
||||
const delta = this.round(target - this.committed(player));
|
||||
const moved = this.putIn(state, index, delta);
|
||||
const newBet = this.committed(player);
|
||||
|
||||
if (newBet > (state.currentBet || 0)) {
|
||||
state.lastRaiseSize = this.round(newBet - (state.currentBet || 0));
|
||||
state.currentBet = newBet;
|
||||
// A raise reopens the action: everyone else owes a response again.
|
||||
state.seats.forEach((p, i) => {
|
||||
if (i !== index && p.inHand && !p.folded) p.hasActed = false;
|
||||
});
|
||||
}
|
||||
|
||||
player.hasActed = true;
|
||||
this.passAction(state, index);
|
||||
return moved;
|
||||
},
|
||||
|
||||
allIn(state, index) {
|
||||
const player = state.seats[index];
|
||||
return this.raiseTo(state, index, this.round(this.committed(player) + this.stackOf(state, player)));
|
||||
},
|
||||
|
||||
passAction(state, index) {
|
||||
state.actingSeatIndex = this.isStreetComplete(state) ? null : this.nextLiveIndex(state, index);
|
||||
},
|
||||
|
||||
// ---- Street and hand boundaries ----
|
||||
|
||||
// Pulls whatever is in front of the players into their hand totals. Anything that
|
||||
// reads a seat's contribution — the pot wizard above all — has to run after this,
|
||||
// or it will price the pot without the chips sitting on the current street.
|
||||
sweepToHandTotals(state) {
|
||||
state.seats.forEach(player => {
|
||||
const moved = this.committed(player);
|
||||
if (moved > 0) {
|
||||
player.amountOverride = this.round((Number(player.amountOverride) || 0) + moved);
|
||||
}
|
||||
player.committed = 0;
|
||||
player.hasActed = false;
|
||||
});
|
||||
},
|
||||
|
||||
// Sweeps the street's chips into each seat's hand total and opens the next one.
|
||||
closeStreet(state) {
|
||||
this.sweepToHandTotals(state);
|
||||
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
isHandOver(state) {
|
||||
return this.liveSeats(state).length <= 1;
|
||||
},
|
||||
|
||||
// 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) {
|
||||
state.street = "preflop";
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
|
||||
state.seats.forEach(player => {
|
||||
player.committed = 0;
|
||||
player.hasActed = false;
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
if (sb > 0 && state.seats[sbIndex].inHand) this.putIn(state, sbIndex, sb);
|
||||
if (bb > 0 && state.seats[bbIndex].inHand) 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);
|
||||
},
|
||||
|
||||
// Winners get their share put back in front of them.
|
||||
creditWinnings(state, payouts) {
|
||||
payouts.forEach(payout => {
|
||||
const player = state.seats.find(p => p.id === payout.id);
|
||||
if (!player) return;
|
||||
player.stack = this.round(this.stackOf(state, player) + payout.amount);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = Betting;
|
||||
}
|
||||
@@ -8,6 +8,34 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
Render.mount(state, hooks);
|
||||
|
||||
const openPotWizard = () => {
|
||||
// Chips still in front of players belong in the pot being priced, so bank them
|
||||
// before the wizard reads anyone's contribution.
|
||||
Betting.sweepToHandTotals(state);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
|
||||
PotWizard.open(state, payouts => {
|
||||
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
||||
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
|
||||
if (payouts.length > 0) {
|
||||
HoldemState.recordHand(state, payouts);
|
||||
// Winnings go straight back in front of the player, so the next
|
||||
// hand's stacks are already right.
|
||||
Betting.creditWinnings(state, payouts);
|
||||
}
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
ActionBar.mount(state, {
|
||||
save: hooks.save,
|
||||
rerender: hooks.rerender,
|
||||
resolvePot: openPotWizard,
|
||||
});
|
||||
|
||||
// Moving to the next hand used to ask "are you sure?" every single time —
|
||||
// the most-pressed button in a session, gated behind a system dialog. It now
|
||||
// just happens, and the toast offers the way back for the few seconds anyone
|
||||
@@ -79,9 +107,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
|
||||
: `button on seat ${nextDealer + 1}`;
|
||||
|
||||
runUndoable(`Next hand — ${where}. Cards and chip counts cleared.`, () =>
|
||||
HoldemState.startNextHand(state)
|
||||
);
|
||||
runUndoable(`Next hand — ${where}. Blinds posted.`, () => {
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("resetTableBtn").addEventListener("click", async () => {
|
||||
@@ -99,13 +128,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
Toast.show("Table reset.", { duration: 2500 });
|
||||
});
|
||||
|
||||
document.getElementById("resolvePotBtn").addEventListener("click", () => {
|
||||
PotWizard.open(state, payouts => {
|
||||
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
||||
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
|
||||
if (payouts.length > 0) HoldemState.recordHand(state, payouts);
|
||||
HoldemState.startNextHand(state);
|
||||
});
|
||||
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
|
||||
|
||||
[
|
||||
["smallBlindInput", "smallBlind"],
|
||||
["bigBlindInput", "bigBlind"],
|
||||
["defaultStackInput", "defaultStack"],
|
||||
].forEach(([id, key]) => {
|
||||
document.getElementById(id).addEventListener("input", e => {
|
||||
state[key] = e.target.value === "" ? null : Number(e.target.value);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ const Render = {
|
||||
this.renderTableHint();
|
||||
this.renderStandings();
|
||||
this.renderHandHistory();
|
||||
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
|
||||
},
|
||||
|
||||
renderSetupFields() {
|
||||
@@ -39,6 +40,9 @@ const Render = {
|
||||
document.getElementById("directionSelect").value = this.state.direction;
|
||||
document.getElementById("basePotInput").value = this.state.basePotAmount ?? "";
|
||||
document.getElementById("dedicatedDealerToggle").checked = !!this.state.dedicatedDealer;
|
||||
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
|
||||
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
|
||||
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
|
||||
},
|
||||
|
||||
renderTableHint() {
|
||||
@@ -54,8 +58,12 @@ const Render = {
|
||||
},
|
||||
|
||||
updatePotBadge() {
|
||||
const total = HoldemState.totalPot(this.state);
|
||||
document.getElementById("potBadgeAmount").textContent = Money.format(total);
|
||||
// Chips still sitting in front of players on the current street are part of
|
||||
// the pot as far as anyone at the table is concerned, so count them — waiting
|
||||
// until the street closes would show a stale number for most of every hand.
|
||||
const swept = HoldemState.totalPot(this.state);
|
||||
const onStreet = this.state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
|
||||
document.getElementById("potBadgeAmount").textContent = Money.format(swept + onStreet);
|
||||
},
|
||||
|
||||
// ---- Seats around the table (compact pucks) ----
|
||||
@@ -149,8 +157,19 @@ const Render = {
|
||||
span.textContent = card;
|
||||
cardsEl.appendChild(span);
|
||||
});
|
||||
const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
||||
// What's in front of them this street reads as the live number during betting;
|
||||
// between hands it falls back to their total for the hand.
|
||||
const streetBet = Betting.committed(player);
|
||||
const total = streetBet > 0
|
||||
? streetBet
|
||||
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
||||
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
|
||||
|
||||
const stackEl = node.querySelector(".seatPuckStack");
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
|
||||
|
||||
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
||||
node.classList.toggle("seatEmpty", empty);
|
||||
node.classList.toggle("seatInactive", !player.inHand && !empty);
|
||||
node.classList.toggle("seatFolded", player.folded);
|
||||
@@ -283,6 +302,13 @@ const Render = {
|
||||
});
|
||||
});
|
||||
|
||||
const stackInput = form.querySelector(".stackInput");
|
||||
stackInput.value = player.stack ?? "";
|
||||
stackInput.addEventListener("input", () => {
|
||||
player.stack = stackInput.value === "" ? null : Number(stackInput.value);
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
|
||||
const amountInput = form.querySelector(".amountOverrideInput");
|
||||
amountInput.value = player.amountOverride ?? "";
|
||||
amountInput.addEventListener("input", () => {
|
||||
|
||||
@@ -26,6 +26,11 @@ const HoldemState = {
|
||||
chipCounts: {},
|
||||
amountOverride: null,
|
||||
customFields: [],
|
||||
// Live-betting fields: chips in front of the player, chips they've put in
|
||||
// on the current street, and whether they've answered the current bet.
|
||||
stack: null,
|
||||
committed: 0,
|
||||
hasActed: false,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -44,6 +49,13 @@ const HoldemState = {
|
||||
notes: "",
|
||||
handNumber: 1,
|
||||
handHistory: [],
|
||||
smallBlind: 1,
|
||||
bigBlind: 2,
|
||||
defaultStack: null,
|
||||
street: "preflop",
|
||||
currentBet: 0,
|
||||
lastRaiseSize: 0,
|
||||
actingSeatIndex: null,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -55,6 +67,20 @@ const HoldemState = {
|
||||
if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault();
|
||||
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
||||
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
||||
|
||||
// Tables saved before live betting existed are missing its fields; fill
|
||||
// them in rather than making the dealer reset a table mid-session.
|
||||
const defaults = this.createDefault();
|
||||
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize", "actingSeatIndex"]
|
||||
.forEach(key => {
|
||||
if (parsed[key] === undefined) parsed[key] = defaults[key];
|
||||
});
|
||||
parsed.seats.forEach(player => {
|
||||
if (player.stack === undefined) player.stack = null;
|
||||
if (player.committed === undefined) player.committed = 0;
|
||||
if (player.hasActed === undefined) player.hasActed = false;
|
||||
});
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
return this.createDefault();
|
||||
@@ -147,6 +173,8 @@ const HoldemState = {
|
||||
p.cards = ["", ""];
|
||||
p.chipCounts = {};
|
||||
p.amountOverride = null;
|
||||
p.committed = 0;
|
||||
p.hasActed = false;
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user