Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e0a687a2f | ||
|
|
dabf1100c9 | ||
|
|
0759d89d13 | ||
|
|
7bedee2934 | ||
|
|
7143e4e701 | ||
|
|
e8797b8dc2 | ||
|
|
48bd3682f2 | ||
|
|
1e1bdcccce | ||
|
|
aee172a5cd | ||
|
|
d206758729 | ||
|
|
4afc3649cc | ||
|
|
3866bdb335 | ||
|
|
71b33f8623 | ||
|
|
d01c67fc45 | ||
|
|
a39675f975 | ||
|
|
870dee4a29 |
@@ -19,6 +19,15 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/casino/texasholdem/quicksplit" class="game-card">
|
||||
<div class="game-icon">➗</div>
|
||||
<div class="game-title">Quick Split</div>
|
||||
<div class="game-description">
|
||||
Chop a pot in seconds on your phone — no table setup, just the amount,
|
||||
how many players are splitting it, and the smallest chip in play.
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="game-card disabled">
|
||||
<div class="game-icon">🎲</div>
|
||||
<div class="game-title">Craps</div>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
|
||||
</div>
|
||||
<div id="holdemHeaderActions">
|
||||
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
|
||||
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
|
||||
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
|
||||
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
|
||||
@@ -37,7 +38,11 @@
|
||||
|
||||
<aside id="holdemSidebar">
|
||||
<section id="seatEditorPanel" class="sidebarPanel seatEditorPanel">
|
||||
<h2 id="seatEditorTitle">Select a seat</h2>
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="seatEditorHeader">
|
||||
<h2 id="seatEditorTitle">Select a seat</h2>
|
||||
<button id="closeSeatEditorBtn" class="seatSheetClose" aria-label="Done editing this seat">Done</button>
|
||||
</div>
|
||||
<div id="seatEditorBody">
|
||||
<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>
|
||||
</div>
|
||||
@@ -62,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" />
|
||||
@@ -126,6 +146,7 @@
|
||||
<div class="seatPuckName"></div>
|
||||
<div class="seatPuckCards"></div>
|
||||
<div class="seatPuckTotal"></div>
|
||||
<div class="seatPuckStack"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -146,9 +167,13 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="seatCards">
|
||||
<input type="text" class="cardInput card1Input" maxlength="2" placeholder="__" />
|
||||
<input type="text" class="cardInput card2Input" maxlength="2" placeholder="__" />
|
||||
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
|
||||
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
|
||||
</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">
|
||||
@@ -205,6 +230,46 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div id="seatMenuOverlay" hidden>
|
||||
<div id="seatMenuSheet" role="dialog" aria-labelledby="seatMenuTitle">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="seatMenuHeader">
|
||||
<h2 id="seatMenuTitle"></h2>
|
||||
<span id="seatMenuSubtitle"></span>
|
||||
</div>
|
||||
<div id="seatMenuActions"></div>
|
||||
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cardPickerOverlay" hidden>
|
||||
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="cardPickerHeader">
|
||||
<h2 id="cardPickerTitle"></h2>
|
||||
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
|
||||
<button id="cardPickerCloseBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||
</div>
|
||||
<div id="cardPickerSlots"></div>
|
||||
<div id="cardPickerGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="actionBar" aria-live="polite" hidden></div>
|
||||
|
||||
<div id="toastHost" aria-live="polite"></div>
|
||||
|
||||
<div id="confirmOverlay" hidden>
|
||||
<div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1">
|
||||
<h2 id="confirmTitle"></h2>
|
||||
<p id="confirmMessage"></p>
|
||||
<div class="confirmActions">
|
||||
<button id="confirmCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="potWizardOverlay" hidden>
|
||||
<div id="potWizardModal">
|
||||
<div class="potWizardHeader">
|
||||
@@ -220,9 +285,15 @@
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
|
||||
<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/cardPicker.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/seatMenu.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>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.Casino.TexasHoldem.QuickSplitModel
|
||||
@{
|
||||
ViewData["Title"] = "Quick Split";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div id="splitApp">
|
||||
<header id="splitHeader">
|
||||
<h1>Quick Split</h1>
|
||||
<p class="splitSubtitle">Chop a pot without setting up a table.</p>
|
||||
</header>
|
||||
|
||||
<div id="splitResult" aria-live="polite">
|
||||
<div id="splitResultLabel">Each player gets</div>
|
||||
<div id="splitResultAmount">$0.00</div>
|
||||
<div id="splitOddNote" hidden></div>
|
||||
<div id="splitDustNote" hidden></div>
|
||||
</div>
|
||||
|
||||
<section class="splitField">
|
||||
<label for="potInput" class="splitLabel">Pot</label>
|
||||
<div class="potInputWrap">
|
||||
<span class="potCurrency">$</span>
|
||||
<input type="text" id="potInput" inputmode="decimal" autocomplete="off"
|
||||
placeholder="0" aria-label="Pot amount" />
|
||||
<button type="button" id="clearPotBtn" class="clearPotBtn" aria-label="Clear pot amount" hidden>✕</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="splitField">
|
||||
<span class="splitLabel" id="waysLabel">Split between</span>
|
||||
<div id="waysChoices" class="chipRow" role="group" aria-labelledby="waysLabel"></div>
|
||||
</section>
|
||||
|
||||
<section class="splitField">
|
||||
<span class="splitLabel" id="chipLabel">Smallest chip in play</span>
|
||||
<div id="chipChoices" class="chipRow" role="group" aria-labelledby="chipLabel"></div>
|
||||
</section>
|
||||
|
||||
<div id="splitActions">
|
||||
<button type="button" id="resetSplitBtn" class="splitBtn splitBtnGhost">Clear</button>
|
||||
<a href="/casino/texasholdem" class="splitBtn splitBtnGhost">Full dealer table →</a>
|
||||
</div>
|
||||
|
||||
<p class="splitFootnote">
|
||||
Uneven pots — someone all-in for less — need the side-pot walkthrough on the
|
||||
<a href="/casino/texasholdem">full table</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/casino/quicksplit.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/splitCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/quickSplit.js?v=@ViewData["cssVersion"]"></script>
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages.Casino.TexasHoldem
|
||||
{
|
||||
public class QuickSplitModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,7 +238,12 @@ body {
|
||||
cursor: grab;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
|
||||
/* A long press on a phone otherwise selects the player's name or raises the
|
||||
system callout menu instead of ours. */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.seat.dragging {
|
||||
@@ -250,13 +255,50 @@ body {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Four states a dealer needs to tell apart from across the table, without
|
||||
reading anything: live in this hand, folded, sitting out, and nobody there.
|
||||
Live gets a green edge, folded gets a red one and a struck-through name,
|
||||
sitting-out just fades, and an empty chair is the only dashed outline. */
|
||||
|
||||
.seat.seatLive {
|
||||
border-color: rgba(78, 201, 122, 0.45);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(78, 201, 122, 0.18),
|
||||
0 3px 10px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Sitting out, not gone: still a person the dealer may need to deal back in, so
|
||||
the name has to stay readable rather than fading almost to the felt. */
|
||||
.seat.seatInactive {
|
||||
opacity: 0.4;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.seat.seatFolded {
|
||||
border-color: rgba(224, 123, 123, 0.4);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.seat.seatFolded .seatPuckName {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: rgba(224, 123, 123, 0.75);
|
||||
}
|
||||
|
||||
.seat.seatEmpty {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-style: dashed;
|
||||
opacity: 0.55;
|
||||
border-color: rgba(255, 255, 255, 0.13);
|
||||
box-shadow: none;
|
||||
/* Deliberately louder than seatInactive: an open seat is information a
|
||||
dealer wants, not something to squint at. */
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.seatEmptyLabel {
|
||||
color: #6d6d6d;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.seat.seatSelected {
|
||||
@@ -376,6 +418,27 @@ body {
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.seatEditorHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.seatEditorHeader h2 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Grip and Done button are the sheet's furniture — no reason for either on a
|
||||
desktop sidebar, where the panel is just a panel. */
|
||||
.seatSheetGrip,
|
||||
.seatSheetClose {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.seatEditorPanel h2 {
|
||||
margin: 0 0 0.6rem;
|
||||
padding-bottom: 0.5rem;
|
||||
@@ -423,24 +486,268 @@ body {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.cardInput {
|
||||
/* The two hole-card slots: they look like cards face-up on the felt, and tapping
|
||||
one opens the picker rather than a keyboard. */
|
||||
.cardSlot {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
background: #fdfdfd;
|
||||
color: #111;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono, monospace);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.cardInput.cardRed {
|
||||
.cardSlot:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.cardSlot.cardRed {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.cardSlot.cardSlotEmpty {
|
||||
background: #191919;
|
||||
color: #5c5c5c;
|
||||
border-style: dashed;
|
||||
border-color: #3f3f3f;
|
||||
box-shadow: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ---------- Seat long-press menu ---------- */
|
||||
|
||||
/* Held down, before the menu opens — the only feedback that a press is being
|
||||
counted rather than ignored. */
|
||||
.seat.seatPressing {
|
||||
transform: translate(-50%, -50%) scale(0.94);
|
||||
}
|
||||
|
||||
#seatMenuOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 96;
|
||||
}
|
||||
|
||||
#seatMenuOverlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#seatMenuSheet {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: linear-gradient(180deg, #242424, #1a1a1a);
|
||||
border: 1px solid #3a3a3a;
|
||||
border-bottom: none;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 0.35rem 0.9rem calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.seatMenuHeader {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.seatMenuHeader h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
#seatMenuSubtitle {
|
||||
color: #9a9a9a;
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
#seatMenuActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.seatMenuBtn {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
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;
|
||||
}
|
||||
|
||||
.seatMenuBtn:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
}
|
||||
|
||||
.seatMenuBtn.menuDanger {
|
||||
background: linear-gradient(180deg, #3d1f1f, #2c1515);
|
||||
border-color: #5b2626;
|
||||
color: #ffd6d6;
|
||||
}
|
||||
|
||||
.seatMenuBtn.menuAccent {
|
||||
background: linear-gradient(180deg, #3a2f18, #241d0f);
|
||||
border-color: #a9782f;
|
||||
color: #f0d9a8;
|
||||
}
|
||||
|
||||
#seatMenuCancelBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
/* ---------- Card picker ---------- */
|
||||
|
||||
#cardPickerOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 95;
|
||||
}
|
||||
|
||||
#cardPickerOverlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#cardPickerSheet {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
max-height: 88vh;
|
||||
overflow-y: auto;
|
||||
background: linear-gradient(180deg, #242424, #1a1a1a);
|
||||
border: 1px solid #3a3a3a;
|
||||
border-bottom: none;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 0.35rem 0.9rem calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.cardPickerHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.cardPickerHeader h2 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #f5f5f5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cardPickerHeader .holdemBtn {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
#cardPickerSlots {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.pickerSlot {
|
||||
flex: 1;
|
||||
min-height: 46px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #3a3a3a;
|
||||
background: #191919;
|
||||
color: #8a8a8a;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* The slot the next tap will fill. */
|
||||
.pickerSlot.active {
|
||||
border-color: #d8b46a;
|
||||
background: #241d0f;
|
||||
color: #f0d9a8;
|
||||
}
|
||||
|
||||
.pickerSlot.cardRed {
|
||||
color: #e07b7b;
|
||||
}
|
||||
|
||||
#cardPickerGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.pickerCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
min-height: 44px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid #d5d5d5;
|
||||
background: #fdfdfd;
|
||||
color: #111;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.pickerCard:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pickerCard.cardRed {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.pickerCard.selected {
|
||||
outline: 3px solid #d8b46a;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* Already dealt somewhere else at the table — visibly out of play rather than
|
||||
silently accepting a duplicate. */
|
||||
.pickerCard:disabled {
|
||||
background: #1d1d1d;
|
||||
border-color: #303030;
|
||||
color: #4a4a4a;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pickerSuit {
|
||||
font-size: 1.05em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.seatContribution {
|
||||
border-top: 1px dashed #3a3a3a;
|
||||
padding-top: 0.4rem;
|
||||
@@ -536,6 +843,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 {
|
||||
@@ -732,6 +1043,364 @@ 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: 0;
|
||||
right: 0;
|
||||
bottom: 1.25rem;
|
||||
margin: 0 auto;
|
||||
z-index: 80;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: linear-gradient(180deg, #2b2b2b, #202020);
|
||||
border: 1px solid #414141;
|
||||
border-left: 3px solid #d8b46a;
|
||||
border-radius: 10px;
|
||||
padding: 0.7rem 0.8rem 0.7rem 0.9rem;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.toast.toastVisible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.toastText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: #e9e9e9;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.toastAction {
|
||||
flex: none;
|
||||
min-height: 38px;
|
||||
padding: 0.35rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d8b46a;
|
||||
background: rgba(216, 180, 106, 0.12);
|
||||
color: #f0d9a8;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toastAction:hover {
|
||||
background: rgba(216, 180, 106, 0.22);
|
||||
}
|
||||
|
||||
.toastClose {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toastClose:hover {
|
||||
background: #333;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
/* ---------- Confirmation dialog ---------- */
|
||||
|
||||
#confirmOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 90;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
#confirmOverlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#confirmModal {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 1.3rem 1.4rem 1.2rem;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#confirmTitle {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
#confirmMessage {
|
||||
margin: 0 0 1.2rem;
|
||||
color: #a5a5a5;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.confirmActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.confirmActions .holdemBtn {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
#potWizardOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -843,3 +1512,213 @@ body {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Phones ----------
|
||||
Two things break on a narrow screen: seat pucks ring the table on a radius
|
||||
measured from the wrap, so the left- and right-most ones hang off the edge of
|
||||
the viewport; and the seat editor lives far enough down the page that tapping a
|
||||
seat looks like it did nothing. Inset the ring by half a puck, and float the
|
||||
editor up as a bottom sheet over the table. */
|
||||
@media (max-width: 700px) {
|
||||
#holdemApp {
|
||||
padding: 1rem 0.75rem 2rem;
|
||||
}
|
||||
|
||||
#holdemHeader {
|
||||
margin-bottom: 1rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
#holdemHeader h1 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.holdemSubtitle {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
#holdemHeaderActions {
|
||||
width: 100%;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
#holdemHeaderActions .holdemBtn {
|
||||
flex: 1 1 auto;
|
||||
justify-content: center;
|
||||
min-height: 46px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* Keeps every puck inside the viewport: the ring is drawn within this box,
|
||||
so half a puck of padding is exactly enough to stop the overhang. */
|
||||
#seatsLayer {
|
||||
inset: 26px 44px;
|
||||
}
|
||||
|
||||
/* Narrower than the desktop puck: at a full ten-handed table the ring is
|
||||
flattest at the top and bottom, and wide pucks are what made neighbours
|
||||
overlap there. The puck is only a summary — the real form is the sheet. */
|
||||
.seat {
|
||||
width: 64px;
|
||||
min-height: 42px;
|
||||
padding: 0.25rem 0.3rem;
|
||||
}
|
||||
|
||||
#tableWatermark {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
#potBadge {
|
||||
padding: 0.5rem 1.1rem;
|
||||
}
|
||||
|
||||
#potBadgeAmount {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.tableHint {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ---- Seat editor as a bottom sheet ---- */
|
||||
|
||||
#seatEditorPanel {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 60;
|
||||
margin: 0;
|
||||
max-height: 74vh;
|
||||
overflow-y: auto;
|
||||
border-radius: 16px 16px 0 0;
|
||||
border-bottom: none;
|
||||
padding: 0.35rem 1rem 1.25rem;
|
||||
background: linear-gradient(180deg, #242424, #1a1a1a);
|
||||
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
|
||||
transform: translateY(105%);
|
||||
transition: transform 0.25s ease;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
body.seatSheetOpen #seatEditorPanel {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.seatSheetGrip {
|
||||
display: block;
|
||||
width: 38px;
|
||||
height: 4px;
|
||||
margin: 0.35rem auto 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
||||
.seatSheetClose {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
min-height: 44px;
|
||||
padding: 0.35rem 1.1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #3a3a3a;
|
||||
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||
color: #ddd;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* The sheet covers the bottom of the page while it's open — give the sidebar
|
||||
room so nothing is stranded underneath it. */
|
||||
body.seatSheetOpen #holdemSidebar {
|
||||
padding-bottom: 40vh;
|
||||
}
|
||||
|
||||
/* The seat form was sized for a 320px desktop sidebar. In the sheet it has a
|
||||
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 {
|
||||
font-size: 16px;
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
|
||||
.seatEditorForm .chipCountInput {
|
||||
width: 56px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.seatEditorForm .chipCounts {
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.seatEditorForm .chipSwatch {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.seatEditorForm .amountOverrideInput {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.seatEditorForm .cardSlot {
|
||||
font-size: 1.5rem;
|
||||
min-height: 62px;
|
||||
}
|
||||
|
||||
/* Thirteen rows of cards need to stay reachable without the sheet running off
|
||||
the bottom of a phone. */
|
||||
.pickerCard {
|
||||
min-height: 46px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.seatEditorForm .seatCardToggles {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.seatEditorForm .inHandToggle,
|
||||
.seatEditorForm .foldToggle {
|
||||
min-height: 44px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.seatEditorForm .inHandToggle input,
|
||||
.seatEditorForm .foldToggle input {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.seatEditorForm .amountOverrideRow,
|
||||
.seatEditorForm .seatTotal {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.seatEditorForm .addFieldBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.toastAction {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.confirmActions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.confirmActions .holdemBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/* Quick Split is phone-first: sized for one thumb at the table, then allowed to
|
||||
breathe on a laptop. Palette matches the dealer table so the two read as one tool. */
|
||||
|
||||
body {
|
||||
background: radial-gradient(ellipse at 50% -10%, #1c1c1c 0%, #101010 55%, #0a0a0a 100%);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
color: #e6e6e6;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
#splitApp {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1rem 3rem;
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
|
||||
#splitHeader h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.04em;
|
||||
color: #f5f5f5;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
#splitHeader h1::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 36px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, #d8b46a, #a9782f);
|
||||
}
|
||||
|
||||
.splitSubtitle {
|
||||
margin: 0.35rem 0 1.25rem;
|
||||
color: #8f8f8f;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---------- The answer ---------- */
|
||||
|
||||
#splitResult {
|
||||
background: linear-gradient(160deg, #1f2a21, #161c17);
|
||||
border: 1px solid #2c3a2f;
|
||||
border-radius: 14px;
|
||||
padding: 1.4rem 1.1rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
#splitResult.hasAmount {
|
||||
border-color: #3c6b46;
|
||||
box-shadow: 0 0 0 1px rgba(78, 201, 122, 0.08), 0 6px 20px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
#splitResultLabel {
|
||||
color: #8f9d92;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#splitResultAmount {
|
||||
margin-top: 0.35rem;
|
||||
font-size: clamp(2.6rem, 14vw, 3.4rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.05;
|
||||
color: #f2f2f2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
#splitResult.hasAmount #splitResultAmount {
|
||||
color: #7ee2a0;
|
||||
}
|
||||
|
||||
#splitOddNote {
|
||||
margin-top: 0.7rem;
|
||||
color: #d8b46a;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
#splitDustNote {
|
||||
margin-top: 0.5rem;
|
||||
color: #e0a1a1;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ---------- Inputs ---------- */
|
||||
|
||||
.splitField {
|
||||
display: block;
|
||||
margin-bottom: 1.35rem;
|
||||
}
|
||||
|
||||
.splitLabel {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #9a9a9a;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.potInputWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: #141414;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 12px;
|
||||
padding: 0.2rem 0.75rem;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.potInputWrap:focus-within {
|
||||
border-color: #d8b46a;
|
||||
box-shadow: 0 0 0 3px rgba(216, 180, 106, 0.12);
|
||||
}
|
||||
|
||||
.potCurrency {
|
||||
color: #777;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
#potInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #f2f2f2;
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.55rem 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
#potInput::placeholder {
|
||||
color: #4a4a4a;
|
||||
}
|
||||
|
||||
.clearPotBtn {
|
||||
flex: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #262626;
|
||||
color: #999;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clearPotBtn:hover {
|
||||
background: #333;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
/* ---------- Tap targets ---------- */
|
||||
|
||||
.chipRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.choiceChip {
|
||||
flex: 1 1 auto;
|
||||
min-width: 58px;
|
||||
min-height: 52px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 11px;
|
||||
color: #ddd;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.choiceChip:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
}
|
||||
|
||||
.choiceChip.selected {
|
||||
background: linear-gradient(180deg, #3a2f18, #241d0f);
|
||||
border-color: #d8b46a;
|
||||
color: #f0d9a8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.waysStepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 150px;
|
||||
background: #191919;
|
||||
border: 1px solid #333;
|
||||
border-radius: 11px;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
.stepBtn {
|
||||
width: 40px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #262626;
|
||||
color: #ddd;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stepBtn:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.waysReadout {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---------- Footer actions ---------- */
|
||||
|
||||
#splitActions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.splitBtn {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #3a3a3a;
|
||||
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||
color: #ddd;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.splitBtn:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
}
|
||||
|
||||
.splitBtnGhost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.splitFootnote {
|
||||
margin-top: 1.5rem;
|
||||
color: #6f6f6f;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.splitFootnote a {
|
||||
color: #a9782f;
|
||||
}
|
||||
|
||||
/* Roomier once there's a real screen to work with. */
|
||||
@media (min-width: 700px) {
|
||||
#splitApp {
|
||||
padding-top: 2.5rem;
|
||||
}
|
||||
|
||||
#splitHeader h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Tap a card instead of typing it.
|
||||
//
|
||||
// Typing "AH" into a two-character box is fine at a desk and miserable at a table:
|
||||
// it needs a keyboard, it accepts nonsense like "ZZ", and it happily lets the same
|
||||
// card land in two players' hands. The picker is one tap per card, greys out cards
|
||||
// already dealt somewhere else at the table, and stores exactly the same two-character
|
||||
// string the rest of the tool has always read.
|
||||
const CardPicker = {
|
||||
RANKS: ["A", "K", "Q", "J", "T", "9", "8", "7", "6", "5", "4", "3", "2"],
|
||||
SUITS: [
|
||||
{ code: "S", symbol: "♠", red: false },
|
||||
{ code: "H", symbol: "♥", red: true },
|
||||
{ code: "D", symbol: "♦", red: true },
|
||||
{ code: "C", symbol: "♣", red: false },
|
||||
],
|
||||
|
||||
state: null,
|
||||
hooks: null,
|
||||
seatIndex: null,
|
||||
cardIndex: 0,
|
||||
|
||||
open(state, hooks, seatIndex, cardIndex) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.seatIndex = seatIndex;
|
||||
this.cardIndex = cardIndex;
|
||||
this.render();
|
||||
document.getElementById("cardPickerOverlay").hidden = false;
|
||||
},
|
||||
|
||||
close() {
|
||||
document.getElementById("cardPickerOverlay").hidden = true;
|
||||
},
|
||||
|
||||
// Every card sitting in front of anyone else. The slot being edited is excluded,
|
||||
// so re-picking the card that's already there isn't treated as a duplicate.
|
||||
cardsInUse() {
|
||||
const used = new Map();
|
||||
this.state.seats.forEach((player, seat) => {
|
||||
(player.cards || []).forEach((card, slot) => {
|
||||
if (!card) return;
|
||||
if (seat === this.seatIndex && slot === this.cardIndex) return;
|
||||
used.set(card.toUpperCase(), HoldemState.displayName(player, seat));
|
||||
});
|
||||
});
|
||||
return used;
|
||||
},
|
||||
|
||||
assign(card) {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
player.cards[this.cardIndex] = card;
|
||||
this.hooks.onPick(this.seatIndex, this.cardIndex, card);
|
||||
|
||||
// Straight on to the second card — a dealer picking hole cards is always
|
||||
// picking two, and making them re-open the picker doubles the taps.
|
||||
if (this.cardIndex === 0 && !player.cards[1]) {
|
||||
this.cardIndex = 1;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
},
|
||||
|
||||
clearSlot() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
player.cards[this.cardIndex] = "";
|
||||
this.hooks.onPick(this.seatIndex, this.cardIndex, "");
|
||||
this.render();
|
||||
},
|
||||
|
||||
render() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
const used = this.cardsInUse();
|
||||
|
||||
document.getElementById("cardPickerTitle").textContent =
|
||||
`${HoldemState.displayName(player, this.seatIndex)} — card ${this.cardIndex + 1}`;
|
||||
|
||||
// Which slot the next tap fills, switchable without closing the sheet.
|
||||
const slots = document.getElementById("cardPickerSlots");
|
||||
slots.innerHTML = "";
|
||||
[0, 1].forEach(slot => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pickerSlot";
|
||||
btn.classList.toggle("active", slot === this.cardIndex);
|
||||
const value = player.cards[slot] || "";
|
||||
btn.textContent = value || "—";
|
||||
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
|
||||
btn.addEventListener("click", () => {
|
||||
this.cardIndex = slot;
|
||||
this.render();
|
||||
});
|
||||
slots.appendChild(btn);
|
||||
});
|
||||
|
||||
const grid = document.getElementById("cardPickerGrid");
|
||||
grid.innerHTML = "";
|
||||
this.RANKS.forEach(rank => {
|
||||
this.SUITS.forEach(suit => {
|
||||
const card = `${rank}${suit.code}`;
|
||||
const takenBy = used.get(card);
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pickerCard";
|
||||
btn.classList.toggle("cardRed", suit.red);
|
||||
btn.disabled = !!takenBy;
|
||||
if (takenBy) btn.title = `Already dealt to ${takenBy}`;
|
||||
if ((player.cards[this.cardIndex] || "").toUpperCase() === card) {
|
||||
btn.classList.add("selected");
|
||||
}
|
||||
|
||||
const rankEl = document.createElement("span");
|
||||
rankEl.className = "pickerRank";
|
||||
rankEl.textContent = rank;
|
||||
const suitEl = document.createElement("span");
|
||||
suitEl.className = "pickerSuit";
|
||||
suitEl.textContent = suit.symbol;
|
||||
btn.appendChild(rankEl);
|
||||
btn.appendChild(suitEl);
|
||||
|
||||
btn.addEventListener("click", () => this.assign(card));
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = CardPicker;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Toasts and confirmation dialogs, so the table never has to fall back on the
|
||||
// browser's own alert boxes. A system confirm() steals focus, looks nothing like
|
||||
// the rest of the page, and on a phone it's a full-screen interruption — all of
|
||||
// which is a bad trade for a dealer trying to keep a game moving.
|
||||
|
||||
// A short-lived message at the bottom of the screen, optionally with one action
|
||||
// (in practice: Undo). Only one is ever on screen; a second replaces the first.
|
||||
const Toast = {
|
||||
DEFAULT_DURATION_MS: 8000,
|
||||
timer: null,
|
||||
|
||||
show(message, options = {}) {
|
||||
const host = document.getElementById("toastHost");
|
||||
if (!host) return;
|
||||
|
||||
this.dismiss();
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "toast";
|
||||
toast.setAttribute("role", "status");
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "toastText";
|
||||
text.textContent = message;
|
||||
toast.appendChild(text);
|
||||
|
||||
if (options.actionLabel && options.onAction) {
|
||||
const action = document.createElement("button");
|
||||
action.type = "button";
|
||||
action.className = "toastAction";
|
||||
action.textContent = options.actionLabel;
|
||||
action.addEventListener("click", () => {
|
||||
this.dismiss();
|
||||
options.onAction();
|
||||
});
|
||||
toast.appendChild(action);
|
||||
}
|
||||
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "toastClose";
|
||||
close.setAttribute("aria-label", "Dismiss");
|
||||
close.textContent = "✕";
|
||||
close.addEventListener("click", () => this.dismiss());
|
||||
toast.appendChild(close);
|
||||
|
||||
host.appendChild(toast);
|
||||
// Next frame, so the entrance transition actually runs.
|
||||
requestAnimationFrame(() => toast.classList.add("toastVisible"));
|
||||
|
||||
this.timer = setTimeout(
|
||||
() => this.dismiss(),
|
||||
options.duration || this.DEFAULT_DURATION_MS
|
||||
);
|
||||
},
|
||||
|
||||
dismiss() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
const host = document.getElementById("toastHost");
|
||||
if (host) host.innerHTML = "";
|
||||
},
|
||||
};
|
||||
|
||||
// Styled stand-in for confirm(). Resolves true if the dealer goes through with it.
|
||||
const Dialog = {
|
||||
confirm({ title, message, confirmLabel = "Confirm", cancelLabel = "Cancel", danger = false }) {
|
||||
return new Promise(resolve => {
|
||||
const overlay = document.getElementById("confirmOverlay");
|
||||
const modal = overlay.querySelector("#confirmModal");
|
||||
overlay.querySelector("#confirmTitle").textContent = title;
|
||||
overlay.querySelector("#confirmMessage").textContent = message;
|
||||
|
||||
const confirmBtn = overlay.querySelector("#confirmAcceptBtn");
|
||||
const cancelBtn = overlay.querySelector("#confirmCancelBtn");
|
||||
confirmBtn.textContent = confirmLabel;
|
||||
cancelBtn.textContent = cancelLabel;
|
||||
confirmBtn.classList.toggle("holdemBtnDanger", danger);
|
||||
confirmBtn.classList.toggle("holdemBtnAccent", !danger);
|
||||
|
||||
const finish = result => {
|
||||
overlay.hidden = true;
|
||||
confirmBtn.removeEventListener("click", onAccept);
|
||||
cancelBtn.removeEventListener("click", onCancel);
|
||||
overlay.removeEventListener("click", onBackdrop);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
resolve(result);
|
||||
};
|
||||
const onAccept = () => finish(true);
|
||||
const onCancel = () => finish(false);
|
||||
const onBackdrop = e => {
|
||||
if (e.target === overlay) finish(false);
|
||||
};
|
||||
const onKey = e => {
|
||||
if (e.key === "Escape") finish(false);
|
||||
if (e.key === "Enter") finish(true);
|
||||
};
|
||||
|
||||
confirmBtn.addEventListener("click", onAccept);
|
||||
cancelBtn.addEventListener("click", onCancel);
|
||||
overlay.addEventListener("click", onBackdrop);
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
overlay.hidden = false;
|
||||
modal.focus();
|
||||
confirmBtn.focus();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { Toast, Dialog };
|
||||
}
|
||||
@@ -1,13 +1,117 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const state = HoldemState.load();
|
||||
|
||||
// A gesture on a seat is a shortcut for something the dealer could do the long
|
||||
// way, so each one is undoable the same way the header buttons are.
|
||||
const seatAction = (index, message, mutate) => {
|
||||
const snapshot = JSON.parse(JSON.stringify(state));
|
||||
mutate(state.seats[index], index);
|
||||
HoldemState.save(state);
|
||||
Render.renderAll();
|
||||
|
||||
Toast.show(message, {
|
||||
actionLabel: "Undo",
|
||||
onAction: () => {
|
||||
Object.keys(state).forEach(key => delete state[key]);
|
||||
Object.assign(state, snapshot);
|
||||
HoldemState.save(state);
|
||||
Render.renderAll();
|
||||
Toast.show("Undone.", { duration: 2500 });
|
||||
},
|
||||
duration: 6000,
|
||||
});
|
||||
};
|
||||
|
||||
const nameOf = index => HoldemState.displayName(state.seats[index], index);
|
||||
|
||||
const hooks = {
|
||||
save: s => HoldemState.save(s),
|
||||
rerender: () => Render.renderAll(),
|
||||
|
||||
onSeatDoubleTap: index => {
|
||||
const player = state.seats[index];
|
||||
if (!player.inHand) return;
|
||||
if (player.folded) {
|
||||
seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; });
|
||||
return;
|
||||
}
|
||||
seatAction(index, `${nameOf(index)} folds.`, () => {
|
||||
if (state.actingSeatIndex === index) Betting.fold(state, index);
|
||||
else player.folded = true;
|
||||
});
|
||||
},
|
||||
|
||||
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
|
||||
};
|
||||
|
||||
const seatMenuHooks = {
|
||||
fold: index => hooks.onSeatDoubleTap(index),
|
||||
unfold: index => seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; }),
|
||||
allIn: index => seatAction(index, `${nameOf(index)} is all-in.`, () => Betting.allIn(state, index)),
|
||||
toggleDealtIn: index => {
|
||||
const dealtIn = state.seats[index].inHand;
|
||||
seatAction(index, dealtIn ? `${nameOf(index)} sits out.` : `${nameOf(index)} is dealt in.`, p => {
|
||||
p.inHand = !dealtIn;
|
||||
if (!p.inHand) p.folded = false;
|
||||
});
|
||||
},
|
||||
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { state.dealerSeatIndex = index; }),
|
||||
editSeat: index => Render.toggleSeatEditor(index),
|
||||
};
|
||||
|
||||
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
|
||||
// would notice a misclick. The snapshot is deliberately in-memory only: an
|
||||
// undo is for the mistake you just made, not one from last night's session.
|
||||
const runUndoable = (message, mutate) => {
|
||||
const snapshot = JSON.parse(JSON.stringify(state));
|
||||
mutate();
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
|
||||
Toast.show(message, {
|
||||
actionLabel: "Undo",
|
||||
onAction: () => {
|
||||
Object.keys(state).forEach(key => delete state[key]);
|
||||
Object.assign(state, snapshot);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
Toast.show("Undone.", { duration: 2500 });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById("seatCountInput").addEventListener("change", e => {
|
||||
HoldemState.resizeSeats(state, Number(e.target.value));
|
||||
hooks.save(state);
|
||||
@@ -49,38 +153,79 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
|
||||
document.getElementById("nextHandBtn").addEventListener("click", () => {
|
||||
if (!confirm("Start the next hand? This moves the button, clears cards and chip counts for every seat.")) return;
|
||||
HoldemState.startNextHand(state);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
// startNextHand moves the button on by one; say where it landed so the
|
||||
// dealer can confirm the rotation without hunting for the badge.
|
||||
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
||||
const where = state.dedicatedDealer
|
||||
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
|
||||
: `button on seat ${nextDealer + 1}`;
|
||||
|
||||
runUndoable(`Next hand — ${where}. Blinds posted.`, () => {
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("resetTableBtn").addEventListener("click", () => {
|
||||
if (!confirm("Reset the whole table? This clears every seat, chip legend and note.")) return;
|
||||
document.getElementById("resetTableBtn").addEventListener("click", async () => {
|
||||
const ok = await Dialog.confirm({
|
||||
title: "Reset the whole table?",
|
||||
message: "Every seat, chip colour and note goes back to defaults. The hand history and session standings are cleared too.",
|
||||
confirmLabel: "Reset table",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
HoldemState.clear();
|
||||
Object.assign(state, HoldemState.createDefault());
|
||||
Render.mount(state, hooks);
|
||||
Toast.show("Table reset.", { duration: 2500 });
|
||||
});
|
||||
|
||||
document.getElementById("resolvePotBtn").addEventListener("click", () => {
|
||||
PotWizard.open(state, payouts => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
|
||||
|
||||
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
|
||||
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
|
||||
if (e.target.id === "seatMenuOverlay") SeatMenu.close();
|
||||
});
|
||||
|
||||
document.getElementById("cardPickerCloseBtn").addEventListener("click", () => CardPicker.close());
|
||||
document.getElementById("cardPickerClearBtn").addEventListener("click", () => CardPicker.clearSlot());
|
||||
document.getElementById("cardPickerOverlay").addEventListener("click", e => {
|
||||
if (e.target.id === "cardPickerOverlay") CardPicker.close();
|
||||
});
|
||||
|
||||
document.getElementById("closePotWizardBtn").addEventListener("click", () => PotWizard.close());
|
||||
document.getElementById("potWizardOverlay").addEventListener("click", e => {
|
||||
if (e.target.id === "potWizardOverlay") PotWizard.close();
|
||||
});
|
||||
|
||||
document.getElementById("clearHistoryBtn").addEventListener("click", () => {
|
||||
if (!confirm("Clear the hand history and reset the hand counter to 1? This can't be undone.")) return;
|
||||
document.getElementById("clearHistoryBtn").addEventListener("click", async () => {
|
||||
const ok = await Dialog.confirm({
|
||||
title: "Clear the hand history?",
|
||||
message: "This wipes every logged hand and the session standings, and puts the hand counter back to #1. Export first if anyone might want the numbers.",
|
||||
confirmLabel: "Clear history",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
HoldemState.clearHistory(state);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
Toast.show("Hand history cleared.", { duration: 2500 });
|
||||
});
|
||||
|
||||
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Shared money formatting for every casino surface. Kept apart from the table's
|
||||
// rendering so the quick-split calculator can pull in the formatter without dragging
|
||||
// the whole seat/chip UI along with it.
|
||||
const Money = {
|
||||
format(amount) {
|
||||
const value = Number(amount) || 0;
|
||||
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
},
|
||||
|
||||
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
||||
// glance, and the sign belongs out front where the eye lands first.
|
||||
formatSigned(amount) {
|
||||
const value = Number(amount) || 0;
|
||||
if (value > 0) return `+${this.format(value)}`;
|
||||
if (value < 0) return `-${this.format(Math.abs(value))}`;
|
||||
return this.format(0);
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = Money;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ const PotWizard = {
|
||||
|
||||
labelFor(player, seatIndex) {
|
||||
const name = HoldemState.displayName(player, seatIndex);
|
||||
const cards = (player.cards || []).filter(Boolean).join(" ");
|
||||
const cards = (player.cards || []).filter(Boolean).map(c => Render.formatCard(c)).join(" ");
|
||||
return cards ? `${name} (${cards})` : name;
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// The quick-split screen: pot in, payout out, nothing to set up and nothing saved.
|
||||
// A dealer opens this one-handed with players waiting, so every control is a tap and
|
||||
// the answer updates as they type rather than behind a "calculate" button.
|
||||
const QuickSplit = {
|
||||
WAY_CHOICES: [2, 3, 4, 5],
|
||||
CHIP_CHOICES: [
|
||||
{ value: 0.01, label: "1¢" },
|
||||
{ value: 0.25, label: "25¢" },
|
||||
{ value: 1, label: "$1" },
|
||||
{ value: 5, label: "$5" },
|
||||
{ value: 25, label: "$25" },
|
||||
],
|
||||
MAX_WAYS: 12,
|
||||
|
||||
ways: 2,
|
||||
chip: 1,
|
||||
|
||||
mount() {
|
||||
this.potInput = document.getElementById("potInput");
|
||||
this.clearPotBtn = document.getElementById("clearPotBtn");
|
||||
|
||||
this.renderWayChoices();
|
||||
this.renderChipChoices();
|
||||
|
||||
this.potInput.addEventListener("input", () => {
|
||||
this.sanitizePotInput();
|
||||
this.update();
|
||||
});
|
||||
|
||||
this.clearPotBtn.addEventListener("click", () => {
|
||||
this.potInput.value = "";
|
||||
this.potInput.focus();
|
||||
this.update();
|
||||
});
|
||||
|
||||
document.getElementById("resetSplitBtn").addEventListener("click", () => {
|
||||
this.potInput.value = "";
|
||||
this.ways = 2;
|
||||
this.chip = 1;
|
||||
this.renderWayChoices();
|
||||
this.renderChipChoices();
|
||||
this.potInput.focus();
|
||||
this.update();
|
||||
});
|
||||
|
||||
this.update();
|
||||
},
|
||||
|
||||
// Keeps the field to something parseable without fighting the typist: digits and a
|
||||
// single decimal point, at most two places.
|
||||
sanitizePotInput() {
|
||||
const cleaned = this.potInput.value
|
||||
.replace(/[^0-9.]/g, "")
|
||||
.replace(/(\..*)\./g, "$1")
|
||||
.replace(/(\.\d\d)\d+/, "$1");
|
||||
if (cleaned !== this.potInput.value) this.potInput.value = cleaned;
|
||||
},
|
||||
|
||||
makeChoiceButton(label, isSelected, onPick) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "choiceChip";
|
||||
btn.textContent = label;
|
||||
btn.setAttribute("aria-pressed", String(isSelected));
|
||||
btn.classList.toggle("selected", isSelected);
|
||||
btn.addEventListener("click", onPick);
|
||||
return btn;
|
||||
},
|
||||
|
||||
renderWayChoices() {
|
||||
const row = document.getElementById("waysChoices");
|
||||
row.innerHTML = "";
|
||||
|
||||
this.WAY_CHOICES.forEach(count => {
|
||||
row.appendChild(
|
||||
this.makeChoiceButton(String(count), this.ways === count, () => {
|
||||
this.ways = count;
|
||||
this.renderWayChoices();
|
||||
this.update();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Beyond a five-way chop it's rare enough to hide behind a stepper rather than
|
||||
// spend a row of buttons on it.
|
||||
const more = document.createElement("div");
|
||||
more.className = "waysStepper";
|
||||
|
||||
const minus = this.makeStepButton("−", () => this.stepWays(-1));
|
||||
const plus = this.makeStepButton("+", () => this.stepWays(1));
|
||||
const readout = document.createElement("span");
|
||||
readout.className = "waysReadout";
|
||||
readout.textContent = `${this.ways} ways`;
|
||||
|
||||
more.appendChild(minus);
|
||||
more.appendChild(readout);
|
||||
more.appendChild(plus);
|
||||
row.appendChild(more);
|
||||
},
|
||||
|
||||
makeStepButton(label, onPick) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "stepBtn";
|
||||
btn.textContent = label;
|
||||
btn.addEventListener("click", onPick);
|
||||
return btn;
|
||||
},
|
||||
|
||||
stepWays(delta) {
|
||||
this.ways = Math.max(2, Math.min(this.MAX_WAYS, this.ways + delta));
|
||||
this.renderWayChoices();
|
||||
this.update();
|
||||
},
|
||||
|
||||
renderChipChoices() {
|
||||
const row = document.getElementById("chipChoices");
|
||||
row.innerHTML = "";
|
||||
this.CHIP_CHOICES.forEach(choice => {
|
||||
row.appendChild(
|
||||
this.makeChoiceButton(choice.label, this.chip === choice.value, () => {
|
||||
this.chip = choice.value;
|
||||
this.renderChipChoices();
|
||||
this.update();
|
||||
})
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
update() {
|
||||
const potValue = Number(this.potInput.value);
|
||||
const pot = Number.isNaN(potValue) ? 0 : potValue;
|
||||
this.clearPotBtn.hidden = this.potInput.value === "";
|
||||
|
||||
const result = SplitCalculator.split(pot, this.ways, this.chip);
|
||||
|
||||
document.getElementById("splitResultLabel").textContent =
|
||||
`Each of ${result.players} players gets`;
|
||||
document.getElementById("splitResultAmount").textContent = Money.format(result.each);
|
||||
|
||||
const oddNote = document.getElementById("splitOddNote");
|
||||
if (result.valid && result.oddChips > 0) {
|
||||
oddNote.textContent = result.oddChips === 1
|
||||
? `plus one odd ${Money.format(result.chip)} chip to the first player left of the button`
|
||||
: `plus ${result.oddChips} odd ${Money.format(result.chip)} chips — one each to the ` +
|
||||
`first ${result.oddChips} players left of the button`;
|
||||
oddNote.hidden = false;
|
||||
} else {
|
||||
oddNote.hidden = true;
|
||||
}
|
||||
|
||||
const dustNote = document.getElementById("splitDustNote");
|
||||
if (result.valid && result.dust > 0) {
|
||||
dustNote.textContent =
|
||||
`${Money.format(result.dust)} doesn't divide into ${Money.format(result.chip)} chips — ` +
|
||||
`pick a smaller denomination.`;
|
||||
dustNote.hidden = false;
|
||||
} else {
|
||||
dustNote.hidden = true;
|
||||
}
|
||||
|
||||
document.getElementById("splitResult").classList.toggle("hasAmount", result.valid && result.each > 0);
|
||||
},
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => QuickSplit.mount());
|
||||
@@ -32,6 +32,7 @@ const Render = {
|
||||
this.renderTableHint();
|
||||
this.renderStandings();
|
||||
this.renderHandHistory();
|
||||
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
|
||||
},
|
||||
|
||||
renderSetupFields() {
|
||||
@@ -39,13 +40,16 @@ 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() {
|
||||
const hint = document.getElementById("tableHint");
|
||||
hint.textContent = this.state.dedicatedDealer
|
||||
? "Drag a seat onto another seat to swap players."
|
||||
: "Drag a seat onto another seat to swap players. Click the dealer chip on a seat to make it the button.";
|
||||
// Gestures are invisible until someone tells you about them, and this is the
|
||||
// only place on the table with room to say so.
|
||||
hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats.";
|
||||
},
|
||||
|
||||
renderNotes() {
|
||||
@@ -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) ----
|
||||
@@ -105,12 +113,7 @@ const Render = {
|
||||
this.updatePuckContent(node, player, index);
|
||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||
|
||||
node.addEventListener("click", () => {
|
||||
this.selectedSeatIndex = index;
|
||||
this.renderSeats();
|
||||
this.renderSeatEditor();
|
||||
});
|
||||
|
||||
this.wireSeatGestures(node, index);
|
||||
this.wireDragAndDrop(node, index);
|
||||
|
||||
layer.appendChild(node);
|
||||
@@ -121,21 +124,67 @@ const Render = {
|
||||
return /[HD]$/i.test(card || "");
|
||||
},
|
||||
|
||||
SUIT_SYMBOLS: { S: "♠", H: "♥", D: "♦", C: "♣" },
|
||||
|
||||
// "KD" -> "K♦". Stored cards stay two plain characters so every existing reader
|
||||
// keeps working; the suit only becomes a symbol on its way to a human.
|
||||
formatCard(card) {
|
||||
const value = (card || "").toUpperCase();
|
||||
if (!value) return "";
|
||||
return `${value[0]}${this.SUIT_SYMBOLS[value[1]] || value[1] || ""}`;
|
||||
},
|
||||
|
||||
// An empty slot has to look like something you can tap, so it gets a placeholder
|
||||
// rather than going blank.
|
||||
paintCardSlot(slot, card) {
|
||||
const value = (card || "").toUpperCase();
|
||||
slot.classList.toggle("cardRed", this.isRedSuit(value));
|
||||
slot.classList.toggle("cardSlotEmpty", !value);
|
||||
slot.textContent = value ? this.formatCard(value) : "+";
|
||||
},
|
||||
|
||||
// A chair nobody has claimed: no name typed, not dealt in, nothing in the pot.
|
||||
// Worth calling out, because at a short-handed table the open seats are what
|
||||
// the dealer is looking for.
|
||||
isEmptySeat(player) {
|
||||
const named = !!(player.name && player.name.trim());
|
||||
const contributing =
|
||||
HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount) > 0;
|
||||
return !named && !player.inHand && !contributing;
|
||||
},
|
||||
|
||||
updatePuckContent(node, player, index) {
|
||||
node.querySelector(".seatPuckName").textContent = player.name && player.name.trim() ? player.name.trim() : "";
|
||||
const empty = this.isEmptySeat(player);
|
||||
const nameEl = node.querySelector(".seatPuckName");
|
||||
nameEl.textContent = empty ? "Empty" : (player.name || "").trim();
|
||||
nameEl.classList.toggle("seatEmptyLabel", empty);
|
||||
|
||||
const cardsEl = node.querySelector(".seatPuckCards");
|
||||
cardsEl.innerHTML = "";
|
||||
(player.cards || []).filter(Boolean).forEach((card, i) => {
|
||||
if (i > 0) cardsEl.appendChild(document.createTextNode(" "));
|
||||
const span = document.createElement("span");
|
||||
if (this.isRedSuit(card)) span.className = "cardRed";
|
||||
span.textContent = card;
|
||||
span.textContent = this.formatCard(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) : "";
|
||||
node.classList.toggle("seatInactive", !player.inHand);
|
||||
|
||||
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);
|
||||
node.classList.toggle("seatLive", player.inHand && !player.folded);
|
||||
},
|
||||
|
||||
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
|
||||
@@ -145,6 +194,109 @@ const Render = {
|
||||
if (node) this.updatePuckContent(node, this.state.seats[index], index);
|
||||
},
|
||||
|
||||
// ---- Seat gestures ----
|
||||
//
|
||||
// One puck, three intents: tap to edit, double-tap to fold, press and hold for
|
||||
// the quick menu. The single tap waits out the double-tap window before it
|
||||
// fires — a fifth of a second of latency on opening a form, in exchange for a
|
||||
// fold that doesn't also fling a sheet up over the table.
|
||||
|
||||
LONG_PRESS_MS: 500,
|
||||
DOUBLE_TAP_MS: 220,
|
||||
DRAG_SLOP_PX: 8,
|
||||
pendingTap: null,
|
||||
|
||||
wireSeatGestures(node, index) {
|
||||
let longPressTimer = null;
|
||||
let longPressFired = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
const cancelLongPress = () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
node.addEventListener("pointerdown", e => {
|
||||
longPressFired = false;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
cancelLongPress();
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTimer = null;
|
||||
longPressFired = true;
|
||||
this.cancelPendingTap();
|
||||
node.classList.remove("seatPressing");
|
||||
this.hooks.onSeatLongPress(index);
|
||||
}, this.LONG_PRESS_MS);
|
||||
node.classList.add("seatPressing");
|
||||
});
|
||||
|
||||
// Moving means they're dragging the seat, not holding it.
|
||||
node.addEventListener("pointermove", e => {
|
||||
if (Math.abs(e.clientX - startX) > this.DRAG_SLOP_PX || Math.abs(e.clientY - startY) > this.DRAG_SLOP_PX) {
|
||||
cancelLongPress();
|
||||
node.classList.remove("seatPressing");
|
||||
}
|
||||
});
|
||||
|
||||
["pointerup", "pointercancel", "pointerleave"].forEach(type => {
|
||||
node.addEventListener(type, () => {
|
||||
cancelLongPress();
|
||||
node.classList.remove("seatPressing");
|
||||
});
|
||||
});
|
||||
node.addEventListener("dragstart", () => {
|
||||
cancelLongPress();
|
||||
this.cancelPendingTap();
|
||||
node.classList.remove("seatPressing");
|
||||
});
|
||||
|
||||
node.addEventListener("click", e => {
|
||||
// The long press already did something; don't also treat the release as a tap.
|
||||
if (longPressFired) {
|
||||
longPressFired = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
this.handleSeatTap(index);
|
||||
});
|
||||
},
|
||||
|
||||
cancelPendingTap() {
|
||||
if (this.pendingTap) {
|
||||
clearTimeout(this.pendingTap.timer);
|
||||
this.pendingTap = null;
|
||||
}
|
||||
},
|
||||
|
||||
handleSeatTap(index) {
|
||||
if (this.pendingTap && this.pendingTap.index === index) {
|
||||
this.cancelPendingTap();
|
||||
this.hooks.onSeatDoubleTap(index);
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelPendingTap();
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingTap = null;
|
||||
this.toggleSeatEditor(index);
|
||||
}, this.DOUBLE_TAP_MS);
|
||||
this.pendingTap = { index, timer };
|
||||
},
|
||||
|
||||
// Tapping the open seat again closes it — on a phone the editor is a sheet over
|
||||
// the table, and the seat you just tapped is the closest thing to hand for
|
||||
// dismissing it.
|
||||
toggleSeatEditor(index) {
|
||||
this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
|
||||
this.renderSeats();
|
||||
this.renderSeatEditor();
|
||||
},
|
||||
|
||||
wireDragAndDrop(node, index) {
|
||||
node.addEventListener("dragstart", e => {
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
@@ -175,10 +327,20 @@ const Render = {
|
||||
|
||||
// ---- Seat editor panel (the full form for whichever seat is selected) ----
|
||||
|
||||
closeSeatEditor() {
|
||||
this.selectedSeatIndex = null;
|
||||
this.renderSeats();
|
||||
this.renderSeatEditor();
|
||||
},
|
||||
|
||||
renderSeatEditor() {
|
||||
const title = document.getElementById("seatEditorTitle");
|
||||
const body = document.getElementById("seatEditorBody");
|
||||
|
||||
// Drives the mobile bottom sheet: the panel only slides up over the table
|
||||
// while a seat is actually being edited.
|
||||
document.body.classList.toggle("seatSheetOpen", this.selectedSeatIndex !== null);
|
||||
|
||||
if (this.selectedSeatIndex === null) {
|
||||
title.textContent = "Select a seat";
|
||||
body.innerHTML = '<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>';
|
||||
@@ -240,20 +402,26 @@ const Render = {
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
|
||||
const card1 = form.querySelector(".card1Input");
|
||||
const card2 = form.querySelector(".card2Input");
|
||||
card1.value = player.cards[0] || "";
|
||||
card2.value = player.cards[1] || "";
|
||||
[card1, card2].forEach((input, cardIdx) => {
|
||||
input.classList.toggle("cardRed", this.isRedSuit(input.value));
|
||||
input.addEventListener("input", () => {
|
||||
input.value = input.value.toUpperCase();
|
||||
input.classList.toggle("cardRed", this.isRedSuit(input.value));
|
||||
player.cards[cardIdx] = input.value;
|
||||
this.commitSeatChange(form, player, index);
|
||||
const slots = [form.querySelector(".card1Slot"), form.querySelector(".card2Slot")];
|
||||
slots.forEach((slot, cardIdx) => {
|
||||
this.paintCardSlot(slot, player.cards[cardIdx]);
|
||||
slot.addEventListener("click", () => {
|
||||
CardPicker.open(this.state, {
|
||||
onPick: (seatIndex, pickedIdx, card) => {
|
||||
this.paintCardSlot(slots[pickedIdx], card);
|
||||
this.commitSeatChange(form, player, seatIndex);
|
||||
},
|
||||
}, index, cardIdx);
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -415,22 +583,6 @@ const Render = {
|
||||
},
|
||||
};
|
||||
|
||||
const Money = {
|
||||
format(amount) {
|
||||
const value = Number(amount) || 0;
|
||||
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
},
|
||||
|
||||
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
||||
// glance, and the sign belongs out front where the eye lands first.
|
||||
formatSigned(amount) {
|
||||
const value = Number(amount) || 0;
|
||||
if (value > 0) return `+${this.format(value)}`;
|
||||
if (value < 0) return `-${this.format(Math.abs(value))}`;
|
||||
return this.format(0);
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { Render, Money };
|
||||
module.exports = { Render };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// The long-press menu on a seat. Everything a dealer does to one player mid-hand,
|
||||
// one press and one tap away, without opening the full editor and without hunting
|
||||
// for whose turn it is in the action bar.
|
||||
const SeatMenu = {
|
||||
state: null,
|
||||
hooks: null,
|
||||
seatIndex: null,
|
||||
|
||||
open(state, hooks, seatIndex) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.seatIndex = seatIndex;
|
||||
this.render();
|
||||
document.getElementById("seatMenuOverlay").hidden = false;
|
||||
},
|
||||
|
||||
close() {
|
||||
document.getElementById("seatMenuOverlay").hidden = true;
|
||||
},
|
||||
|
||||
// Only the actions that make sense for this seat right now — a folded player has
|
||||
// nothing to go all-in with, and an empty chair has nothing to fold.
|
||||
actionsFor(player, index) {
|
||||
const actions = [];
|
||||
const dealtIn = player.inHand;
|
||||
const live = dealtIn && !player.folded;
|
||||
|
||||
if (live) {
|
||||
actions.push({ label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
|
||||
actions.push({ label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
|
||||
} else if (dealtIn && player.folded) {
|
||||
actions.push({ label: "Un-fold", run: () => this.hooks.unfold(index) });
|
||||
}
|
||||
|
||||
actions.push({
|
||||
label: dealtIn ? "Sit out" : "Deal in",
|
||||
run: () => this.hooks.toggleDealtIn(index),
|
||||
});
|
||||
|
||||
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
|
||||
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
||||
}
|
||||
|
||||
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
||||
return actions;
|
||||
},
|
||||
|
||||
render() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, this.seatIndex);
|
||||
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
const owed = Betting.toCall(this.state, player);
|
||||
const bits = [];
|
||||
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
|
||||
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
|
||||
document.getElementById("seatMenuSubtitle").textContent = bits.join(" · ");
|
||||
|
||||
const list = document.getElementById("seatMenuActions");
|
||||
list.innerHTML = "";
|
||||
this.actionsFor(player, this.seatIndex).forEach(action => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `seatMenuBtn ${action.className || ""}`;
|
||||
btn.textContent = action.label;
|
||||
btn.addEventListener("click", () => {
|
||||
this.close();
|
||||
action.run();
|
||||
});
|
||||
list.appendChild(btn);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = SeatMenu;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Pure math for chopping one pot between tied players. No DOM.
|
||||
//
|
||||
// A dealer can't push half a chip across the felt, so this works in whole chips of
|
||||
// whatever the smallest denomination in play is: everyone gets the same stack, and
|
||||
// whatever won't divide is called out separately as odd chips. House rule for those
|
||||
// is that they go to the first player left of the button, one at a time.
|
||||
const SplitCalculator = {
|
||||
// Everything is done in cents to keep 0.1 + 0.2 out of a payout.
|
||||
toCents(amount) {
|
||||
return Math.round((Number(amount) || 0) * 100);
|
||||
},
|
||||
|
||||
split(potAmount, ways, chipAmount) {
|
||||
const potCents = this.toCents(potAmount);
|
||||
const chipCents = Math.max(1, this.toCents(chipAmount));
|
||||
const players = Math.max(1, Math.floor(Number(ways) || 0));
|
||||
|
||||
if (potCents <= 0) {
|
||||
return { valid: false, each: 0, oddChips: 0, chip: chipCents / 100, dust: 0, players };
|
||||
}
|
||||
|
||||
const wholeChips = Math.floor(potCents / chipCents);
|
||||
// Left over when the pot itself isn't a whole number of the smallest chip —
|
||||
// usually means the dealer picked the wrong denomination, so it's surfaced
|
||||
// rather than silently rolled into someone's stack.
|
||||
const dustCents = potCents - wholeChips * chipCents;
|
||||
|
||||
const chipsEach = Math.floor(wholeChips / players);
|
||||
const oddChips = wholeChips - chipsEach * players;
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
players,
|
||||
each: (chipsEach * chipCents) / 100,
|
||||
oddChips,
|
||||
chip: chipCents / 100,
|
||||
dust: dustCents / 100,
|
||||
};
|
||||
},
|
||||
|
||||
// Sanity check for the UI: what the split actually hands out has to equal the pot.
|
||||
accountedFor(result) {
|
||||
return (
|
||||
Math.round(result.each * 100) * result.players +
|
||||
Math.round(result.chip * 100) * result.oddChips +
|
||||
Math.round(result.dust * 100)
|
||||
) / 100;
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = SplitCalculator;
|
||||
}
|
||||
@@ -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