Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e94ffa6005 | ||
|
|
ae927983e7 | ||
|
|
4ceeed71a2 | ||
|
|
95c523738c | ||
|
|
1fcc058dd8 |
@@ -81,34 +81,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>
|
||||||
|
|
||||||
|
<details class="subPanel">
|
||||||
|
<summary>Without live betting</summary>
|
||||||
|
<div class="subPanelBody">
|
||||||
<label class="fieldRow">
|
<label class="fieldRow">
|
||||||
<span>Base pot amount ($)</span>
|
<span>Base pot amount ($)</span>
|
||||||
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
|
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
|
||||||
</label>
|
</label>
|
||||||
<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>
|
<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 +110,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>
|
||||||
@@ -294,6 +298,7 @@
|
|||||||
<script src="~/js/casino/holdem/seatMenu.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>
|
||||||
|
|||||||
@@ -872,6 +872,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;
|
||||||
@@ -1075,12 +1115,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;
|
||||||
@@ -1094,6 +1159,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;
|
||||||
@@ -1102,6 +1172,26 @@ 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 ---------- */
|
/* ---------- Side-pot breakdown ---------- */
|
||||||
|
|
||||||
.potBreakdown {
|
.potBreakdown {
|
||||||
@@ -1169,6 +1259,7 @@ body.hasActionBar #holdemSidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.actionBtn {
|
.actionBtn {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1206,6 +1297,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;
|
||||||
@@ -1316,11 +1430,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 ---------- */
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -46,6 +48,8 @@ const ActionBar = {
|
|||||||
} else {
|
} else {
|
||||||
host.appendChild(this.renderActions());
|
host.appendChild(this.renderActions());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.syncTankTimer();
|
||||||
},
|
},
|
||||||
|
|
||||||
renderStatus() {
|
renderStatus() {
|
||||||
@@ -62,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];
|
||||||
@@ -69,15 +81,101 @@ 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
|
// 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
|
// undivided pot needs no announcement, and a line that's always there stops being
|
||||||
// read.
|
// read.
|
||||||
renderPotBreakdown() {
|
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) => ({
|
const contributions = this.state.seats.map((player, index) => ({
|
||||||
id: player.id,
|
id: player.id,
|
||||||
index,
|
index,
|
||||||
@@ -96,6 +194,19 @@ const ActionBar = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 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";
|
||||||
@@ -106,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";
|
||||||
@@ -117,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();
|
||||||
@@ -128,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) {
|
||||||
@@ -210,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);
|
||||||
@@ -229,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;
|
||||||
|
|||||||
@@ -83,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 —
|
||||||
@@ -97,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();
|
||||||
|
|
||||||
@@ -177,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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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