Feature/casino holdem sidebar polish #31
@@ -31,6 +31,7 @@
|
|||||||
<span id="potBadgeAmount">$0</span>
|
<span id="potBadgeAmount">$0</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="betsLayer" aria-hidden="true"></div>
|
||||||
<div id="seatsLayer"></div>
|
<div id="seatsLayer"></div>
|
||||||
</div>
|
</div>
|
||||||
<p id="tableHint" class="tableHint"></p>
|
<p id="tableHint" class="tableHint"></p>
|
||||||
@@ -81,34 +82,25 @@
|
|||||||
<span>Starting stack ($)</span>
|
<span>Starting stack ($)</span>
|
||||||
<input type="number" id="defaultStackInput" min="0" step="1" placeholder="e.g. 200" />
|
<input type="number" id="defaultStackInput" min="0" step="1" placeholder="e.g. 200" />
|
||||||
</label>
|
</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>
|
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
|
||||||
<label class="fieldRow">
|
|
||||||
<span>Base pot amount ($)</span>
|
<details class="subPanel">
|
||||||
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
|
<summary>Without live betting</summary>
|
||||||
</label>
|
<div class="subPanelBody">
|
||||||
<p class="fieldHint">Every player who didn't go all-in short is assumed to have put in this amount. Enter a smaller amount on a seat only if that player is all-in for less.</p>
|
<label class="fieldRow">
|
||||||
|
<span>Base pot amount ($)</span>
|
||||||
|
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
|
||||||
|
</label>
|
||||||
|
<p class="fieldHint">For tracking a hand by hand instead of tapping the action: everyone is assumed to have put in this much, unless their seat says otherwise.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="sidebarPanel">
|
<details class="sidebarPanel">
|
||||||
<summary>Chip Legend</summary>
|
<summary>Session</summary>
|
||||||
<div class="panelBody">
|
|
||||||
<div id="chipLegendList"></div>
|
|
||||||
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
|
||||||
<p class="fieldHint">Give each chip color a dollar value, then count chips per seat instead of typing a total.</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details class="sidebarPanel">
|
|
||||||
<summary>Table Notes</summary>
|
|
||||||
<div class="panelBody">
|
|
||||||
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details class="sidebarPanel">
|
|
||||||
<summary>Session Standings</summary>
|
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
|
<h3 class="panelSubhead">Standings</h3>
|
||||||
<div id="standingsTable">
|
<div id="standingsTable">
|
||||||
<div class="standingsRow standingsHeader">
|
<div class="standingsRow standingsHeader">
|
||||||
<span>Player</span>
|
<span>Player</span>
|
||||||
@@ -119,20 +111,33 @@
|
|||||||
<div id="standingsList"></div>
|
<div id="standingsList"></div>
|
||||||
</div>
|
</div>
|
||||||
<p id="standingsEmptyHint" class="fieldHint">Once you resolve a pot, everyone's running total for the night shows up here.</p>
|
<p id="standingsEmptyHint" class="fieldHint">Once you resolve a pot, everyone's running total for the night shows up here.</p>
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details class="sidebarPanel">
|
<h3 class="panelSubhead">Hands</h3>
|
||||||
<summary>Hand History</summary>
|
|
||||||
<div class="panelBody">
|
|
||||||
<div id="handHistoryList"></div>
|
<div id="handHistoryList"></div>
|
||||||
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots show up here as you play, in case a chip count is disputed later.</p>
|
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
|
||||||
|
|
||||||
<div class="handHistoryActions">
|
<div class="handHistoryActions">
|
||||||
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
||||||
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details class="sidebarPanel">
|
||||||
|
<summary>Chip Legend</summary>
|
||||||
|
<div class="panelBody">
|
||||||
|
<div id="chipLegendList"></div>
|
||||||
|
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
||||||
|
<p class="fieldHint">Give each chip color a value, then count chips on a seat instead of typing a total.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="sidebarPanel">
|
||||||
|
<summary>Table Notes</summary>
|
||||||
|
<div class="panelBody">
|
||||||
|
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,8 +172,8 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="seatCards">
|
<div class="seatCards">
|
||||||
<input type="text" class="cardInput card1Input" maxlength="2" placeholder="__" />
|
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
|
||||||
<input type="text" class="cardInput card2Input" maxlength="2" placeholder="__" />
|
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
|
||||||
</div>
|
</div>
|
||||||
<label class="fieldRow stackRow">
|
<label class="fieldRow stackRow">
|
||||||
<span>Stack</span>
|
<span>Stack</span>
|
||||||
@@ -230,6 +235,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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="actionBar" aria-live="polite" hidden></div>
|
||||||
|
|
||||||
<div id="toastHost" aria-live="polite"></div>
|
<div id="toastHost" aria-live="polite"></div>
|
||||||
@@ -265,8 +295,11 @@
|
|||||||
<script src="~/js/casino/holdem/potCalculator.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/state.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/render.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/betting.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/actionBar.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/actionBar.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
<script src="~/js/casino/holdem/shortcuts.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/potWizard.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/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
|||||||
@@ -223,6 +223,68 @@ body {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Bets on the felt ---------- */
|
||||||
|
|
||||||
|
/* Shares the seat ring's box exactly, so a bet always lands on the spoke between its
|
||||||
|
player and the pot. When the seat ring gets inset on a phone, this moves with it —
|
||||||
|
measured the alternative, and a bets ring on its own geometry ends up either behind
|
||||||
|
the pucks or on top of the pot badge. */
|
||||||
|
#betsLayer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.betChip {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28rem;
|
||||||
|
padding: 0.1rem 0.42rem 0.1rem 0.2rem;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reads as a chip seen from above: a lighter top-left, a rim, and the notches a
|
||||||
|
casino chip has around its edge. */
|
||||||
|
.betChipDisc {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.45),
|
||||||
|
inset 0 -1px 2px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.betChipAmount {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f0f0f0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clinging to a puck rather than sitting on its own ring — the phone layout. */
|
||||||
|
.betChipAnchored {
|
||||||
|
left: 50%;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.betChipAbove {
|
||||||
|
top: -11px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.betChipBelow {
|
||||||
|
top: auto;
|
||||||
|
bottom: -11px;
|
||||||
|
transform: translate(-50%, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Seats (compact pucks on the table) ---------- */
|
/* ---------- Seats (compact pucks on the table) ---------- */
|
||||||
|
|
||||||
.seat {
|
.seat {
|
||||||
@@ -238,7 +300,12 @@ body {
|
|||||||
cursor: grab;
|
cursor: grab;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
text-align: center;
|
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 {
|
.seat.dragging {
|
||||||
@@ -481,24 +548,268 @@ body {
|
|||||||
margin-bottom: 0.4rem;
|
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%;
|
width: 100%;
|
||||||
|
min-height: 52px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-transform: uppercase;
|
|
||||||
background: #fdfdfd;
|
background: #fdfdfd;
|
||||||
color: #111;
|
color: #111;
|
||||||
border: 1px solid #3a3a3a;
|
border: 1px solid #3a3a3a;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.35rem 0;
|
padding: 0.35rem 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono, monospace);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
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;
|
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 {
|
.seatContribution {
|
||||||
border-top: 1px dashed #3a3a3a;
|
border-top: 1px dashed #3a3a3a;
|
||||||
padding-top: 0.4rem;
|
padding-top: 0.4rem;
|
||||||
@@ -623,6 +934,46 @@ body {
|
|||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A heading inside a panel, so one panel can hold two related things — the
|
||||||
|
standings and the hand log are two views of the same session — without needing
|
||||||
|
two identical-looking accordions to say so. */
|
||||||
|
.panelSubhead {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: #d8b46a;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelSubhead + * {
|
||||||
|
margin-top: -0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested disclosure for the things a dealer sets once and then forgets, kept out
|
||||||
|
of the way of the settings they actually touch. */
|
||||||
|
.subPanel {
|
||||||
|
border-top: 1px solid #2c2c2c;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subPanel summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #8f8f8f;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subPanel summary:hover {
|
||||||
|
color: #c5c5c5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subPanelBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.fieldRow {
|
.fieldRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -826,12 +1177,37 @@ body.hasActionBar #holdemSidebar {
|
|||||||
|
|
||||||
.actionStatus {
|
.actionStatus {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 0.6rem;
|
gap: 0.4rem 0.6rem;
|
||||||
margin-bottom: 0.55rem;
|
margin-bottom: 0.55rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Always there while there's something to take back, rather than a toast per tap. */
|
||||||
|
.actionUndo {
|
||||||
|
flex: none;
|
||||||
|
min-height: 30px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #414141;
|
||||||
|
background: #262626;
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionUndo:hover {
|
||||||
|
background: #333;
|
||||||
|
border-color: #4f4f4f;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
.actionStreet {
|
.actionStreet {
|
||||||
color: #d8b46a;
|
color: #d8b46a;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -845,6 +1221,11 @@ body.hasActionBar #holdemSidebar {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actionPlayers {
|
||||||
|
color: #9c9c9c;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.actionWho {
|
.actionWho {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: #9c9c9c;
|
color: #9c9c9c;
|
||||||
@@ -853,12 +1234,94 @@ body.hasActionBar #holdemSidebar {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stopwatch, not a shot clock: it colours up as a prompt to the dealer, and does
|
||||||
|
nothing else. */
|
||||||
|
.actionTank {
|
||||||
|
flex: none;
|
||||||
|
min-width: 3.1rem;
|
||||||
|
text-align: right;
|
||||||
|
color: #7d7d7d;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionTank.tankNudge {
|
||||||
|
color: #d8b46a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionTank.tankSlow {
|
||||||
|
color: #e07b7b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Side-pot breakdown ---------- */
|
||||||
|
|
||||||
|
.potBreakdown {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
background: rgba(216, 180, 106, 0.07);
|
||||||
|
border: 1px solid rgba(216, 180, 106, 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.potBreakdownHeading {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: #d8b46a;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.potLayer {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.potLayerLabel {
|
||||||
|
color: #d8b46a;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.potLayerAmount {
|
||||||
|
color: #f2f2f2;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.potLayerWho {
|
||||||
|
color: #9a9a9a;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: right;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.potBreakdownWizard {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.potLayerReturn .potLayerLabel,
|
||||||
|
.potLayerReturn .potLayerAmount {
|
||||||
|
color: #9a9a9a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.actionButtons {
|
.actionButtons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionBtn {
|
.actionBtn {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -896,6 +1359,29 @@ body.hasActionBar #holdemSidebar {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actionKey {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 7px;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
opacity: 0.4;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-transform: uppercase;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No keyboard, no key badges — on a phone they'd just be clutter on the buttons
|
||||||
|
the dealer is already tapping. */
|
||||||
|
@media (hover: none) {
|
||||||
|
.actionKey {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.actionFold {
|
.actionFold {
|
||||||
background: linear-gradient(180deg, #3d1f1f, #2c1515);
|
background: linear-gradient(180deg, #3d1f1f, #2c1515);
|
||||||
border-color: #5b2626;
|
border-color: #5b2626;
|
||||||
@@ -1006,11 +1492,18 @@ body.hasActionBar #holdemSidebar {
|
|||||||
.blindsRow .fieldRow {
|
.blindsRow .fieldRow {
|
||||||
flex: 1 1 120px;
|
flex: 1 1 120px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Small blind" broke onto two lines beside its input, which made the pair look
|
||||||
|
like a mistake rather than a pair. */
|
||||||
|
.blindsRow .fieldRow > span {
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blindsRow input {
|
.blindsRow input {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 5rem;
|
width: 3.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Toasts ---------- */
|
/* ---------- Toasts ---------- */
|
||||||
@@ -1303,7 +1796,8 @@ body.hasActionBar #toastHost {
|
|||||||
|
|
||||||
/* Keeps every puck inside the viewport: the ring is drawn within this box,
|
/* 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. */
|
so half a puck of padding is exactly enough to stop the overhang. */
|
||||||
#seatsLayer {
|
#seatsLayer,
|
||||||
|
#betsLayer {
|
||||||
inset: 26px 44px;
|
inset: 26px 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1420,9 +1914,16 @@ body.hasActionBar #toastHost {
|
|||||||
width: 110px;
|
width: 110px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seatEditorForm .cardInput {
|
.seatEditorForm .cardSlot {
|
||||||
font-size: 1.35rem;
|
font-size: 1.5rem;
|
||||||
min-height: 58px;
|
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 {
|
.seatEditorForm .seatCardToggles {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const ActionBar = {
|
|||||||
if (!this.isRunning()) {
|
if (!this.isRunning()) {
|
||||||
host.hidden = true;
|
host.hidden = true;
|
||||||
document.body.classList.remove("hasActionBar");
|
document.body.classList.remove("hasActionBar");
|
||||||
|
this.stopTankTimer();
|
||||||
|
this.tankSeatIndex = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
host.hidden = false;
|
host.hidden = false;
|
||||||
@@ -34,6 +36,9 @@ const ActionBar = {
|
|||||||
|
|
||||||
host.appendChild(this.renderStatus());
|
host.appendChild(this.renderStatus());
|
||||||
|
|
||||||
|
const breakdown = this.renderPotBreakdown();
|
||||||
|
if (breakdown) host.appendChild(breakdown);
|
||||||
|
|
||||||
if (Betting.isHandOver(this.state)) {
|
if (Betting.isHandOver(this.state)) {
|
||||||
host.appendChild(this.renderHandOver());
|
host.appendChild(this.renderHandOver());
|
||||||
} else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) {
|
} else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) {
|
||||||
@@ -43,6 +48,8 @@ const ActionBar = {
|
|||||||
} else {
|
} else {
|
||||||
host.appendChild(this.renderActions());
|
host.appendChild(this.renderActions());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.syncTankTimer();
|
||||||
},
|
},
|
||||||
|
|
||||||
renderStatus() {
|
renderStatus() {
|
||||||
@@ -59,6 +66,14 @@ const ActionBar = {
|
|||||||
pot.textContent = `Pot ${Money.format(Betting.potSoFar(this.state))}`;
|
pot.textContent = `Pot ${Money.format(Betting.potSoFar(this.state))}`;
|
||||||
row.appendChild(pot);
|
row.appendChild(pot);
|
||||||
|
|
||||||
|
// How many are still in the hand — part of the standard per-street
|
||||||
|
// announcement, and the number a dealer is asked for most often.
|
||||||
|
const remaining = Betting.liveSeats(this.state).length;
|
||||||
|
const players = document.createElement("span");
|
||||||
|
players.className = "actionPlayers";
|
||||||
|
players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`;
|
||||||
|
row.appendChild(players);
|
||||||
|
|
||||||
const index = this.state.actingSeatIndex;
|
const index = this.state.actingSeatIndex;
|
||||||
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
|
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
|
||||||
const player = this.state.seats[index];
|
const player = this.state.seats[index];
|
||||||
@@ -66,11 +81,132 @@ const ActionBar = {
|
|||||||
who.className = "actionWho";
|
who.className = "actionWho";
|
||||||
who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`;
|
who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`;
|
||||||
row.appendChild(who);
|
row.appendChild(who);
|
||||||
|
|
||||||
|
const tank = document.createElement("span");
|
||||||
|
tank.className = "actionTank";
|
||||||
|
tank.id = "actionTank";
|
||||||
|
tank.title = "How long this player has been thinking";
|
||||||
|
tank.textContent = "0:00";
|
||||||
|
row.appendChild(tank);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A standing control rather than a toast per action: a dealer taps this bar
|
||||||
|
// constantly, and a toast after every fold would be noise they'd learn to
|
||||||
|
// ignore — which is exactly when they'd miss the one that mattered.
|
||||||
|
const undoLabel = this.hooks.lastActionLabel();
|
||||||
|
if (undoLabel) {
|
||||||
|
const undo = document.createElement("button");
|
||||||
|
undo.type = "button";
|
||||||
|
undo.className = "actionUndo";
|
||||||
|
undo.title = `Undo ${undoLabel}`;
|
||||||
|
undo.textContent = `↶ ${undoLabel}`;
|
||||||
|
undo.addEventListener("click", () => this.hooks.undoLast());
|
||||||
|
row.appendChild(undo);
|
||||||
}
|
}
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- Think timer ----
|
||||||
|
//
|
||||||
|
// A player who hasn't noticed the action is on them costs more time than anything
|
||||||
|
// else a dealer does, so the bar shows how long the current player has been in the
|
||||||
|
// tank. It's a stopwatch, not a shot clock: nothing happens when it grows, it just
|
||||||
|
// tells the dealer when it's worth a nudge.
|
||||||
|
NUDGE_AFTER_S: 30,
|
||||||
|
SLOW_AFTER_S: 60,
|
||||||
|
|
||||||
|
tankSeatIndex: null,
|
||||||
|
tankStartedAt: null,
|
||||||
|
tankInterval: null,
|
||||||
|
|
||||||
|
// Restarts only when the action actually moves — a re-render for any other reason
|
||||||
|
// (a chip edit, a name change) must not put a thinking player back to zero.
|
||||||
|
syncTankTimer() {
|
||||||
|
const index = this.state.actingSeatIndex;
|
||||||
|
const running = index !== null && index !== undefined && !Betting.isHandOver(this.state);
|
||||||
|
|
||||||
|
if (!running) {
|
||||||
|
this.stopTankTimer();
|
||||||
|
this.tankSeatIndex = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tankSeatIndex !== index) {
|
||||||
|
this.tankSeatIndex = index;
|
||||||
|
this.tankStartedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.tankInterval) {
|
||||||
|
this.tankInterval = setInterval(() => this.paintTank(), 1000);
|
||||||
|
}
|
||||||
|
this.paintTank();
|
||||||
|
},
|
||||||
|
|
||||||
|
stopTankTimer() {
|
||||||
|
if (this.tankInterval) {
|
||||||
|
clearInterval(this.tankInterval);
|
||||||
|
this.tankInterval = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Updates only the timer's own text, so the seconds ticking over never rebuilds the
|
||||||
|
// buttons under the dealer's thumb.
|
||||||
|
paintTank() {
|
||||||
|
const el = document.getElementById("actionTank");
|
||||||
|
if (!el || this.tankStartedAt === null) return;
|
||||||
|
|
||||||
|
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
el.textContent = `${mins}:${String(seconds % 60).padStart(2, "0")}`;
|
||||||
|
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
|
||||||
|
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Shown only once someone is all-in and the money has actually split — a single
|
||||||
|
// undivided pot needs no announcement, and a line that's always there stops being
|
||||||
|
// read.
|
||||||
|
renderPotBreakdown() {
|
||||||
|
// Only worth showing once someone is actually all-in. Mid-street the amounts in
|
||||||
|
// front of players are unequal all the time — the blinds alone are unequal —
|
||||||
|
// and layering that would announce a side pot on every hand before anyone had
|
||||||
|
// even acted.
|
||||||
|
const someoneAllIn = this.state.seats.some(
|
||||||
|
player => player.inHand && Betting.handTotal(player) > 0 && Betting.stackOf(this.state, player) <= 0
|
||||||
|
);
|
||||||
|
if (!someoneAllIn) return null;
|
||||||
|
|
||||||
|
const contributions = this.state.seats.map((player, index) => ({
|
||||||
|
id: player.id,
|
||||||
|
index,
|
||||||
|
contribution: Betting.handTotal(player),
|
||||||
|
folded: player.folded,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const nameById = new Map(
|
||||||
|
this.state.seats.map((player, index) => [player.id, HoldemState.displayName(player, index)])
|
||||||
|
);
|
||||||
|
|
||||||
|
return Render.buildPotBreakdown(
|
||||||
|
PotCalculator.layers(contributions),
|
||||||
|
PotCalculator.uncalledReturns(contributions),
|
||||||
|
id => nameById.get(id)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Which key presses each button. Shown on the button itself and hidden on touch
|
||||||
|
// devices — a shortcut nobody can discover may as well not exist, and a key badge
|
||||||
|
// on a phone is just clutter.
|
||||||
|
KEY_HINTS: {
|
||||||
|
actionFold: "F",
|
||||||
|
actionCheck: "C",
|
||||||
|
actionCall: "C",
|
||||||
|
actionRaise: "R",
|
||||||
|
actionAdvance: "↵",
|
||||||
|
raiseConfirm: "↵",
|
||||||
|
raiseCancel: "esc",
|
||||||
|
},
|
||||||
|
|
||||||
button(label, className, onClick, sublabel) {
|
button(label, className, onClick, sublabel) {
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.type = "button";
|
btn.type = "button";
|
||||||
@@ -81,6 +217,14 @@ const ActionBar = {
|
|||||||
main.textContent = label;
|
main.textContent = label;
|
||||||
btn.appendChild(main);
|
btn.appendChild(main);
|
||||||
|
|
||||||
|
const hint = this.KEY_HINTS[className];
|
||||||
|
if (hint) {
|
||||||
|
const kbd = document.createElement("kbd");
|
||||||
|
kbd.className = "actionKey";
|
||||||
|
kbd.textContent = hint;
|
||||||
|
btn.appendChild(kbd);
|
||||||
|
}
|
||||||
|
|
||||||
if (sublabel) {
|
if (sublabel) {
|
||||||
const sub = document.createElement("span");
|
const sub = document.createElement("span");
|
||||||
sub.className = "actionBtnSub";
|
sub.className = "actionBtnSub";
|
||||||
@@ -92,7 +236,11 @@ const ActionBar = {
|
|||||||
return btn;
|
return btn;
|
||||||
},
|
},
|
||||||
|
|
||||||
commit(mutate) {
|
// Every action taken from the bar goes through here, which is also where it gets
|
||||||
|
// recorded so it can be taken back. A misclick on Fold is the one mistake in this
|
||||||
|
// tool a dealer can't talk their way out of.
|
||||||
|
commit(label, mutate) {
|
||||||
|
this.hooks.beginAction(label);
|
||||||
mutate();
|
mutate();
|
||||||
this.hooks.save(this.state);
|
this.hooks.save(this.state);
|
||||||
this.hooks.rerender();
|
this.hooks.rerender();
|
||||||
@@ -103,20 +251,21 @@ const ActionBar = {
|
|||||||
const player = this.state.seats[index];
|
const player = this.state.seats[index];
|
||||||
const owed = Betting.toCall(this.state, player);
|
const owed = Betting.toCall(this.state, player);
|
||||||
const stack = Betting.stackOf(this.state, player);
|
const stack = Betting.stackOf(this.state, player);
|
||||||
|
const who = HoldemState.displayName(player, index);
|
||||||
|
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "actionButtons";
|
row.className = "actionButtons";
|
||||||
|
|
||||||
row.appendChild(this.button("Fold", "actionFold", () => this.commit(() => Betting.fold(this.state, index))));
|
row.appendChild(this.button("Fold", "actionFold", () => this.commit(`${who}'s fold`, () => Betting.fold(this.state, index))));
|
||||||
|
|
||||||
if (owed <= 0) {
|
if (owed <= 0) {
|
||||||
row.appendChild(this.button("Check", "actionCheck", () => this.commit(() => Betting.check(this.state, index))));
|
row.appendChild(this.button("Check", "actionCheck", () => this.commit(`${who}'s check`, () => Betting.check(this.state, index))));
|
||||||
} else if (owed >= stack) {
|
} else if (owed >= stack) {
|
||||||
// Calling would put them all-in, so say that rather than quoting a number
|
// Calling would put them all-in, so say that rather than quoting a number
|
||||||
// they can't cover.
|
// they can't cover.
|
||||||
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(() => Betting.allIn(this.state, index)), Money.format(stack)));
|
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(`${who}'s all-in`, () => Betting.allIn(this.state, index)), Money.format(stack)));
|
||||||
} else {
|
} else {
|
||||||
row.appendChild(this.button("Call", "actionCall", () => this.commit(() => Betting.call(this.state, index)), Money.format(owed)));
|
row.appendChild(this.button("Call", "actionCall", () => this.commit(`${who}'s call`, () => Betting.call(this.state, index)), Money.format(owed)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stack > owed) {
|
if (stack > owed) {
|
||||||
@@ -185,7 +334,8 @@ const ActionBar = {
|
|||||||
entry.appendChild(this.button("Raise to", "raiseConfirm", () => {
|
entry.appendChild(this.button("Raise to", "raiseConfirm", () => {
|
||||||
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
|
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
|
||||||
this.raiseOpen = false;
|
this.raiseOpen = false;
|
||||||
this.commit(() => Betting.raiseTo(this.state, index, target));
|
const who = HoldemState.displayName(player, index);
|
||||||
|
this.commit(`${who}'s raise to ${Money.format(target)}`, () => Betting.raiseTo(this.state, index, target));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
wrap.appendChild(entry);
|
wrap.appendChild(entry);
|
||||||
@@ -204,7 +354,7 @@ const ActionBar = {
|
|||||||
this.hooks.resolvePot();
|
this.hooks.resolvePot();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.commit(() => Betting.closeStreet(this.state));
|
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,62 @@
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const state = HoldemState.load();
|
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 = {
|
const hooks = {
|
||||||
save: s => HoldemState.save(s),
|
save: s => HoldemState.save(s),
|
||||||
rerender: () => Render.renderAll(),
|
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);
|
Render.mount(state, hooks);
|
||||||
@@ -30,10 +83,69 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A short history of actions taken from the bar, so a misclicked Fold can be taken
|
||||||
|
// back — and the one before it, since a dealer usually notices a beat late. Kept in
|
||||||
|
// memory only: this is for the mistake you just made, not last night's hand.
|
||||||
|
const MAX_UNDO_STEPS = 12;
|
||||||
|
const actionHistory = [];
|
||||||
|
|
||||||
ActionBar.mount(state, {
|
ActionBar.mount(state, {
|
||||||
save: hooks.save,
|
save: hooks.save,
|
||||||
rerender: hooks.rerender,
|
rerender: hooks.rerender,
|
||||||
resolvePot: openPotWizard,
|
resolvePot: openPotWizard,
|
||||||
|
|
||||||
|
beginAction: label => {
|
||||||
|
actionHistory.push({ label, snapshot: JSON.parse(JSON.stringify(state)) });
|
||||||
|
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
|
||||||
|
},
|
||||||
|
|
||||||
|
lastActionLabel: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].label : null),
|
||||||
|
|
||||||
|
undoLast: () => {
|
||||||
|
const step = actionHistory.pop();
|
||||||
|
if (!step) return;
|
||||||
|
Object.keys(state).forEach(key => delete state[key]);
|
||||||
|
Object.assign(state, step.snapshot);
|
||||||
|
hooks.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
Toast.show(`Took back ${step.label}.`, { duration: 2500 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Anything that ends the hand makes the per-action history meaningless — the
|
||||||
|
// snapshots refer to a hand that no longer exists.
|
||||||
|
const clearActionHistory = () => { actionHistory.length = 0; };
|
||||||
|
|
||||||
|
// Keyboard shortcuts press the very same buttons a dealer would tap, rather than
|
||||||
|
// calling the betting engine themselves — there's no second code path to drift out
|
||||||
|
// of step with what the bar is actually offering right now.
|
||||||
|
const pressInBar = selector => {
|
||||||
|
const btn = document.querySelector(`#actionBar ${selector}`);
|
||||||
|
if (!btn || btn.disabled) return false;
|
||||||
|
btn.click();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const quickRaiseButton = label =>
|
||||||
|
[...document.querySelectorAll("#actionBar .raiseQuickBtn")]
|
||||||
|
.find(b => b.querySelector(".actionBtnLabel").textContent === label);
|
||||||
|
|
||||||
|
Shortcuts.mount({
|
||||||
|
fold: () => pressInBar(".actionFold"),
|
||||||
|
checkOrCall: () => pressInBar(".actionCheck") || pressInBar(".actionCall"),
|
||||||
|
raise: () => pressInBar(".actionRaise"),
|
||||||
|
allIn: () => {
|
||||||
|
// Open the raise pad first if it isn't already, then take its All-in amount.
|
||||||
|
if (!document.querySelector("#actionBar .raisePad") && !pressInBar(".actionRaise")) return false;
|
||||||
|
const quick = quickRaiseButton("All-in");
|
||||||
|
if (!quick) return false;
|
||||||
|
quick.click();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
// Enter confirms a raise if one is being entered, otherwise moves the hand on.
|
||||||
|
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance"),
|
||||||
|
undo: () => pressInBar(".actionUndo"),
|
||||||
|
cancel: () => pressInBar(".raiseCancel"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Moving to the next hand used to ask "are you sure?" every single time —
|
// Moving to the next hand used to ask "are you sure?" every single time —
|
||||||
@@ -44,6 +156,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const runUndoable = (message, mutate) => {
|
const runUndoable = (message, mutate) => {
|
||||||
const snapshot = JSON.parse(JSON.stringify(state));
|
const snapshot = JSON.parse(JSON.stringify(state));
|
||||||
mutate();
|
mutate();
|
||||||
|
// Starting or settling a hand invalidates the per-action history: those
|
||||||
|
// snapshots describe a hand that's over. The whole-step Undo below replaces it.
|
||||||
|
clearActionHistory();
|
||||||
hooks.save(state);
|
hooks.save(state);
|
||||||
Render.renderAll();
|
Render.renderAll();
|
||||||
|
|
||||||
@@ -124,6 +239,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
HoldemState.clear();
|
HoldemState.clear();
|
||||||
Object.assign(state, HoldemState.createDefault());
|
Object.assign(state, HoldemState.createDefault());
|
||||||
|
clearActionHistory();
|
||||||
Render.mount(state, hooks);
|
Render.mount(state, hooks);
|
||||||
Toast.show("Table reset.", { duration: 2500 });
|
Toast.show("Table reset.", { duration: 2500 });
|
||||||
});
|
});
|
||||||
@@ -144,6 +260,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
|
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("closePotWizardBtn").addEventListener("click", () => PotWizard.close());
|
||||||
document.getElementById("potWizardOverlay").addEventListener("click", e => {
|
document.getElementById("potWizardOverlay").addEventListener("click", e => {
|
||||||
if (e.target.id === "potWizardOverlay") PotWizard.close();
|
if (e.target.id === "potWizardOverlay") PotWizard.close();
|
||||||
|
|||||||
@@ -24,6 +24,79 @@ const PotCalculator = {
|
|||||||
return entries.filter(e => !e.folded && e.remaining > this.EPSILON);
|
return entries.filter(e => !e.folded && e.remaining > this.EPSILON);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Splits the money on the table into the main pot and any side pots, with who can
|
||||||
|
// win each. Read-only — this is what a dealer says out loud before the showdown
|
||||||
|
// ("main pot four-eighty, side pot ninety between seats 3 and 5"), which is the
|
||||||
|
// announcement that goes missing far more often than the arithmetic goes wrong.
|
||||||
|
//
|
||||||
|
// Every distinct all-in amount opens a new layer: each player pays into a layer up
|
||||||
|
// to their own contribution, and only players who reached that height can win it.
|
||||||
|
// Folded money stays in — it's dead money owed to whoever wins the layer it fell in.
|
||||||
|
layers(players) {
|
||||||
|
const live = players.filter(p => p.contribution > this.EPSILON);
|
||||||
|
if (live.length === 0) return [];
|
||||||
|
|
||||||
|
const heights = [...new Set(live.map(p => Math.round(p.contribution * 100)))].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const layers = [];
|
||||||
|
let floor = 0;
|
||||||
|
heights.forEach(height => {
|
||||||
|
const band = height - floor;
|
||||||
|
const payers = live.filter(p => Math.round(p.contribution * 100) >= height);
|
||||||
|
const amount = band * payers.length;
|
||||||
|
const eligible = payers.filter(p => !p.folded).map(p => p.id);
|
||||||
|
|
||||||
|
// A band nobody can win isn't a pot — it's an overbet that never got
|
||||||
|
// called, and it goes back to whoever put it in. Announcing it as a side
|
||||||
|
// pot would overstate what's actually being played for.
|
||||||
|
if (amount > 0 && eligible.length > 0) {
|
||||||
|
layers.push({ amount: amount / 100, eligible });
|
||||||
|
}
|
||||||
|
floor = height;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Consecutive layers with the same set of eligible players are one pot to a
|
||||||
|
// dealer — nobody announces two side pots between the same two people.
|
||||||
|
const merged = [];
|
||||||
|
layers.forEach(layer => {
|
||||||
|
const previous = merged[merged.length - 1];
|
||||||
|
const sameEligibility =
|
||||||
|
previous &&
|
||||||
|
previous.eligible.length === layer.eligible.length &&
|
||||||
|
previous.eligible.every(id => layer.eligible.includes(id));
|
||||||
|
|
||||||
|
if (sameEligibility) {
|
||||||
|
previous.amount = Math.round((previous.amount + layer.amount) * 100) / 100;
|
||||||
|
} else {
|
||||||
|
merged.push({ ...layer });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return merged.map((layer, index) => ({
|
||||||
|
...layer,
|
||||||
|
label: index === 0 ? "Main pot" : `Side pot ${index}`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Chips nobody could call, which the dealer pushes back rather than into the pot.
|
||||||
|
// Anything above the biggest live contribution was never contested — most often an
|
||||||
|
// overbet by someone who then folded.
|
||||||
|
uncalledReturns(players) {
|
||||||
|
const contributing = players.filter(p => p.contribution > this.EPSILON);
|
||||||
|
const liveCents = contributing.filter(p => !p.folded).map(p => Math.round(p.contribution * 100));
|
||||||
|
|
||||||
|
// Nobody live at all means the fold flags are wrong, not that every chip on
|
||||||
|
// the table was uncalled. That's a correction for the dealer to make, so say
|
||||||
|
// nothing here and let the wizard ask them to fix it.
|
||||||
|
if (liveCents.length === 0) return [];
|
||||||
|
|
||||||
|
const cap = Math.max(...liveCents);
|
||||||
|
|
||||||
|
return contributing
|
||||||
|
.map(p => ({ id: p.id, amount: (Math.round(p.contribution * 100) - cap) / 100 }))
|
||||||
|
.filter(r => r.amount > this.EPSILON);
|
||||||
|
},
|
||||||
|
|
||||||
// Awards the current pot layer to `winnerIds` (one id, or several for a split/tie).
|
// Awards the current pot layer to `winnerIds` (one id, or several for a split/tie).
|
||||||
// The payout is capped at the smallest remaining contribution among the winners —
|
// The payout is capped at the smallest remaining contribution among the winners —
|
||||||
// that's the classic side-pot rule: an all-in player can only win what everyone at
|
// that's the classic side-pot rule: an all-in player can only win what everyone at
|
||||||
|
|||||||
@@ -18,13 +18,16 @@ const PotWizard = {
|
|||||||
this.playerLabelById = new Map(
|
this.playerLabelById = new Map(
|
||||||
players.map(p => [p.id, this.labelFor(p, state.seats.indexOf(p))])
|
players.map(p => [p.id, this.labelFor(p, state.seats.indexOf(p))])
|
||||||
);
|
);
|
||||||
this.entries = PotCalculator.buildEntries(
|
const contributions = players.map(p => ({
|
||||||
players.map(p => ({
|
id: p.id,
|
||||||
id: p.id,
|
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||||
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
|
folded: p.folded,
|
||||||
folded: p.folded,
|
}));
|
||||||
}))
|
this.entries = PotCalculator.buildEntries(contributions);
|
||||||
);
|
// Kept so the wizard can show the whole breakdown up front, not just the layer
|
||||||
|
// it happens to be asking about.
|
||||||
|
this.layers = PotCalculator.layers(contributions);
|
||||||
|
this.returns = PotCalculator.uncalledReturns(contributions);
|
||||||
this.log = [];
|
this.log = [];
|
||||||
this.totals = new Map();
|
this.totals = new Map();
|
||||||
|
|
||||||
@@ -38,7 +41,7 @@ const PotWizard = {
|
|||||||
|
|
||||||
labelFor(player, seatIndex) {
|
labelFor(player, seatIndex) {
|
||||||
const name = HoldemState.displayName(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;
|
return cards ? `${name} (${cards})` : name;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -58,6 +61,7 @@ const PotWizard = {
|
|||||||
|
|
||||||
const body = document.getElementById("potWizardBody");
|
const body = document.getElementById("potWizardBody");
|
||||||
body.innerHTML = "";
|
body.innerHTML = "";
|
||||||
|
if (this.log.length === 0) body.appendChild(this.renderBreakdown());
|
||||||
body.appendChild(this.renderLog());
|
body.appendChild(this.renderLog());
|
||||||
|
|
||||||
const remaining = PotCalculator.totalPot(this.entries.filter(e => e.remaining > PotCalculator.EPSILON));
|
const remaining = PotCalculator.totalPot(this.entries.filter(e => e.remaining > PotCalculator.EPSILON));
|
||||||
@@ -65,7 +69,17 @@ const PotWizard = {
|
|||||||
if (contenders.length === 0 && remaining > PotCalculator.EPSILON) {
|
if (contenders.length === 0 && remaining > PotCalculator.EPSILON) {
|
||||||
const warning = document.createElement("p");
|
const warning = document.createElement("p");
|
||||||
warning.className = "potWizardPrompt";
|
warning.className = "potWizardPrompt";
|
||||||
warning.textContent = `${Money.format(remaining)} is still unassigned, but every remaining player is marked Folded. Uncheck Fold on whoever should win it.`;
|
|
||||||
|
// Money left over because nobody could call it isn't a pot anyone won —
|
||||||
|
// it goes back to whoever put it in. Telling the dealer to un-fold someone
|
||||||
|
// to "win" it would be wrong.
|
||||||
|
const returned = (this.returns || []).reduce((sum, r) => sum + r.amount, 0);
|
||||||
|
if (Math.abs(returned - remaining) <= PotCalculator.EPSILON && returned > 0) {
|
||||||
|
const who = this.returns.map(r => this.playerLabelById.get(r.id)).join(", ");
|
||||||
|
warning.textContent = `${Money.format(remaining)} was never called — push it back to ${who} rather than paying it out.`;
|
||||||
|
} else {
|
||||||
|
warning.textContent = `${Money.format(remaining)} is still unassigned, but every remaining player is marked Folded. Uncheck Fold on whoever should win it.`;
|
||||||
|
}
|
||||||
body.appendChild(warning);
|
body.appendChild(warning);
|
||||||
body.appendChild(this.renderSummary());
|
body.appendChild(this.renderSummary());
|
||||||
return;
|
return;
|
||||||
@@ -131,6 +145,20 @@ const PotWizard = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The announcement, spelled out before anyone is asked to pick a winner: what the
|
||||||
|
// main pot is worth, what each side pot is worth, and who can win each.
|
||||||
|
renderBreakdown() {
|
||||||
|
const node = Render.buildPotBreakdown(
|
||||||
|
this.layers || [],
|
||||||
|
this.returns || [],
|
||||||
|
id => this.playerLabelById.get(id),
|
||||||
|
"Announce before the showdown"
|
||||||
|
);
|
||||||
|
if (!node) return document.createDocumentFragment();
|
||||||
|
node.classList.add("potBreakdownWizard");
|
||||||
|
return node;
|
||||||
|
},
|
||||||
|
|
||||||
renderLog() {
|
renderLog() {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = "potWizardLog";
|
wrap.className = "potWizardLog";
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ const Render = {
|
|||||||
|
|
||||||
renderTableHint() {
|
renderTableHint() {
|
||||||
const hint = document.getElementById("tableHint");
|
const hint = document.getElementById("tableHint");
|
||||||
hint.textContent = this.state.dedicatedDealer
|
// Gestures are invisible until someone tells you about them, and this is the
|
||||||
? "Drag a seat onto another seat to swap players."
|
// only place on the table with room to say so.
|
||||||
: "Drag a seat onto another seat to swap players. Click the dealer chip on a seat to make it the button.";
|
hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats.";
|
||||||
},
|
},
|
||||||
|
|
||||||
renderNotes() {
|
renderNotes() {
|
||||||
@@ -69,13 +69,85 @@ const Render = {
|
|||||||
// ---- Seats around the table (compact pucks) ----
|
// ---- Seats around the table (compact pucks) ----
|
||||||
|
|
||||||
seatPosition(index, count) {
|
seatPosition(index, count) {
|
||||||
|
return this.positionOnRing(index, count, 44, 42);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Bets sit on the felt between their player and the pot, the way chips actually
|
||||||
|
// do — closer in than the seats, on the same spoke. The bets layer is inset to
|
||||||
|
// match the seat ring (see the CSS), so this one radius holds at every size.
|
||||||
|
betPosition(index, count) {
|
||||||
|
return this.positionOnRing(index, count, 27, 25);
|
||||||
|
},
|
||||||
|
|
||||||
|
positionOnRing(index, count, rx, ry) {
|
||||||
const angle = -90 + (360 / count) * index;
|
const angle = -90 + (360 / count) * index;
|
||||||
const radians = (angle * Math.PI) / 180;
|
const radians = (angle * Math.PI) / 180;
|
||||||
const rx = 44;
|
return {
|
||||||
const ry = 42;
|
left: 50 + rx * Math.cos(radians),
|
||||||
const left = 50 + rx * Math.cos(radians);
|
top: 50 + ry * Math.sin(radians),
|
||||||
const top = 50 + ry * Math.sin(radians);
|
};
|
||||||
return { left, top };
|
},
|
||||||
|
|
||||||
|
// Colour a bet by the biggest chip that fits it, so a table with a chip legend
|
||||||
|
// gets bets that look like the chips actually being pushed out.
|
||||||
|
chipColorFor(amount) {
|
||||||
|
const legend = [...(this.state.chipLegend || [])]
|
||||||
|
.filter(chip => Number(chip.value) > 0)
|
||||||
|
.sort((a, b) => Number(b.value) - Number(a.value));
|
||||||
|
const match = legend.find(chip => amount >= Number(chip.value));
|
||||||
|
return (match || legend[legend.length - 1] || {}).color || "#8a8a8a";
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Bets on the felt ----
|
||||||
|
|
||||||
|
makeBetChip(bet) {
|
||||||
|
const chip = document.createElement("div");
|
||||||
|
chip.className = "betChip";
|
||||||
|
|
||||||
|
const disc = document.createElement("span");
|
||||||
|
disc.className = "betChipDisc";
|
||||||
|
disc.style.background = this.chipColorFor(bet);
|
||||||
|
|
||||||
|
const amount = document.createElement("span");
|
||||||
|
amount.className = "betChipAmount";
|
||||||
|
amount.textContent = Money.format(bet);
|
||||||
|
|
||||||
|
chip.appendChild(disc);
|
||||||
|
chip.appendChild(amount);
|
||||||
|
return chip;
|
||||||
|
},
|
||||||
|
|
||||||
|
// A phone's felt has no room for a third ring: measured every radius between the
|
||||||
|
// seats and the pot badge and each one collides with one or the other. So on a
|
||||||
|
// narrow screen the bet clings to its own puck instead — same chip, no ring —
|
||||||
|
// sitting on whichever side faces the middle of the table.
|
||||||
|
renderBets() {
|
||||||
|
const layer = document.getElementById("betsLayer");
|
||||||
|
if (!layer) return;
|
||||||
|
layer.innerHTML = "";
|
||||||
|
|
||||||
|
const anchorToSeat = window.matchMedia("(max-width: 700px)").matches;
|
||||||
|
|
||||||
|
this.state.seats.forEach((player, index) => {
|
||||||
|
const bet = Betting.committed(player);
|
||||||
|
if (bet <= 0) return;
|
||||||
|
|
||||||
|
const chip = this.makeBetChip(bet);
|
||||||
|
const pos = this.betPosition(index, this.state.seatCount);
|
||||||
|
|
||||||
|
if (anchorToSeat) {
|
||||||
|
const seat = document.querySelector(`.seat[data-seat-index="${index}"]`);
|
||||||
|
if (!seat) return;
|
||||||
|
const seatPos = this.seatPosition(index, this.state.seatCount);
|
||||||
|
chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow");
|
||||||
|
seat.appendChild(chip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chip.style.left = `${pos.left}%`;
|
||||||
|
chip.style.top = `${pos.top}%`;
|
||||||
|
layer.appendChild(chip);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
puckLabel(player, seatIndex) {
|
puckLabel(player, seatIndex) {
|
||||||
@@ -113,25 +185,38 @@ const Render = {
|
|||||||
this.updatePuckContent(node, player, index);
|
this.updatePuckContent(node, player, index);
|
||||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||||
|
|
||||||
node.addEventListener("click", () => {
|
this.wireSeatGestures(node, index);
|
||||||
// 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.
|
|
||||||
this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
|
|
||||||
this.renderSeats();
|
|
||||||
this.renderSeatEditor();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.wireDragAndDrop(node, index);
|
this.wireDragAndDrop(node, index);
|
||||||
|
|
||||||
layer.appendChild(node);
|
layer.appendChild(node);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.renderBets();
|
||||||
},
|
},
|
||||||
|
|
||||||
isRedSuit(card) {
|
isRedSuit(card) {
|
||||||
return /[HD]$/i.test(card || "");
|
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.
|
// 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
|
// Worth calling out, because at a short-handed table the open seats are what
|
||||||
// the dealer is looking for.
|
// the dealer is looking for.
|
||||||
@@ -154,14 +239,16 @@ const Render = {
|
|||||||
if (i > 0) cardsEl.appendChild(document.createTextNode(" "));
|
if (i > 0) cardsEl.appendChild(document.createTextNode(" "));
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
if (this.isRedSuit(card)) span.className = "cardRed";
|
if (this.isRedSuit(card)) span.className = "cardRed";
|
||||||
span.textContent = card;
|
span.textContent = this.formatCard(card);
|
||||||
cardsEl.appendChild(span);
|
cardsEl.appendChild(span);
|
||||||
});
|
});
|
||||||
// What's in front of them this street reads as the live number during betting;
|
// Always this player's total for the hand. What they've just pushed out on this
|
||||||
// between hands it falls back to their total for the hand.
|
// street is shown as a chip on the felt instead — the puck used to flip between
|
||||||
const streetBet = Betting.committed(player);
|
// the two meanings depending on the moment, which is a bad thing for a number
|
||||||
const total = streetBet > 0
|
// sitting next to a stack size to do.
|
||||||
? streetBet
|
const handTotal = Betting.handTotal(player);
|
||||||
|
const total = handTotal > 0
|
||||||
|
? handTotal
|
||||||
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
||||||
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
|
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
|
||||||
|
|
||||||
@@ -183,6 +270,109 @@ const Render = {
|
|||||||
if (node) this.updatePuckContent(node, this.state.seats[index], index);
|
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) {
|
wireDragAndDrop(node, index) {
|
||||||
node.addEventListener("dragstart", e => {
|
node.addEventListener("dragstart", e => {
|
||||||
e.dataTransfer.setData("text/plain", String(index));
|
e.dataTransfer.setData("text/plain", String(index));
|
||||||
@@ -288,17 +478,16 @@ const Render = {
|
|||||||
this.commitSeatChange(form, player, index);
|
this.commitSeatChange(form, player, index);
|
||||||
});
|
});
|
||||||
|
|
||||||
const card1 = form.querySelector(".card1Input");
|
const slots = [form.querySelector(".card1Slot"), form.querySelector(".card2Slot")];
|
||||||
const card2 = form.querySelector(".card2Input");
|
slots.forEach((slot, cardIdx) => {
|
||||||
card1.value = player.cards[0] || "";
|
this.paintCardSlot(slot, player.cards[cardIdx]);
|
||||||
card2.value = player.cards[1] || "";
|
slot.addEventListener("click", () => {
|
||||||
[card1, card2].forEach((input, cardIdx) => {
|
CardPicker.open(this.state, {
|
||||||
input.classList.toggle("cardRed", this.isRedSuit(input.value));
|
onPick: (seatIndex, pickedIdx, card) => {
|
||||||
input.addEventListener("input", () => {
|
this.paintCardSlot(slots[pickedIdx], card);
|
||||||
input.value = input.value.toUpperCase();
|
this.commitSeatChange(form, player, seatIndex);
|
||||||
input.classList.toggle("cardRed", this.isRedSuit(input.value));
|
},
|
||||||
player.cards[cardIdx] = input.value;
|
}, index, cardIdx);
|
||||||
this.commitSeatChange(form, player, index);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -407,6 +596,51 @@ const Render = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- Pot breakdown ----
|
||||||
|
|
||||||
|
// Shared by the action bar (live, mid-hand) and the pot wizard (before winners are
|
||||||
|
// picked), because both are showing the dealer the same announcement.
|
||||||
|
buildPotBreakdown(pots, returns, nameFor, heading) {
|
||||||
|
if (pots.length < 2 && returns.length === 0) return null;
|
||||||
|
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "potBreakdown";
|
||||||
|
|
||||||
|
if (heading) {
|
||||||
|
const title = document.createElement("p");
|
||||||
|
title.className = "potBreakdownHeading";
|
||||||
|
title.textContent = heading;
|
||||||
|
wrap.appendChild(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addRow = (label, amount, who, className) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = `potLayer ${className || ""}`;
|
||||||
|
|
||||||
|
const labelEl = document.createElement("span");
|
||||||
|
labelEl.className = "potLayerLabel";
|
||||||
|
labelEl.textContent = label;
|
||||||
|
|
||||||
|
const amountEl = document.createElement("span");
|
||||||
|
amountEl.className = "potLayerAmount";
|
||||||
|
amountEl.textContent = Money.format(amount);
|
||||||
|
|
||||||
|
const whoEl = document.createElement("span");
|
||||||
|
whoEl.className = "potLayerWho";
|
||||||
|
whoEl.textContent = who;
|
||||||
|
|
||||||
|
row.appendChild(labelEl);
|
||||||
|
row.appendChild(amountEl);
|
||||||
|
row.appendChild(whoEl);
|
||||||
|
wrap.appendChild(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.map(nameFor).join(", ")));
|
||||||
|
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${nameFor(ret.id)}`, "potLayerReturn"));
|
||||||
|
|
||||||
|
return wrap;
|
||||||
|
},
|
||||||
|
|
||||||
// ---- Session standings ----
|
// ---- Session standings ----
|
||||||
|
|
||||||
renderStandings() {
|
renderStandings() {
|
||||||
|
|||||||
@@ -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,64 @@
|
|||||||
|
// Keyboard control for a dealer running this on a laptop.
|
||||||
|
//
|
||||||
|
// The phone has gestures; the desktop had a mouse and nothing else. These are the
|
||||||
|
// same actions the bar already offers, reachable without moving a hand to the
|
||||||
|
// trackpad between every decision — which is the whole point when the bar is being
|
||||||
|
// tapped thirty times a hand.
|
||||||
|
const Shortcuts = {
|
||||||
|
// Deliberately single letters with no modifier: a dealer's spare hand rests on
|
||||||
|
// the keyboard, and a chord is slower than reaching for the button would be.
|
||||||
|
KEYS: {
|
||||||
|
f: "fold",
|
||||||
|
c: "checkOrCall",
|
||||||
|
r: "raise",
|
||||||
|
a: "allIn",
|
||||||
|
" ": "advance",
|
||||||
|
enter: "advance",
|
||||||
|
u: "undo",
|
||||||
|
escape: "cancel",
|
||||||
|
},
|
||||||
|
|
||||||
|
mount(handlers) {
|
||||||
|
this.handlers = handlers;
|
||||||
|
document.addEventListener("keydown", e => this.onKey(e));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Nothing fires while the dealer is typing a name, an amount, or a note — the
|
||||||
|
// "c" in "Cass" must not fold the table.
|
||||||
|
isTyping() {
|
||||||
|
const el = document.activeElement;
|
||||||
|
if (!el) return false;
|
||||||
|
const tag = el.tagName;
|
||||||
|
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
||||||
|
},
|
||||||
|
|
||||||
|
// An overlay owns the keyboard while it's up: the confirm dialog already binds
|
||||||
|
// Enter and Escape, and the card picker and seat menu are modal.
|
||||||
|
overlayOpen() {
|
||||||
|
return ["confirmOverlay", "potWizardOverlay", "cardPickerOverlay", "seatMenuOverlay"]
|
||||||
|
.some(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
return el && !el.hidden;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onKey(e) {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
|
||||||
|
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase();
|
||||||
|
const action = this.KEYS[key];
|
||||||
|
if (!action) return;
|
||||||
|
|
||||||
|
// Escape is the exception: it should still close the raise pad even if focus
|
||||||
|
// happens to be sitting in its amount field.
|
||||||
|
if (this.isTyping() && !(action === "cancel")) return;
|
||||||
|
if (this.overlayOpen()) return;
|
||||||
|
|
||||||
|
const handled = this.handlers[action] && this.handlers[action]();
|
||||||
|
if (handled) e.preventDefault();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = Shortcuts;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user