Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db208a1a86 | ||
|
|
3d63ba50ee | ||
|
|
073ba9a252 |
@@ -6,6 +6,148 @@ body {
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Touch ----------
|
||||||
|
A dealer runs this one-handed on a phone with players waiting, so a tap has to
|
||||||
|
register the instant their thumb lands.
|
||||||
|
|
||||||
|
`touch-action: manipulation` is the whole game here. Without it a mobile browser
|
||||||
|
holds every tap for ~300ms to see whether a second one is coming that would mean
|
||||||
|
"zoom in" — on top of which double-tapping a seat, which this tool uses for fold,
|
||||||
|
*is* the browser's zoom gesture, so the dealer's shortcut fights the page zooming
|
||||||
|
under their thumb. `manipulation` keeps panning and pinch-zoom and drops only the
|
||||||
|
double-tap-to-zoom, which is exactly the one we don't want.
|
||||||
|
|
||||||
|
The tap highlight goes with it: every control below gets a real pressed state, and
|
||||||
|
the browser's translucent grey rectangle on top of a designed one just looks like
|
||||||
|
a rendering artefact. Text selection goes too — a fast tap-and-hold on a seat
|
||||||
|
should open the seat menu, not raise a text-selection magnifier over the felt. */
|
||||||
|
.holdemBtn,
|
||||||
|
.seat,
|
||||||
|
.dealerChip,
|
||||||
|
.cardSlot,
|
||||||
|
.linkBtn,
|
||||||
|
.seatMenuBtn,
|
||||||
|
.seatSheetClose,
|
||||||
|
.pickerCard,
|
||||||
|
.actionBtn,
|
||||||
|
.raiseQuickBtn,
|
||||||
|
.actionUndo,
|
||||||
|
.toastAction,
|
||||||
|
.toastClose,
|
||||||
|
.potWizardChoice,
|
||||||
|
.subPanel summary,
|
||||||
|
button,
|
||||||
|
[role="button"],
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
a {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The grey rectangle goes from every control, fields included — a tapped input
|
||||||
|
already shows a caret and a focus ring, which say the same thing more quietly. */
|
||||||
|
.holdemBtn,
|
||||||
|
.seat,
|
||||||
|
.dealerChip,
|
||||||
|
.cardSlot,
|
||||||
|
.linkBtn,
|
||||||
|
.seatMenuBtn,
|
||||||
|
.seatSheetClose,
|
||||||
|
.pickerCard,
|
||||||
|
.actionBtn,
|
||||||
|
.raiseQuickBtn,
|
||||||
|
.actionUndo,
|
||||||
|
.toastAction,
|
||||||
|
.toastClose,
|
||||||
|
.potWizardChoice,
|
||||||
|
.subPanel summary,
|
||||||
|
button,
|
||||||
|
[role="button"],
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
a {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text selection is switched off only on the things you press, never on the fields
|
||||||
|
you type into — a dealer editing a player's name still needs to select the text. */
|
||||||
|
.holdemBtn,
|
||||||
|
.seat,
|
||||||
|
.dealerChip,
|
||||||
|
.cardSlot,
|
||||||
|
.linkBtn,
|
||||||
|
.seatMenuBtn,
|
||||||
|
.seatSheetClose,
|
||||||
|
.pickerCard,
|
||||||
|
.actionBtn,
|
||||||
|
.raiseQuickBtn,
|
||||||
|
.actionUndo,
|
||||||
|
.toastAction,
|
||||||
|
.toastClose,
|
||||||
|
.potWizardChoice,
|
||||||
|
.subPanel summary,
|
||||||
|
button,
|
||||||
|
[role="button"] {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Pressed states ----------
|
||||||
|
Touch has no hover, so the press is the only feedback a dealer gets that the tap
|
||||||
|
landed — and with the grey highlight suppressed above, these have to carry it.
|
||||||
|
Everything presses the same way it would on a real table: down and slightly
|
||||||
|
darker, no travel time. */
|
||||||
|
.holdemBtn:active:not(:disabled),
|
||||||
|
.linkBtn:active:not(:disabled),
|
||||||
|
.seatMenuBtn:active:not(:disabled),
|
||||||
|
.seatSheetClose:active:not(:disabled),
|
||||||
|
.toastAction:active,
|
||||||
|
.toastClose:active,
|
||||||
|
.potWizardChoice:active,
|
||||||
|
.raiseQuickBtn:active:not(:disabled),
|
||||||
|
.actionUndo:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
filter: brightness(1.25);
|
||||||
|
transition-duration: 0.04s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardSlot:active,
|
||||||
|
.pickerCard:active:not(:disabled),
|
||||||
|
.dealerChip:active {
|
||||||
|
transform: translateY(1px) scale(0.97);
|
||||||
|
transition-duration: 0.04s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The action bar already presses down; the brightness is what it was missing, and
|
||||||
|
it's the one place a dealer taps without looking. */
|
||||||
|
.actionBtn:active:not(:disabled) {
|
||||||
|
filter: brightness(1.3);
|
||||||
|
transition-duration: 0.04s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seats deliberately have no `:active` rule. They already dip via `.seatPressing`,
|
||||||
|
which `pointerdown` adds in render.js — that fires earlier than `:active` resolves,
|
||||||
|
and, more importantly, a seat's `transform` is what positions it around the felt
|
||||||
|
(`translate(-50%, -50%)` plus its own left/top). A press state setting `transform`
|
||||||
|
would drop that translate and throw every pressed seat about 38px off its spot. */
|
||||||
|
|
||||||
|
/* Nobody should get a stuck press state from a tap that turned into a scroll. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.holdemBtn:active:not(:disabled),
|
||||||
|
.linkBtn:active:not(:disabled),
|
||||||
|
.seatMenuBtn:active:not(:disabled),
|
||||||
|
.seatSheetClose:active:not(:disabled),
|
||||||
|
.cardSlot:active,
|
||||||
|
.pickerCard:active:not(:disabled),
|
||||||
|
.dealerChip:active,
|
||||||
|
.raiseQuickBtn:active:not(:disabled),
|
||||||
|
.actionUndo:active:not(:disabled) {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#holdemApp {
|
#holdemApp {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -85,10 +227,12 @@ body {
|
|||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.holdemBtn:hover {
|
@media (hover: hover) {
|
||||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
.holdemBtn:hover {
|
||||||
border-color: #4a4a4a;
|
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||||
transform: translateY(-1px);
|
border-color: #4a4a4a;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.holdemBtnAccent {
|
.holdemBtnAccent {
|
||||||
@@ -97,8 +241,10 @@ body {
|
|||||||
color: #ecffef;
|
color: #ecffef;
|
||||||
}
|
}
|
||||||
|
|
||||||
.holdemBtnAccent:hover {
|
@media (hover: hover) {
|
||||||
background: linear-gradient(180deg, #1a8a44, #0f602c);
|
.holdemBtnAccent:hover {
|
||||||
|
background: linear-gradient(180deg, #1a8a44, #0f602c);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.holdemBtnDanger {
|
.holdemBtnDanger {
|
||||||
@@ -107,8 +253,10 @@ body {
|
|||||||
color: #ffd6d6;
|
color: #ffd6d6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.holdemBtnDanger:hover {
|
@media (hover: hover) {
|
||||||
background: linear-gradient(180deg, #572525, #401818);
|
.holdemBtnDanger:hover {
|
||||||
|
background: linear-gradient(180deg, #572525, #401818);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.holdemBtnGhost {
|
.holdemBtnGhost {
|
||||||
@@ -158,8 +306,10 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-bottom: 72%;
|
padding-bottom: 72%;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#pokerTable {
|
#pokerTable {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 6%;
|
inset: 6%;
|
||||||
@@ -430,11 +580,16 @@ body {
|
|||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seat:hover .dealerChip,
|
|
||||||
.dealerChip:focus-visible {
|
.dealerChip:focus-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.seat:hover .dealerChip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dealerChip.active {
|
.dealerChip.active {
|
||||||
background: radial-gradient(circle at 35% 30%, #ffe9a8, #f5d547 55%, #cba428 100%);
|
background: radial-gradient(circle at 35% 30%, #ffe9a8, #f5d547 55%, #cba428 100%);
|
||||||
border-color: #a9782f;
|
border-color: #a9782f;
|
||||||
@@ -474,6 +629,13 @@ body {
|
|||||||
color: #fff7e6;
|
color: #fff7e6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A blind the dealer named by hand rather than one the button handed out. Without
|
||||||
|
the ring the table just looks like it's put the blinds in the wrong place. */
|
||||||
|
.blindBadge.blindManual {
|
||||||
|
outline: 1.5px solid #f2f2f2;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
|
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
|
||||||
|
|
||||||
.seatEditorPanel {
|
.seatEditorPanel {
|
||||||
@@ -567,9 +729,11 @@ body {
|
|||||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardSlot:hover {
|
@media (hover: hover) {
|
||||||
transform: translateY(-1px);
|
.cardSlot:hover {
|
||||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardSlot.cardRed {
|
.cardSlot.cardRed {
|
||||||
@@ -743,8 +907,10 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.linkBtn:hover {
|
@media (hover: hover) {
|
||||||
color: #d8b46a;
|
.linkBtn:hover {
|
||||||
|
color: #d8b46a;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#rulingsOverlay {
|
#rulingsOverlay {
|
||||||
@@ -909,9 +1075,11 @@ body {
|
|||||||
transition: all 0.12s ease;
|
transition: all 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seatMenuBtn:hover {
|
@media (hover: hover) {
|
||||||
border-color: #4a4a4a;
|
.seatMenuBtn:hover {
|
||||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
border-color: #4a4a4a;
|
||||||
|
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.seatMenuBtn.menuDanger {
|
.seatMenuBtn.menuDanger {
|
||||||
@@ -1037,8 +1205,10 @@ body {
|
|||||||
transition: transform 0.1s ease;
|
transition: transform 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pickerCard:hover:not(:disabled) {
|
@media (hover: hover) {
|
||||||
transform: translateY(-1px);
|
.pickerCard:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pickerCard.cardRed {
|
.pickerCard.cardRed {
|
||||||
@@ -1217,8 +1387,10 @@ body {
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subPanel summary:hover {
|
@media (hover: hover) {
|
||||||
color: #c5c5c5;
|
.subPanel summary:hover {
|
||||||
|
color: #c5c5c5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.subPanelBody {
|
.subPanelBody {
|
||||||
@@ -1395,6 +1567,10 @@ body {
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
/* iOS zooms the whole page in when you focus a field under 16px, and the dealer
|
||||||
|
is then pinching back out to see the table. Every other input here is already
|
||||||
|
at 16px; this one was the straggler. */
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Pot wizard ---------- */
|
/* ---------- Pot wizard ---------- */
|
||||||
@@ -1456,10 +1632,12 @@ body.hasActionBar #holdemSidebar {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionUndo:hover {
|
@media (hover: hover) {
|
||||||
background: #333;
|
.actionUndo:hover {
|
||||||
border-color: #4f4f4f;
|
background: #333;
|
||||||
color: #eee;
|
border-color: #4f4f4f;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionStreet {
|
.actionStreet {
|
||||||
@@ -1600,9 +1778,11 @@ body.hasActionBar #holdemSidebar {
|
|||||||
transition: all 0.12s ease;
|
transition: all 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionBtn:hover {
|
@media (hover: hover) {
|
||||||
border-color: #4a4a4a;
|
.actionBtn:hover {
|
||||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
border-color: #4a4a4a;
|
||||||
|
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionBtn:active {
|
.actionBtn:active {
|
||||||
@@ -1743,6 +1923,24 @@ body.hasActionBar #holdemSidebar {
|
|||||||
margin-top: 0.1rem;
|
margin-top: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tournament-sized amounts. A number has no spaces to break on, so an amount too
|
||||||
|
wide for the 64px puck neither wraps nor ellipsises — it draws straight out over
|
||||||
|
the seat next to it. These step the size down so every digit stays on its own
|
||||||
|
puck. Shrinking rather than abbreviating is deliberate: "$1.25M" would fit more
|
||||||
|
comfortably, but a dealer reading a rounded stack off the felt is a worse failure
|
||||||
|
than a small one, and the exact figure is what the tool is for. */
|
||||||
|
.seatPuckStack.puckAmountLong,
|
||||||
|
.seatPuckTotal.puckAmountLong {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatPuckStack.puckAmountHuge,
|
||||||
|
.seatPuckTotal.puckAmountHuge {
|
||||||
|
font-size: 0.44rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
.blindsRow {
|
.blindsRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1784,8 +1982,13 @@ body.hasActionBar #holdemSidebar {
|
|||||||
|
|
||||||
/* Sit above the action bar rather than on top of it, so an Undo never covers the
|
/* Sit above the action bar rather than on top of it, so an Undo never covers the
|
||||||
Fold button the dealer is reaching for. */
|
Fold button the dealer is reaching for. */
|
||||||
|
/* Sit the toast directly on top of the action bar, whatever height it happens to be.
|
||||||
|
This was a hardcoded 9rem, which is roughly right for the bar at rest and wrong the
|
||||||
|
moment the raise pad opens — and on a short phone it put the toast, Undo button and
|
||||||
|
all, straight over the bottom seats of the table, so a tap meant for a player hit
|
||||||
|
"Undo" instead. */
|
||||||
body.hasActionBar #toastHost {
|
body.hasActionBar #toastHost {
|
||||||
bottom: calc(9rem + env(safe-area-inset-bottom, 0px));
|
bottom: calc(var(--actionBarHeight, 9rem) + 0.75rem + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
@@ -1831,8 +2034,10 @@ body.hasActionBar #toastHost {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toastAction:hover {
|
@media (hover: hover) {
|
||||||
background: rgba(216, 180, 106, 0.22);
|
.toastAction:hover {
|
||||||
|
background: rgba(216, 180, 106, 0.22);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.toastClose {
|
.toastClose {
|
||||||
@@ -1848,9 +2053,11 @@ body.hasActionBar #toastHost {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toastClose:hover {
|
@media (hover: hover) {
|
||||||
background: #333;
|
.toastClose:hover {
|
||||||
color: #ddd;
|
background: #333;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Confirmation dialog ---------- */
|
/* ---------- Confirmation dialog ---------- */
|
||||||
@@ -1985,9 +2192,11 @@ body.hasActionBar #toastHost {
|
|||||||
transition: border-color 0.12s ease, background 0.12s ease;
|
transition: border-color 0.12s ease, background 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.potWizardChoice:hover {
|
@media (hover: hover) {
|
||||||
border-color: #555;
|
.potWizardChoice:hover {
|
||||||
background: #191919;
|
border-color: #555;
|
||||||
|
background: #191919;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.potWizardChoice input {
|
.potWizardChoice input {
|
||||||
@@ -2110,8 +2319,13 @@ body.hasActionBar #toastHost {
|
|||||||
seat looks like it did nothing. Inset the ring by half a puck, and float the
|
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. */
|
editor up as a bottom sheet over the table. */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
|
/* The bottom padding has to clear the fixed action bar, plus room for a toast to
|
||||||
|
sit above it. Without it the table just carries on underneath the bar and the
|
||||||
|
lower seats of the oval are behind the Fold button — measured at 360x640, where
|
||||||
|
62px of felt was covered. `--actionBarHeight` is published by actionBar.js and
|
||||||
|
is 0 when no hand is running, so the page doesn't hold a gap it doesn't need. */
|
||||||
#holdemApp {
|
#holdemApp {
|
||||||
padding: 1rem 0.75rem 2rem;
|
padding: 1rem 0.75rem calc(var(--actionBarHeight, 0px) + 5rem + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
#holdemHeader {
|
#holdemHeader {
|
||||||
@@ -2383,3 +2597,96 @@ body.hasActionBar #toastHost {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- A phone on its side ----------
|
||||||
|
Deliberately last in the file. The felt is an aspect-ratio box whose height comes
|
||||||
|
from `padding-bottom`, and the `max-width: 900px` block above re-sets that to 100%
|
||||||
|
— so a landscape rule placed any earlier is overridden at equal specificity and
|
||||||
|
does nothing, which is exactly what happened on the first attempt.
|
||||||
|
|
||||||
|
Landscape is where this layout falls apart worst: sized from width, the table came
|
||||||
|
out 765px tall in a 390px-tall window, putting six of the nine seats below the fold
|
||||||
|
and the rest behind the action bar. Sizing it from the height that's actually left
|
||||||
|
makes every seat reachable without scrolling. The table ends up small, but a seat
|
||||||
|
you have to go hunting for is worse than a seat that's small. */
|
||||||
|
@media (orientation: landscape) and (max-height: 560px) {
|
||||||
|
/* Set outright rather than via padding-bottom: a percentage padding resolves
|
||||||
|
against the containing block's width, not the element's own, so narrowing the
|
||||||
|
wrap on its own left it full height regardless. */
|
||||||
|
#pokerTableWrap {
|
||||||
|
--feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 8.5rem);
|
||||||
|
width: min(100%, calc(var(--feltRoom) / 0.72));
|
||||||
|
height: var(--feltRoom);
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The header ate 148px of a 390px-tall window, which was most of the reason the
|
||||||
|
table started below the fold. Here it only needs to be a title bar. */
|
||||||
|
#holdemHeader {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#holdemHeader h1 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
padding-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holdemSubtitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* There's no vertical room left, but plenty either side of a table this narrow,
|
||||||
|
so the toast moves out of the middle and sits beside the felt instead of on
|
||||||
|
top of it. Centred, it was covering up to six seats at once — and it carries
|
||||||
|
an Undo button, so a tap meant for a player was landing on "Undo". */
|
||||||
|
body.hasActionBar #toastHost,
|
||||||
|
#toastHost {
|
||||||
|
left: auto;
|
||||||
|
right: 0.5rem;
|
||||||
|
max-width: 17rem;
|
||||||
|
bottom: calc(var(--actionBarHeight, 0px) + 0.5rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nine seats around a table this small overlap each other, so they shrink to suit. */
|
||||||
|
.seat {
|
||||||
|
width: 62px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Short phones, upright ----------
|
||||||
|
A 640px-tall screen spends ~150px on the header before the felt even starts, which
|
||||||
|
left the bottom of the table sitting behind the action bar until you scrolled. The
|
||||||
|
header carries no information a dealer needs mid-hand, so it gives the space up. */
|
||||||
|
@media (max-height: 700px) {
|
||||||
|
/* Nowhere on a screen this short is the toast clear of both the table and the
|
||||||
|
action bar, so it overlaps the one that costs least: it sits over the bar's
|
||||||
|
status line, which is information a dealer can re-read a second later, rather
|
||||||
|
than over the seats and buttons, which are things they need to hit. Overlapping
|
||||||
|
a readout beats covering a control. */
|
||||||
|
body.hasActionBar #toastHost {
|
||||||
|
bottom: calc(var(--actionBarHeight, 0px) - 3.1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) and (max-height: 700px) {
|
||||||
|
#holdemHeader {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#holdemHeader h1 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
padding-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holdemSubtitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,58 @@ body {
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same touch handling as the dealer table — see the note there. This page is the one
|
||||||
|
a dealer opens mid-hand to chop a pot, so the ~300ms the browser spends deciding
|
||||||
|
whether a tap was a double-tap-to-zoom is the last thing it needs. */
|
||||||
|
button,
|
||||||
|
[role="button"],
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
a {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
[role="button"],
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
a {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Not on the fields — a dealer typing a pot amount still needs to select it. */
|
||||||
|
button,
|
||||||
|
[role="button"] {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearPotBtn:active:not(:disabled),
|
||||||
|
.choiceChip:active:not(:disabled),
|
||||||
|
.stepBtn:active:not(:disabled),
|
||||||
|
.splitBtn:active:not(:disabled),
|
||||||
|
.splitBtnGhost:active:not(:disabled),
|
||||||
|
.unevenFoldBtn:active:not(:disabled),
|
||||||
|
.unevenRemoveBtn:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
filter: brightness(1.25);
|
||||||
|
transition-duration: 0.04s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.clearPotBtn:active:not(:disabled),
|
||||||
|
.choiceChip:active:not(:disabled),
|
||||||
|
.stepBtn:active:not(:disabled),
|
||||||
|
.splitBtn:active:not(:disabled),
|
||||||
|
.splitBtnGhost:active:not(:disabled),
|
||||||
|
.unevenFoldBtn:active:not(:disabled),
|
||||||
|
.unevenRemoveBtn:active:not(:disabled) {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#splitApp {
|
#splitApp {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -336,9 +388,11 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clearPotBtn:hover {
|
@media (hover: hover) {
|
||||||
background: #333;
|
.clearPotBtn:hover {
|
||||||
color: #ddd;
|
background: #333;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Tap targets ---------- */
|
/* ---------- Tap targets ---------- */
|
||||||
@@ -364,9 +418,11 @@ body {
|
|||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.choiceChip:hover {
|
@media (hover: hover) {
|
||||||
border-color: #4a4a4a;
|
.choiceChip:hover {
|
||||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
border-color: #4a4a4a;
|
||||||
|
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.choiceChip.selected {
|
.choiceChip.selected {
|
||||||
@@ -401,8 +457,10 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepBtn:hover {
|
@media (hover: hover) {
|
||||||
background: #333;
|
.stepBtn:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.waysReadout {
|
.waysReadout {
|
||||||
@@ -439,9 +497,11 @@ body {
|
|||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitBtn:hover {
|
@media (hover: hover) {
|
||||||
border-color: #4a4a4a;
|
.splitBtn:hover {
|
||||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
border-color: #4a4a4a;
|
||||||
|
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitBtnGhost {
|
.splitBtnGhost {
|
||||||
|
|||||||
@@ -11,9 +11,35 @@ const ActionBar = {
|
|||||||
mount(state, hooks) {
|
mount(state, hooks) {
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.hooks = hooks;
|
this.hooks = hooks;
|
||||||
|
this.watchHeight();
|
||||||
this.render();
|
this.render();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The bar is fixed to the bottom of the screen, so everything else has to know how
|
||||||
|
// tall it is or the table simply runs underneath it — on a 640px-tall phone the
|
||||||
|
// bottom seats of the oval were sitting behind the Fold button. Its height isn't a
|
||||||
|
// constant either: opening the raise pad grows it. Publishing the measured height
|
||||||
|
// as a custom property lets the layout and the toast both stay clear of it without
|
||||||
|
// anyone guessing a number.
|
||||||
|
watchHeight() {
|
||||||
|
const host = document.getElementById("actionBar");
|
||||||
|
if (!host || this.heightWatcher) return;
|
||||||
|
|
||||||
|
const publish = () => {
|
||||||
|
const visible = !host.hidden;
|
||||||
|
const height = visible ? host.getBoundingClientRect().height : 0;
|
||||||
|
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof ResizeObserver === "function") {
|
||||||
|
this.heightWatcher = new ResizeObserver(publish);
|
||||||
|
this.heightWatcher.observe(host);
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", publish);
|
||||||
|
window.addEventListener("orientationchange", publish);
|
||||||
|
publish();
|
||||||
|
},
|
||||||
|
|
||||||
// Anything to act on? A hand with nobody dealt in doesn't get a bar.
|
// Anything to act on? A hand with nobody dealt in doesn't get a bar.
|
||||||
isRunning() {
|
isRunning() {
|
||||||
return Betting.liveSeats(this.state).length > 0;
|
return Betting.liveSeats(this.state).length > 0;
|
||||||
@@ -27,6 +53,7 @@ const ActionBar = {
|
|||||||
if (!this.isRunning()) {
|
if (!this.isRunning()) {
|
||||||
host.hidden = true;
|
host.hidden = true;
|
||||||
document.body.classList.remove("hasActionBar");
|
document.body.classList.remove("hasActionBar");
|
||||||
|
document.documentElement.style.setProperty("--actionBarHeight", "0px");
|
||||||
this.stopTankTimer();
|
this.stopTankTimer();
|
||||||
this.tankSeatIndex = null;
|
this.tankSeatIndex = null;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -243,6 +243,28 @@ const Betting = {
|
|||||||
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
|
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// True while nothing has happened in this hand except the blinds going in. Up to
|
||||||
|
// that point the dealer can still move the button or rename the blinds and have
|
||||||
|
// it cost nothing — which is the state a home game spends a lot of time in,
|
||||||
|
// arguing about whose turn it is before the cards come out.
|
||||||
|
onlyBlindsIn(state) {
|
||||||
|
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pulls the blinds back off the table and posts them again from wherever the
|
||||||
|
// button and the blind assignment now say they belong. Refunding first is what
|
||||||
|
// keeps the chip count straight when the button gets passed round three or four
|
||||||
|
// times before anybody is happy with it.
|
||||||
|
repostBlinds(state) {
|
||||||
|
state.seats.forEach(player => {
|
||||||
|
const back = this.committed(player);
|
||||||
|
if (back > 0) player.stack = this.round(this.stackOf(state, player) + back);
|
||||||
|
player.committed = 0;
|
||||||
|
player.hasActed = false;
|
||||||
|
});
|
||||||
|
this.beginHand(state);
|
||||||
|
},
|
||||||
|
|
||||||
// Deals a fresh betting round: blinds posted, action to the seat after the big
|
// 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.
|
// blind, which is where a dealer expects to be looking when the cards go out.
|
||||||
beginHand(state) {
|
beginHand(state) {
|
||||||
@@ -250,6 +272,11 @@ const Betting = {
|
|||||||
state.currentBet = 0;
|
state.currentBet = 0;
|
||||||
state.lastRaiseSize = 0;
|
state.lastRaiseSize = 0;
|
||||||
|
|
||||||
|
// Position is read off the button all through the hand, so it has to be on
|
||||||
|
// somebody before the blinds are worked out — not on the empty seat 1 a
|
||||||
|
// brand-new table starts on.
|
||||||
|
HoldemState.ensureButtonOnPlayer(state);
|
||||||
|
|
||||||
state.seats.forEach(player => {
|
state.seats.forEach(player => {
|
||||||
player.committed = 0;
|
player.committed = 0;
|
||||||
player.hasActed = false;
|
player.hasActed = false;
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const HandHistoryExport = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Money columns go in as bare numbers. Written as "$1,250,000.00" they import as
|
||||||
|
// text, so the one thing anybody opens this file to do — total a column and see
|
||||||
|
// who owes what — doesn't work without cleaning every cell first. The commas were
|
||||||
|
// quoted correctly, so the file parsed; it just wouldn't add up.
|
||||||
toCsv(handHistory, standings = []) {
|
toCsv(handHistory, standings = []) {
|
||||||
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
||||||
// handHistory is stored newest-first; the export reads better oldest-first.
|
// handHistory is stored newest-first; the export reads better oldest-first.
|
||||||
@@ -25,7 +29,7 @@ const HandHistoryExport = {
|
|||||||
rows.push([
|
rows.push([
|
||||||
entry.handNumber,
|
entry.handNumber,
|
||||||
this.formatTimestamp(entry.timestamp),
|
this.formatTimestamp(entry.timestamp),
|
||||||
Money.format(entry.pot),
|
Money.forSheet(entry.pot),
|
||||||
payouts,
|
payouts,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -41,12 +45,12 @@ const HandHistoryExport = {
|
|||||||
standings.forEach(row => {
|
standings.forEach(row => {
|
||||||
rows.push([
|
rows.push([
|
||||||
row.name,
|
row.name,
|
||||||
Money.format(row.in),
|
Money.forSheet(row.in),
|
||||||
Money.format(row.won),
|
Money.forSheet(row.won),
|
||||||
Money.formatSigned(row.net),
|
Money.forSheet(row.net),
|
||||||
Money.format(row.boughtIn || 0),
|
Money.forSheet(row.boughtIn || 0),
|
||||||
Money.format(row.chipsNow || 0),
|
Money.forSheet(row.chipsNow || 0),
|
||||||
Money.formatSigned(row.cashPosition || 0),
|
Money.forSheet(row.cashPosition || 0),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
|
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
|
||||||
|
onGiveButton: index => seatMenuHooks.makeDealer(index),
|
||||||
};
|
};
|
||||||
|
|
||||||
const seatMenuHooks = {
|
const seatMenuHooks = {
|
||||||
@@ -59,13 +60,71 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
unfold: index => seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; }),
|
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)),
|
allIn: index => seatAction(index, `${nameOf(index)} is all-in.`, () => Betting.allIn(state, index)),
|
||||||
toggleDealtIn: index => {
|
toggleDealtIn: index => {
|
||||||
const dealtIn = state.seats[index].inHand;
|
const player = state.seats[index];
|
||||||
seatAction(index, dealtIn ? `${nameOf(index)} sits out.` : `${nameOf(index)} is dealt in.`, p => {
|
const dealtIn = player.inHand;
|
||||||
|
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
|
||||||
|
const alreadyIn = Betting.handTotal(player);
|
||||||
|
|
||||||
|
// Somebody walking away mid-hand doesn't get their chips back — they're
|
||||||
|
// dead money the winner collects. Say so, because the pot total pointedly
|
||||||
|
// does not go down and a dealer needs to know that's deliberate.
|
||||||
|
const message = dealtIn
|
||||||
|
? (alreadyIn > 0
|
||||||
|
? `${nameOf(index)} is out — their ${Money.format(alreadyIn)} stays in the pot.`
|
||||||
|
: `${nameOf(index)} sits out.`)
|
||||||
|
: (handRunning
|
||||||
|
? `${nameOf(index)} is in from the next hand.`
|
||||||
|
: `${nameOf(index)} is dealt in.`);
|
||||||
|
|
||||||
|
seatAction(index, message, p => {
|
||||||
p.inHand = !dealtIn;
|
p.inHand = !dealtIn;
|
||||||
if (!p.inHand) p.folded = false;
|
if (!p.inHand) {
|
||||||
|
// Their chips stay put; only the fold flag is cleared, since being
|
||||||
|
// out of the hand already covers not winning it.
|
||||||
|
p.folded = false;
|
||||||
|
if (state.actingSeatIndex === index) Betting.passAction(state, index);
|
||||||
|
} else if (handRunning) {
|
||||||
|
// Joining a hand already in progress means sitting this one out —
|
||||||
|
// you can't buy into a pot you haven't been paying for. `folded`
|
||||||
|
// is exactly that state, and the next hand clears it.
|
||||||
|
p.folded = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { state.dealerSeatIndex = index; }),
|
|
||||||
|
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => {
|
||||||
|
state.dealerSeatIndex = index;
|
||||||
|
// Before anyone has acted the blinds are still just sitting there, so
|
||||||
|
// moving the button moves them too — a home game passes it round several
|
||||||
|
// times before the cards come out, and the posted chips have to follow.
|
||||||
|
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||||
|
}),
|
||||||
|
|
||||||
|
assignBlind: (index, which) => {
|
||||||
|
const blinds = HoldemState.getBlindSeats(state);
|
||||||
|
const other = which === "sb" ? blinds.bbIndex : blinds.sbIndex;
|
||||||
|
// Naming one blind pins the other where it already is, so the dealer only
|
||||||
|
// has to state the part they're changing. If that would land both on one
|
||||||
|
// player, the other blind moves to the next seat along.
|
||||||
|
let partner = other !== null && other !== undefined && other !== index
|
||||||
|
? other
|
||||||
|
: Betting.nextLiveIndex(state, index);
|
||||||
|
if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index);
|
||||||
|
|
||||||
|
const label = which === "sb" ? "small blind" : "big blind";
|
||||||
|
seatAction(index, `${nameOf(index)} posts the ${label}.`, () => {
|
||||||
|
state.blindOverride = which === "sb"
|
||||||
|
? { sbIndex: index, bbIndex: partner }
|
||||||
|
: { sbIndex: partner, bbIndex: index };
|
||||||
|
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => {
|
||||||
|
state.blindOverride = null;
|
||||||
|
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||||
|
}),
|
||||||
|
|
||||||
editSeat: index => Render.toggleSeatEditor(index),
|
editSeat: index => Render.toggleSeatEditor(index),
|
||||||
|
|
||||||
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
|
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
|
||||||
|
|||||||
@@ -25,9 +25,27 @@ const Money = {
|
|||||||
return this.toCents(amount) / 100;
|
return this.toCents(amount) / 100;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Cents only when there are cents.
|
||||||
|
//
|
||||||
|
// A chip game deals in whole numbers nearly all the time, and a trailing ".00"
|
||||||
|
// is two characters that say nothing — measured at ~22px, against the 64px a
|
||||||
|
// seat puck has to show a stack in. "$1,250,000.00" doesn't fit and wraps;
|
||||||
|
// "$1,250,000" does. Amounts that genuinely carry cents still show them, so a
|
||||||
|
// 25c-blind game reads exactly as it did.
|
||||||
format(amount) {
|
format(amount) {
|
||||||
const value = Number(amount) || 0;
|
const value = Number(amount) || 0;
|
||||||
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
const hasCents = this.toCents(value) % 100 !== 0;
|
||||||
|
return `$${value.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: hasCents ? 2 : 0,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// The same amount for a spreadsheet rather than for a person: no currency symbol
|
||||||
|
// and no thousands separators, because those are what make a column of money
|
||||||
|
// import as text and refuse to add up.
|
||||||
|
forSheet(amount) {
|
||||||
|
return (this.toCents(amount) / 100).toFixed(2);
|
||||||
},
|
},
|
||||||
|
|
||||||
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ const PotWizard = {
|
|||||||
const contributions = players.map(p => ({
|
const contributions = 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,
|
// A seat switched off mid-hand still has its chips in the pot, but it
|
||||||
|
// can no more win them than a folded one can — same dead money, and the
|
||||||
|
// wizard must not offer it as a winner.
|
||||||
|
folded: p.folded || HoldemState.isDeadMoney(p),
|
||||||
}));
|
}));
|
||||||
this.entries = PotCalculator.buildEntries(contributions);
|
this.entries = PotCalculator.buildEntries(contributions);
|
||||||
// Kept so the wizard can show the whole breakdown up front, not just the layer
|
// Kept so the wizard can show the whole breakdown up front, not just the layer
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ const Render = {
|
|||||||
const layer = document.getElementById("seatsLayer");
|
const layer = document.getElementById("seatsLayer");
|
||||||
layer.innerHTML = "";
|
layer.innerHTML = "";
|
||||||
const template = document.getElementById("seatTemplate");
|
const template = document.getElementById("seatTemplate");
|
||||||
const { dealerIndex, sbIndex, bbIndex } = HoldemState.getBlindSeats(this.state);
|
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
|
||||||
|
|
||||||
this.state.seats.forEach((player, index) => {
|
this.state.seats.forEach((player, index) => {
|
||||||
const node = template.content.firstElementChild.cloneNode(true);
|
const node = template.content.firstElementChild.cloneNode(true);
|
||||||
@@ -172,15 +172,26 @@ const Render = {
|
|||||||
const dealerChip = node.querySelector(".dealerChip");
|
const dealerChip = node.querySelector(".dealerChip");
|
||||||
dealerChip.classList.toggle("active", index === dealerIndex);
|
dealerChip.classList.toggle("active", index === dealerIndex);
|
||||||
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
|
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
|
||||||
|
// Tapping the puck is the same act as "Give the button" in the seat menu,
|
||||||
|
// so it goes through the same hook — otherwise this one path would move
|
||||||
|
// the button without moving the blinds that follow it.
|
||||||
dealerChip.addEventListener("click", e => {
|
dealerChip.addEventListener("click", e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.state.dealerSeatIndex = index;
|
this.hooks.onGiveButton(index);
|
||||||
this.hooks.save(this.state);
|
|
||||||
this.renderSeats();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
node.querySelector(".sbBadge").classList.toggle("visible", index === sbIndex);
|
const sbBadge = node.querySelector(".sbBadge");
|
||||||
node.querySelector(".bbBadge").classList.toggle("visible", index === bbIndex);
|
const bbBadge = node.querySelector(".bbBadge");
|
||||||
|
sbBadge.classList.toggle("visible", index === sbIndex);
|
||||||
|
bbBadge.classList.toggle("visible", index === bbIndex);
|
||||||
|
// A blind the dealer named by hand isn't where the button would have put
|
||||||
|
// it, so it's marked — otherwise the table looks like it's just wrong.
|
||||||
|
// The `!!` matters: getBlindSeats leaves `manual` undefined when the
|
||||||
|
// blinds are positional, and classList.toggle with an undefined second
|
||||||
|
// argument *flips* the class instead of clearing it, so the marker
|
||||||
|
// appeared and disappeared on alternate renders.
|
||||||
|
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
|
||||||
|
bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex);
|
||||||
|
|
||||||
this.updatePuckContent(node, player, index);
|
this.updatePuckContent(node, player, index);
|
||||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||||
@@ -250,11 +261,14 @@ const Render = {
|
|||||||
const total = handTotal > 0
|
const total = handTotal > 0
|
||||||
? handTotal
|
? 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) : "";
|
const totalEl = node.querySelector(".seatPuckTotal");
|
||||||
|
totalEl.textContent = total > 0 ? Money.format(total) : "";
|
||||||
|
this.fitAmount(totalEl);
|
||||||
|
|
||||||
const stackEl = node.querySelector(".seatPuckStack");
|
const stackEl = node.querySelector(".seatPuckStack");
|
||||||
const stack = Betting.stackOf(this.state, player);
|
const stack = Betting.stackOf(this.state, player);
|
||||||
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
|
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
|
||||||
|
this.fitAmount(stackEl);
|
||||||
|
|
||||||
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
||||||
node.classList.toggle("seatEmpty", empty);
|
node.classList.toggle("seatEmpty", empty);
|
||||||
@@ -263,6 +277,17 @@ const Render = {
|
|||||||
node.classList.toggle("seatLive", player.inHand && !player.folded);
|
node.classList.toggle("seatLive", player.inHand && !player.folded);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// A seat puck is 64px of usable width. A tournament stack doesn't fit in that and,
|
||||||
|
// because the number has no spaces to break on, it doesn't wrap or ellipsise
|
||||||
|
// either — it just draws straight out over the neighbouring seat. Stepping the
|
||||||
|
// font down keeps every digit, which matters: a dealer misreading a stack is a
|
||||||
|
// worse outcome than a small one, so this shrinks rather than abbreviating.
|
||||||
|
fitAmount(el) {
|
||||||
|
const length = (el.textContent || "").length;
|
||||||
|
el.classList.toggle("puckAmountLong", length >= 8 && length < 10);
|
||||||
|
el.classList.toggle("puckAmountHuge", length >= 10);
|
||||||
|
},
|
||||||
|
|
||||||
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
|
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
|
||||||
// (which live in a separate panel) never lose focus.
|
// (which live in a separate panel) never lose focus.
|
||||||
refreshPuck(index) {
|
refreshPuck(index) {
|
||||||
|
|||||||
@@ -45,6 +45,24 @@ const SeatMenu = {
|
|||||||
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Naming a blind by hand. Only offered while nothing but the blinds are in —
|
||||||
|
// once somebody has called, moving a blind would mean unpicking a bet that
|
||||||
|
// has already been answered, and that's a decision for the dealer's mouth,
|
||||||
|
// not a menu item. Which blind a seat already has is shown rather than
|
||||||
|
// offered again.
|
||||||
|
if (dealtIn && Betting.onlyBlindsIn(this.state)) {
|
||||||
|
const blinds = HoldemState.getBlindSeats(this.state);
|
||||||
|
if (blinds.sbIndex !== index) {
|
||||||
|
actions.push({ label: "Make small blind", run: () => this.hooks.assignBlind(index, "sb") });
|
||||||
|
}
|
||||||
|
if (blinds.bbIndex !== index) {
|
||||||
|
actions.push({ label: "Make big blind", run: () => this.hooks.assignBlind(index, "bb") });
|
||||||
|
}
|
||||||
|
if (this.state.blindOverride) {
|
||||||
|
actions.push({ label: "Blinds follow the button again", run: () => this.hooks.clearBlindOverride() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
||||||
return actions;
|
return actions;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ const HoldemState = {
|
|||||||
currentBet: 0,
|
currentBet: 0,
|
||||||
lastRaiseSize: 0,
|
lastRaiseSize: 0,
|
||||||
actingSeatIndex: null,
|
actingSeatIndex: null,
|
||||||
|
// Set when the dealer names the blinds by hand instead of letting them
|
||||||
|
// follow the button — { sbIndex, bbIndex }. Home games do this constantly
|
||||||
|
// ("you had it last time", someone arriving late owing a blind), and the
|
||||||
|
// button order is the wrong answer often enough that it has to be
|
||||||
|
// overridable. Cleared at the start of every hand.
|
||||||
|
blindOverride: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -73,7 +79,8 @@ const HoldemState = {
|
|||||||
// Tables saved before live betting existed are missing its fields; fill
|
// Tables saved before live betting existed are missing its fields; fill
|
||||||
// them in rather than making the dealer reset a table mid-session.
|
// them in rather than making the dealer reset a table mid-session.
|
||||||
const defaults = this.createDefault();
|
const defaults = this.createDefault();
|
||||||
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize", "actingSeatIndex"]
|
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
|
||||||
|
"actingSeatIndex", "blindOverride"]
|
||||||
.forEach(key => {
|
.forEach(key => {
|
||||||
if (parsed[key] === undefined) parsed[key] = defaults[key];
|
if (parsed[key] === undefined) parsed[key] = defaults[key];
|
||||||
});
|
});
|
||||||
@@ -124,9 +131,14 @@ const HoldemState = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Contribution the table calculator will treat this player as having put in.
|
// Contribution the table calculator will treat this player as having put in.
|
||||||
|
//
|
||||||
|
// Chips already pushed into the middle count whether or not the player is still
|
||||||
|
// in the hand. Someone who storms off, gets taken out by the dealer, or is
|
||||||
|
// dealt out mid-hand leaves their money on the felt — it's dead money owed to
|
||||||
|
// whoever wins, and it is not theirs to take back. Reading `inHand` first is
|
||||||
|
// how those chips used to disappear out of the pot the instant a seat was
|
||||||
|
// switched off, quietly shrinking the payout by whatever they had posted.
|
||||||
computeContribution(player, chipLegend, basePotAmount) {
|
computeContribution(player, chipLegend, basePotAmount) {
|
||||||
if (!player.inHand) return 0;
|
|
||||||
|
|
||||||
const override = player.amountOverride;
|
const override = player.amountOverride;
|
||||||
if (override !== null && override !== undefined && String(override).trim() !== "") {
|
if (override !== null && override !== undefined && String(override).trim() !== "") {
|
||||||
const parsed = Number(override);
|
const parsed = Number(override);
|
||||||
@@ -139,10 +151,22 @@ const HoldemState = {
|
|||||||
}, 0);
|
}, 0);
|
||||||
if (chipTotal > 0) return chipTotal;
|
if (chipTotal > 0) return chipTotal;
|
||||||
|
|
||||||
|
// The table-wide default is the one thing that stays tied to being dealt in:
|
||||||
|
// it's a stand-in for "everyone put the same amount in", so applying it to
|
||||||
|
// empty chairs would invent a contribution from every seat nobody is using.
|
||||||
|
if (!player.inHand) return 0;
|
||||||
|
|
||||||
const base = Number(basePotAmount);
|
const base = Number(basePotAmount);
|
||||||
return Number.isNaN(base) ? 0 : Math.max(0, base);
|
return Number.isNaN(base) ? 0 : Math.max(0, base);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Anyone whose chips are in the pot, whether or not they're still in the hand.
|
||||||
|
// A seat that's been switched off mid-hand is dead money: it counts towards the
|
||||||
|
// pot but can never be picked as a winner, exactly like a fold.
|
||||||
|
isDeadMoney(player) {
|
||||||
|
return !player.inHand;
|
||||||
|
},
|
||||||
|
|
||||||
totalPot(state) {
|
totalPot(state) {
|
||||||
return state.seats.reduce(
|
return state.seats.reduce(
|
||||||
(sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
|
(sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||||
@@ -188,12 +212,52 @@ const HoldemState = {
|
|||||||
return order;
|
return order;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// A button sitting on an empty chair is only cosmetic until you try to read
|
||||||
|
// position off it. On the very first hand of a fresh table it starts on seat 1
|
||||||
|
// whether or not anybody is sitting there, and a table that empties out around it
|
||||||
|
// leaves it stranded. Move it to a real player before a hand is dealt.
|
||||||
|
ensureButtonOnPlayer(state) {
|
||||||
|
if (state.seats[state.dealerSeatIndex] && state.seats[state.dealerSeatIndex].inHand) return;
|
||||||
|
for (let step = 0; step < state.seatCount; step++) {
|
||||||
|
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||||
|
if (state.seats[index].inHand) {
|
||||||
|
state.dealerSeatIndex = index;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// A hand-picked blind is only usable if that seat is actually in the hand — a
|
||||||
|
// dealer who names a blind and then deals that player out shouldn't have the
|
||||||
|
// table quietly post to nobody.
|
||||||
|
validBlindOverride(state) {
|
||||||
|
const o = state.blindOverride;
|
||||||
|
if (!o) return null;
|
||||||
|
const ok = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i] && state.seats[i].inHand;
|
||||||
|
if (!ok(o.sbIndex) || !ok(o.bbIndex) || o.sbIndex === o.bbIndex) return null;
|
||||||
|
return o;
|
||||||
|
},
|
||||||
|
|
||||||
// Who posts what. Heads-up is its own rule: with two players the button posts the
|
// Who posts what. Heads-up is its own rule: with two players the button posts the
|
||||||
// small blind, which is the opposite of the usual order.
|
// small blind, which is the opposite of the usual order.
|
||||||
getBlindSeats(state) {
|
getBlindSeats(state) {
|
||||||
const live = this.liveOrderFromButton(state);
|
const live = this.liveOrderFromButton(state);
|
||||||
const dealerIndex = state.dealerSeatIndex;
|
const dealerIndex = state.dealerSeatIndex;
|
||||||
|
|
||||||
|
// A blind the dealer named by hand wins over anything positional. Heads-up is
|
||||||
|
// still reported honestly so the betting order stays right — who posts what
|
||||||
|
// is the dealer's call, but two-handed play acting in reverse is a rule.
|
||||||
|
const override = this.validBlindOverride(state);
|
||||||
|
if (override) {
|
||||||
|
return {
|
||||||
|
dealerIndex,
|
||||||
|
sbIndex: override.sbIndex,
|
||||||
|
bbIndex: override.bbIndex,
|
||||||
|
headsUp: live.length === 2,
|
||||||
|
manual: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (live.length < 2) {
|
if (live.length < 2) {
|
||||||
return { dealerIndex, sbIndex: null, bbIndex: null, headsUp: false };
|
return { dealerIndex, sbIndex: null, bbIndex: null, headsUp: false };
|
||||||
}
|
}
|
||||||
@@ -210,6 +274,10 @@ const HoldemState = {
|
|||||||
|
|
||||||
startNextHand(state) {
|
startNextHand(state) {
|
||||||
this.advanceButton(state);
|
this.advanceButton(state);
|
||||||
|
// A hand-picked blind applies to the hand it was picked for. Carrying it into
|
||||||
|
// the next one would freeze the blinds on two players for the rest of the
|
||||||
|
// night, which is the opposite of what naming them once is for.
|
||||||
|
state.blindOverride = null;
|
||||||
state.seats.forEach(p => {
|
state.seats.forEach(p => {
|
||||||
p.folded = false;
|
p.folded = false;
|
||||||
p.cards = ["", ""];
|
p.cards = ["", ""];
|
||||||
@@ -218,6 +286,8 @@ const HoldemState = {
|
|||||||
p.committed = 0;
|
p.committed = 0;
|
||||||
p.hasActed = false;
|
p.hasActed = false;
|
||||||
});
|
});
|
||||||
|
// Anyone who joined mid-hand is a full player again from here.
|
||||||
|
this.ensureButtonOnPlayer(state);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Appends a resolved-pot record to the running session log a dealer can point to
|
// Appends a resolved-pot record to the running session log a dealer can point to
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const Money = load("money");
|
|||||||
const PotCalculator = load("potCalculator");
|
const PotCalculator = load("potCalculator");
|
||||||
const SplitCalculator = load("splitCalculator");
|
const SplitCalculator = load("splitCalculator");
|
||||||
const Betting = load("betting");
|
const Betting = load("betting");
|
||||||
|
const HandHistoryExport = load("handHistoryExport");
|
||||||
|
|
||||||
// state.js exports `HoldemState`, which doesn't match its filename.
|
// state.js exports `HoldemState`, which doesn't match its filename.
|
||||||
const HoldemState = require(path.join(SRC, "state.js"));
|
const HoldemState = require(path.join(SRC, "state.js"));
|
||||||
@@ -68,6 +69,7 @@ function contributions(state) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
Money,
|
Money,
|
||||||
|
HandHistoryExport,
|
||||||
PotCalculator,
|
PotCalculator,
|
||||||
SplitCalculator,
|
SplitCalculator,
|
||||||
Betting,
|
Betting,
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
// Home games are messy. People walk up mid-hand, storm off mid-hand, argue about
|
||||||
|
// whose blind it is, and pass the button round three times before anyone deals.
|
||||||
|
// These are the cases the positional rules quietly get wrong.
|
||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Betting, HoldemState, PotCalculator, Money, makeTable, chipsOnTable, contributions } = require("./load");
|
||||||
|
|
||||||
|
const potOf = state => HoldemState.totalPot(state);
|
||||||
|
|
||||||
|
describe("a player leaves in the middle of a hand", () => {
|
||||||
|
test("their chips stay in the pot", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
const before = potOf(state);
|
||||||
|
assert.equal(before, 8);
|
||||||
|
|
||||||
|
state.seats[1].inHand = false; // dealer takes them out
|
||||||
|
|
||||||
|
assert.equal(potOf(state), before, "the pot must not shrink when a player is taken out");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("they cannot be paid out of a pot they walked away from", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
state.seats[1].inHand = false;
|
||||||
|
|
||||||
|
const players = state.seats
|
||||||
|
.filter(p => HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount) > 0)
|
||||||
|
.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||||
|
folded: p.folded || HoldemState.isDeadMoney(p),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const layers = PotCalculator.layers(players);
|
||||||
|
assert.ok(!layers[0].eligible.includes(state.seats[1].id), "a departed player can't win");
|
||||||
|
assert.equal(layers.reduce((s, l) => s + l.amount, 0), 6, "but their money is still being played for");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("taking out the player who is to act moves the action on", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const acting = state.actingSeatIndex;
|
||||||
|
|
||||||
|
state.seats[acting].inHand = false;
|
||||||
|
Betting.passAction(state, acting);
|
||||||
|
|
||||||
|
assert.notEqual(state.actingSeatIndex, acting, "the table can't wait on somebody who left");
|
||||||
|
assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty chair still contributes nothing, base pot or not", () => {
|
||||||
|
// The table-wide default must not be read as an ante from seats nobody is in.
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], dealerSeatIndex: 0 });
|
||||||
|
state.basePotAmount = 25;
|
||||||
|
assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, 25), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a player joins in the middle of a hand", () => {
|
||||||
|
test("they sit the hand out rather than joining a pot they never paid into", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
// Walks up and is dealt in — the app marks them folded for this hand.
|
||||||
|
state.seats[4].inHand = true;
|
||||||
|
state.seats[4].stack = 100;
|
||||||
|
state.seats[4].folded = true;
|
||||||
|
|
||||||
|
assert.ok(!Betting.liveSeats(state).some(s => s.index === 4), "not live this hand");
|
||||||
|
assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, state.basePotAmount), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("they are a full player again on the next hand", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
state.seats[4].inHand = true;
|
||||||
|
state.seats[4].stack = 100;
|
||||||
|
state.seats[4].folded = true;
|
||||||
|
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[4].folded, false);
|
||||||
|
assert.ok(Betting.liveSeats(state).some(s => s.index === 4), "dealt in like anyone else");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("passing the button around before the cards come out", () => {
|
||||||
|
test("moving the button re-posts the blinds and keeps the chip count straight", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
const startingChips = chipsOnTable(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[1].committed, 1);
|
||||||
|
assert.equal(state.seats[2].committed, 2);
|
||||||
|
|
||||||
|
state.dealerSeatIndex = 2;
|
||||||
|
Betting.repostBlinds(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[1].committed, 0, "the old small blind got their chip back");
|
||||||
|
assert.equal(state.seats[3].committed, 1, "and the new one has posted");
|
||||||
|
assert.equal(state.seats[0].committed, 2);
|
||||||
|
assert.equal(chipsOnTable(state), startingChips, "no chips created or lost in the shuffle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passing it round many times still balances", () => {
|
||||||
|
const state = makeTable({ seatCount: 8, seated: [0, 2, 3, 5, 7], dealerSeatIndex: 0 });
|
||||||
|
const startingChips = chipsOnTable(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
[2, 5, 7, 3, 0, 5].forEach(seat => {
|
||||||
|
state.dealerSeatIndex = seat;
|
||||||
|
Betting.repostBlinds(state);
|
||||||
|
assert.equal(chipsOnTable(state), startingChips, `chips drifted after passing to seat ${seat}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(Betting.committed(state.seats[sbIndex]), 1);
|
||||||
|
assert.equal(Betting.committed(state.seats[bbIndex]), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("once somebody has acted, the blinds are settled", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(Betting.onlyBlindsIn(state), true, "nothing but blinds so far");
|
||||||
|
|
||||||
|
Betting.call(state, 3);
|
||||||
|
assert.equal(Betting.onlyBlindsIn(state), false, "a call means the blinds are set where they are");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("naming the blinds by hand", () => {
|
||||||
|
test("a named blind wins over the button order", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||||
|
|
||||||
|
const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(sbIndex, 3);
|
||||||
|
assert.equal(bbIndex, 0);
|
||||||
|
assert.equal(manual, true);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(state.seats[3].committed, 1);
|
||||||
|
assert.equal(state.seats[0].committed, 2);
|
||||||
|
assert.equal(state.seats[1].committed, 0, "the positional small blind posts nothing");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a named blind on a player who isn't dealt in is ignored", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
state.blindOverride = { sbIndex: 4, bbIndex: 0 }; // seat 4 is an empty chair
|
||||||
|
|
||||||
|
const { sbIndex, manual } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.ok(!manual, "falls back to the button order rather than posting to nobody");
|
||||||
|
assert.ok(state.seats[sbIndex].inHand);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("both blinds on one player is refused", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
state.blindOverride = { sbIndex: 1, bbIndex: 1 };
|
||||||
|
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.notEqual(sbIndex, bbIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("naming blinds heads-up still acts in the right order", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
|
||||||
|
state.blindOverride = { sbIndex: 5, bbIndex: 2 }; // deliberately the reverse
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(state.seats[5].committed, 1);
|
||||||
|
assert.equal(state.seats[2].committed, 2);
|
||||||
|
assert.equal(state.actingSeatIndex, 5, "the small blind acts first pre-flop heads-up");
|
||||||
|
|
||||||
|
Betting.call(state, 5);
|
||||||
|
Betting.check(state, 2);
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
assert.equal(state.actingSeatIndex, 2, "and the big blind leads after the flop");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the naming only lasts the hand it was made for", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
assert.equal(state.blindOverride, null, "blinds go back to following the button");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the first hand on a fresh table", () => {
|
||||||
|
test("the button never starts on an empty chair", () => {
|
||||||
|
const state = HoldemState.createDefault();
|
||||||
|
HoldemState.resizeSeats(state, 9);
|
||||||
|
[4, 6, 7].forEach(i => { state.seats[i].inHand = true; state.seats[i].stack = 100; });
|
||||||
|
assert.equal(state.dealerSeatIndex, 0, "a fresh table starts the button on seat 1");
|
||||||
|
assert.equal(state.seats[0].inHand, false, "which nobody is sitting in");
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.ok(state.seats[state.dealerSeatIndex].inHand, "the button moved to a real player");
|
||||||
|
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.ok(state.seats[sbIndex].inHand);
|
||||||
|
assert.ok(state.seats[bbIndex].inHand);
|
||||||
|
assert.notEqual(sbIndex, bbIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a table that empties out around the button recovers", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 3 });
|
||||||
|
state.seats[3].inHand = false; // the player holding the button leaves
|
||||||
|
HoldemState.ensureButtonOnPlayer(state);
|
||||||
|
assert.ok(state.seats[state.dealerSeatIndex].inHand);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two players left after everyone else walks is heads-up, blinds reversed", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
[2, 3].forEach(i => { state.seats[i].inHand = false; });
|
||||||
|
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
const { sbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(headsUp, true);
|
||||||
|
assert.equal(sbIndex, state.dealerSeatIndex, "heads-up the button posts the small blind");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chips survive a messy table", () => {
|
||||||
|
test("joining, leaving and passing the button never changes the chip count", () => {
|
||||||
|
const state = makeTable({ seatCount: 8, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||||
|
state.seats[6].stack = 100; // sitting down but not dealt in yet
|
||||||
|
const startingChips = chipsOnTable(state);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
state.dealerSeatIndex = 2;
|
||||||
|
Betting.repostBlinds(state);
|
||||||
|
|
||||||
|
state.seats[6].inHand = true; // walks up mid-hand
|
||||||
|
state.seats[6].folded = true;
|
||||||
|
assert.equal(chipsOnTable(state), startingChips);
|
||||||
|
|
||||||
|
Betting.call(state, state.actingSeatIndex);
|
||||||
|
state.seats[1].inHand = false; // and someone storms off
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
assert.equal(chipsOnTable(state), startingChips, "every chip is still accounted for");
|
||||||
|
assert.equal(
|
||||||
|
Money.toCents(potOf(state) + state.seats.reduce((s, p) => s + (Number(p.stack) || 0), 0)),
|
||||||
|
Money.toCents(startingChips),
|
||||||
|
"and the pot plus the stacks is the whole table"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Money, HandHistoryExport } = require("./load");
|
||||||
|
|
||||||
|
describe("Money.format", () => {
|
||||||
|
test("whole amounts drop the pointless cents", () => {
|
||||||
|
assert.equal(Money.format(12), "$12");
|
||||||
|
assert.equal(Money.format(450), "$450");
|
||||||
|
assert.equal(Money.format(1250000), "$1,250,000");
|
||||||
|
assert.equal(Money.format(0), "$0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("amounts that carry cents still show them", () => {
|
||||||
|
assert.equal(Money.format(12.5), "$12.50");
|
||||||
|
assert.equal(Money.format(0.25), "$0.25");
|
||||||
|
assert.equal(Money.format(3.01), "$3.01");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a quarter-blind game reads exactly as it did", () => {
|
||||||
|
// The case that would be ruined by dropping cents unconditionally.
|
||||||
|
[0.25, 0.5, 1.75, 12.05].forEach(v => {
|
||||||
|
assert.match(Money.format(v), /\.\d\d$/, `${v} lost its cents`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("floating-point dust doesn't invent cents", () => {
|
||||||
|
assert.equal(Money.format(0.1 + 0.2), "$0.30");
|
||||||
|
assert.equal(Money.format(1.0000000001), "$1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("signed amounts put the sign out front, both scales", () => {
|
||||||
|
assert.equal(Money.formatSigned(87500), "+$87,500");
|
||||||
|
assert.equal(Money.formatSigned(-87500), "-$87,500");
|
||||||
|
assert.equal(Money.formatSigned(-12.5), "-$12.50");
|
||||||
|
assert.equal(Money.formatSigned(0), "$0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rubbish in doesn't produce rubbish out", () => {
|
||||||
|
assert.equal(Money.format(null), "$0");
|
||||||
|
assert.equal(Money.format(undefined), "$0");
|
||||||
|
assert.equal(Money.format("nonsense"), "$0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Money.forSheet", () => {
|
||||||
|
test("a bare number a spreadsheet will add up", () => {
|
||||||
|
assert.equal(Money.forSheet(1250000), "1250000.00");
|
||||||
|
assert.equal(Money.forSheet(12.5), "12.50");
|
||||||
|
assert.equal(Money.forSheet(-87500), "-87500.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no currency symbol and no thousands separators", () => {
|
||||||
|
const written = Money.forSheet(1234567.89);
|
||||||
|
assert.ok(!written.includes("$"), "a $ makes the cell text");
|
||||||
|
assert.ok(!written.includes(","), "a comma makes the cell text");
|
||||||
|
assert.equal(Number(written), 1234567.89, "and it parses straight back to a number");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the exported CSV", () => {
|
||||||
|
const history = [
|
||||||
|
{ handNumber: 2, timestamp: 0, pot: 1250000, payouts: [{ id: "a", label: "Alexandra", amount: 1250000 }] },
|
||||||
|
{ handNumber: 1, timestamp: 0, pot: 24.5, payouts: [{ id: "b", label: "Bob", amount: 24.5 }] },
|
||||||
|
];
|
||||||
|
const standings = [
|
||||||
|
{ id: "a", name: "Alexandra", in: 1250000, won: 1250000, net: 0, boughtIn: 3200000, chipsNow: 3200000, cashPosition: 0 },
|
||||||
|
{ id: "b", name: "Bob", in: 24.5, won: 0, net: -24.5, boughtIn: 100, chipsNow: 75.5, cashPosition: -24.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Splitting on commas isn't good enough: the timestamp column legitimately holds
|
||||||
|
// one ("1/1/1970, 5:00:00 PM") and is quoted for exactly that reason.
|
||||||
|
const splitRow = line => {
|
||||||
|
const fields = [];
|
||||||
|
let field = "";
|
||||||
|
let quoted = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const c = line[i];
|
||||||
|
if (quoted) {
|
||||||
|
if (c === '"' && line[i + 1] === '"') { field += '"'; i++; }
|
||||||
|
else if (c === '"') quoted = false;
|
||||||
|
else field += c;
|
||||||
|
} else if (c === '"') quoted = true;
|
||||||
|
else if (c === ",") { fields.push(field); field = ""; }
|
||||||
|
else field += c;
|
||||||
|
}
|
||||||
|
fields.push(field);
|
||||||
|
return fields;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("every money cell parses as a number", () => {
|
||||||
|
const csv = HandHistoryExport.toCsv(history, standings);
|
||||||
|
const lines = csv.split("\r\n");
|
||||||
|
|
||||||
|
lines.slice(1, 3).forEach(line => {
|
||||||
|
const cell = splitRow(line)[2]; // Pot
|
||||||
|
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `pot cell "${cell}" isn't a number`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Standings rows: every column after the name has to be numeric.
|
||||||
|
const standingsStart = lines.findIndex(l => l.startsWith("Player,"));
|
||||||
|
lines.slice(standingsStart + 1).filter(Boolean).forEach(line => {
|
||||||
|
splitRow(line).slice(1).forEach(cell => {
|
||||||
|
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `standings cell "${cell}" isn't a number`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a million-dollar pot doesn't need quoting any more", () => {
|
||||||
|
const csv = HandHistoryExport.toCsv(history, []);
|
||||||
|
assert.ok(csv.includes("1250000.00"), "written plainly");
|
||||||
|
assert.ok(!csv.includes('"$1,250,000'), "not as a quoted, unsummable string");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("names and payout descriptions stay human", () => {
|
||||||
|
const csv = HandHistoryExport.toCsv(history, standings);
|
||||||
|
assert.ok(csv.includes("Alexandra"), "the name column is still a name");
|
||||||
|
assert.ok(csv.includes("Alexandra: $1,250,000"), "the payout column still reads like a sentence");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a name with a comma is still quoted properly", () => {
|
||||||
|
const csv = HandHistoryExport.toCsv([], [
|
||||||
|
{ id: "x", name: "Smith, John", in: 0, won: 0, net: 0, boughtIn: 0, chipsNow: 0, cashPosition: 0 },
|
||||||
|
]);
|
||||||
|
assert.ok(csv.includes('"Smith, John"'), "the CSV escaping still applies where it's needed");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user