Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf22e3153 | ||
|
|
f0994a8aed | ||
|
|
ddb3907ace | ||
|
|
3fe16fa7f2 |
@@ -294,7 +294,9 @@
|
|||||||
not during it.
|
not during it.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section class="ruling">
|
<nav id="rulingsIndex" class="rulingsIndex" aria-label="Jump to a ruling"></nav>
|
||||||
|
|
||||||
|
<section class="ruling" data-short="Misdeal">
|
||||||
<h3>Redeal it — misdeal</h3>
|
<h3>Redeal it — misdeal</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>The first card goes to the wrong seat.</li>
|
<li>The first card goes to the wrong seat.</li>
|
||||||
@@ -304,7 +306,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ruling">
|
<section class="ruling" data-short="Board card early">
|
||||||
<h3>A board card turned too early</h3>
|
<h3>A board card turned too early</h3>
|
||||||
<p>
|
<p>
|
||||||
If the fourth board card gets turned before the betting round is finished,
|
If the fourth board card gets turned before the betting round is finished,
|
||||||
@@ -313,7 +315,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ruling">
|
<section class="ruling" data-short="Out of turn">
|
||||||
<h3>Acting out of turn</h3>
|
<h3>Acting out of turn</h3>
|
||||||
<p>
|
<p>
|
||||||
A one-off is usually just backed up if no one has acted behind them.
|
A one-off is usually just backed up if no one has acted behind them.
|
||||||
@@ -321,7 +323,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ruling">
|
<section class="ruling" data-short="Short all-in">
|
||||||
<h3>A short all-in doesn't reopen the betting</h3>
|
<h3>A short all-in doesn't reopen the betting</h3>
|
||||||
<p>
|
<p>
|
||||||
Somebody shoving in less than a full raise — say $3 more when the last
|
Somebody shoving in less than a full raise — say $3 more when the last
|
||||||
@@ -336,7 +338,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ruling">
|
<section class="ruling" data-short="Uncalled bet">
|
||||||
<h3>Chips nobody called come back</h3>
|
<h3>Chips nobody called come back</h3>
|
||||||
<p>
|
<p>
|
||||||
Bet more than anyone was able to call and the excess is returned to you
|
Bet more than anyone was able to call and the excess is returned to you
|
||||||
@@ -346,7 +348,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ruling">
|
<section class="ruling" data-short="Oversized chip">
|
||||||
<h3>One big chip, nothing said</h3>
|
<h3>One big chip, nothing said</h3>
|
||||||
<p>
|
<p>
|
||||||
The most common argument at a home table. A player facing a bet who
|
The most common argument at a home table. A player facing a bet who
|
||||||
@@ -362,7 +364,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ruling">
|
<section class="ruling" data-short="Minimum raise">
|
||||||
<h3>A raise has to be a full raise</h3>
|
<h3>A raise has to be a full raise</h3>
|
||||||
<p>
|
<p>
|
||||||
At least as much again as the last raise, or the big blind if there
|
At least as much again as the last raise, or the big blind if there
|
||||||
@@ -373,7 +375,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ruling">
|
<section class="ruling" data-short="Odd chip">
|
||||||
<h3>The odd chip in a split pot</h3>
|
<h3>The odd chip in a split pot</h3>
|
||||||
<p>
|
<p>
|
||||||
When a chopped pot won't divide evenly, the odd chip goes to the first
|
When a chopped pot won't divide evenly, the odd chip goes to the first
|
||||||
@@ -383,6 +385,22 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="ruling" data-short="String bet">
|
||||||
|
<h3>Betting in one motion — the string bet</h3>
|
||||||
|
<p>
|
||||||
|
A bet is whatever goes in on the first trip to the stack, unless the
|
||||||
|
amount was said out loud first. Reaching back for more chips without
|
||||||
|
having said "raise" is a string bet, and only the first motion stands.
|
||||||
|
Saying the number is always enough on its own: "raise to sixty" binds
|
||||||
|
whatever order the chips arrive in.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
It's the rule home games invoke most and enforce least, and both are
|
||||||
|
fine — the point is that a player can't watch the table react and then
|
||||||
|
decide how much to bet. Say it, or push it all at once.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p class="rulingsSource">
|
<p class="rulingsSource">
|
||||||
Follows the Tournament Directors Association rules, which most rooms and
|
Follows the Tournament Directors Association rules, which most rooms and
|
||||||
home games take their lead from.
|
home games take their lead from.
|
||||||
|
|||||||
@@ -1095,6 +1095,51 @@ button,
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* One chip per ruling, wrapped across the top of the sheet. Names rather than icons:
|
||||||
|
a dealer scanning this is looking for the words somebody at the table just said. */
|
||||||
|
.rulingsIndex {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulingsIndexBtn {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
background: #232323;
|
||||||
|
color: #d8b46a;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.rulingsIndexBtn:hover {
|
||||||
|
background: #2e2e2e;
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A thumb needs the same 44px here as everywhere else. */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.rulingsIndexBtn {
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The heading the index jumps to takes focus, and a focus ring on a heading that the
|
||||||
|
dealer just asked for reads as noise rather than as help. */
|
||||||
|
.ruling h3:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.ruling {
|
.ruling {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding-left: 0.7rem;
|
padding-left: 0.7rem;
|
||||||
@@ -2603,6 +2648,16 @@ body.hasActionBar #toastHost {
|
|||||||
color: #d6ffe0;
|
color: #d6ffe0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The chips to push, under the amount they add up to. Quieter than the payout it
|
||||||
|
belongs to — the dealer reads the name and the money first, then counts. */
|
||||||
|
.payoutChips {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: #9fb8a6;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.potWizardActions {
|
.potWizardActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
|
|||||||
@@ -96,6 +96,19 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Two players change chairs. Undoable like every other seat action, because a
|
||||||
|
// swap is easy to aim wrong and the seats look alike at a glance.
|
||||||
|
swapSeats: (from, to) => {
|
||||||
|
const movedName = nameOf(from);
|
||||||
|
const intoName = nameOf(to);
|
||||||
|
const wasEmpty = Render.isEmptySeat(state.seats[to]);
|
||||||
|
runUndoable(
|
||||||
|
wasEmpty
|
||||||
|
? `${movedName} moved to seat ${to + 1}.`
|
||||||
|
: `${movedName} and ${intoName} changed seats.`,
|
||||||
|
() => HoldemState.swapSeats(state, from, to));
|
||||||
|
},
|
||||||
|
|
||||||
makeDealer: index => {
|
makeDealer: index => {
|
||||||
// Before the deal there's nothing posted, so this is just moving a marker.
|
// Before the deal there's nothing posted, so this is just moving a marker.
|
||||||
// After it, the blinds have to follow the button or the badges and the
|
// After it, the blinds have to follow the button or the badges and the
|
||||||
@@ -445,7 +458,50 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
// Escape is Modal's job now, for this sheet and the five others that never had it.
|
// Escape is Modal's job now, for this sheet and the five others that never had it.
|
||||||
const rulingsOverlay = document.getElementById("rulingsOverlay");
|
const rulingsOverlay = document.getElementById("rulingsOverlay");
|
||||||
const closeRulings = () => { rulingsOverlay.hidden = true; };
|
const closeRulings = () => { rulingsOverlay.hidden = true; };
|
||||||
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
|
// An index across the top of the rulings sheet, one chip per ruling.
|
||||||
|
//
|
||||||
|
// This is opened mid-argument with players waiting, and it was nine rulings of
|
||||||
|
// prose with no way in: reaching the last one meant scrolling 1.6 screens on a
|
||||||
|
// desktop, 2.1 on a phone and 3.5 on a phone held sideways. A dealer looking for
|
||||||
|
// the odd-chip rule had to read past seven others to find it.
|
||||||
|
//
|
||||||
|
// Built from the sections rather than written out beside them, so a ruling added
|
||||||
|
// later turns up in the index without anyone having to remember to add it twice.
|
||||||
|
const buildRulingsIndex = () => {
|
||||||
|
const index = document.getElementById("rulingsIndex");
|
||||||
|
if (!index || index.childElementCount > 0) return;
|
||||||
|
[...rulingsOverlay.querySelectorAll(".ruling")].forEach((section, i) => {
|
||||||
|
const heading = section.querySelector("h3");
|
||||||
|
if (!heading) return;
|
||||||
|
if (!section.id) section.id = `ruling${i + 1}`;
|
||||||
|
|
||||||
|
const jump = document.createElement("button");
|
||||||
|
jump.type = "button";
|
||||||
|
jump.className = "rulingsIndexBtn";
|
||||||
|
// A short label where the ruling gives one. The headings are sentences —
|
||||||
|
// "A short all-in doesn't reopen the betting" — and nine of those stacked
|
||||||
|
// one per row made the index 342px tall on a phone, which is most of the
|
||||||
|
// sheet before the first ruling. "Short all-in" says the same thing to
|
||||||
|
// somebody who is looking for it, and three fit on a line.
|
||||||
|
jump.textContent = section.dataset.short || heading.textContent;
|
||||||
|
// The full heading is still what it announces, so the short label never
|
||||||
|
// costs a screen reader the sentence.
|
||||||
|
if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent);
|
||||||
|
jump.addEventListener("click", () => {
|
||||||
|
section.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||||
|
// The heading is what the dealer is now reading, so that is where the
|
||||||
|
// keyboard and a screen reader should be too.
|
||||||
|
heading.setAttribute("tabindex", "-1");
|
||||||
|
heading.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
index.appendChild(jump);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("openRulingsBtn").addEventListener("click", () => {
|
||||||
|
buildRulingsIndex();
|
||||||
|
rulingsOverlay.hidden = false;
|
||||||
|
});
|
||||||
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
|
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
|
||||||
rulingsOverlay.addEventListener("click", e => {
|
rulingsOverlay.addEventListener("click", e => {
|
||||||
if (e.target === rulingsOverlay) closeRulings();
|
if (e.target === rulingsOverlay) closeRulings();
|
||||||
|
|||||||
@@ -48,6 +48,58 @@ const Money = {
|
|||||||
return (this.toCents(amount) / 100).toFixed(2);
|
return (this.toCents(amount) / 100).toFixed(2);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The chips to actually push, for an amount.
|
||||||
|
//
|
||||||
|
// A payout figure is the answer to the arithmetic, not to the dealer's job: they
|
||||||
|
// still have to work out that $132 is two blacks, a green, a red and two whites
|
||||||
|
// while six people watch. The chip legend already knows the denominations.
|
||||||
|
//
|
||||||
|
// Largest first, which is greedy and therefore not optimal for an arbitrary set of
|
||||||
|
// values — but poker chips are not arbitrary. Every real set is canonical (each
|
||||||
|
// denomination divides the next: 1, 5, 25, 100, 500), and greedy is exactly optimal
|
||||||
|
// on those. A dealer who invents a 3-and-7 chip set gets a valid answer that might
|
||||||
|
// use one chip more than it had to.
|
||||||
|
//
|
||||||
|
// Anything the smallest chip can't make comes back as `remainder` rather than being
|
||||||
|
// rounded away: a 50c share in a game whose smallest chip is a dollar is a real
|
||||||
|
// problem for the dealer to solve, not a rounding error to hide.
|
||||||
|
chipBreakdown(amount, legend) {
|
||||||
|
let cents = this.toCents(amount);
|
||||||
|
if (cents <= 0) return { chips: [], remainder: 0 };
|
||||||
|
|
||||||
|
const values = (legend || [])
|
||||||
|
.map(chip => ({ chip, cents: this.toCents(chip.value) }))
|
||||||
|
.filter(entry => entry.cents > 0)
|
||||||
|
.sort((a, b) => b.cents - a.cents);
|
||||||
|
|
||||||
|
const chips = [];
|
||||||
|
values.forEach(entry => {
|
||||||
|
const count = Math.floor(cents / entry.cents);
|
||||||
|
if (count <= 0) return;
|
||||||
|
cents -= count * entry.cents;
|
||||||
|
chips.push({ value: this.fromCents(entry.cents), count, color: entry.chip.color });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { chips, remainder: this.fromCents(cents) };
|
||||||
|
},
|
||||||
|
|
||||||
|
// "2×$50, 1×$25, 2×$1" — the breakdown as a dealer would say it out loud.
|
||||||
|
//
|
||||||
|
// A table with no chip legend gets nothing rather than "$132 no chip covers": it
|
||||||
|
// hasn't told us its denominations, so there is no advice to give and the amount
|
||||||
|
// stands on its own. A legend that exists but can't reach the amount is different,
|
||||||
|
// and does say so.
|
||||||
|
formatChipBreakdown(amount, legend) {
|
||||||
|
const hasDenominations = (legend || []).some(chip => this.toCents(chip.value) > 0);
|
||||||
|
if (!hasDenominations) return "";
|
||||||
|
|
||||||
|
const { chips, remainder } = this.chipBreakdown(amount, legend);
|
||||||
|
if (chips.length === 0 && remainder === 0) return "";
|
||||||
|
const parts = chips.map(chip => `${chip.count}×${this.format(chip.value)}`);
|
||||||
|
if (remainder > 0) parts.push(`${this.format(remainder)} no chip covers`);
|
||||||
|
return parts.join(", ");
|
||||||
|
},
|
||||||
|
|
||||||
// Should focusing this field select what's already in it?
|
// Should focusing this field select what's already in it?
|
||||||
//
|
//
|
||||||
// Every amount box in this tool opens pre-filled with a suggestion: the raise pad
|
// Every amount box in this tool opens pre-filled with a suggestion: the raise pad
|
||||||
|
|||||||
@@ -246,10 +246,23 @@ const PotWizard = {
|
|||||||
empty.textContent = "Nobody has money in this pot yet — mark seats \"In hand\" and set contributions first.";
|
empty.textContent = "Nobody has money in this pot yet — mark seats \"In hand\" and set contributions first.";
|
||||||
wrap.appendChild(empty);
|
wrap.appendChild(empty);
|
||||||
} else {
|
} else {
|
||||||
|
const legend = (this.state && this.state.chipLegend) || [];
|
||||||
const list = document.createElement("ul");
|
const list = document.createElement("ul");
|
||||||
for (const [id, amount] of this.totals.entries()) {
|
for (const [id, amount] of this.totals.entries()) {
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
|
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
|
||||||
|
|
||||||
|
// The amount is the answer to the arithmetic; the chips are the answer
|
||||||
|
// to the dealer's job. Only when the legend has been filled in — a
|
||||||
|
// table that never set up chip colours gets the number on its own,
|
||||||
|
// which is what it had before.
|
||||||
|
const chips = Money.formatChipBreakdown(amount, legend);
|
||||||
|
if (chips) {
|
||||||
|
const note = document.createElement("span");
|
||||||
|
note.className = "payoutChips";
|
||||||
|
note.textContent = chips;
|
||||||
|
li.appendChild(note);
|
||||||
|
}
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
}
|
}
|
||||||
wrap.appendChild(list);
|
wrap.appendChild(list);
|
||||||
|
|||||||
@@ -49,11 +49,21 @@ const Render = {
|
|||||||
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
|
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Whether this device can actually drag a seat. HTML5 drag events are never
|
||||||
|
// generated from a touch screen, so on a phone the last clause of the hint below
|
||||||
|
// described something impossible — on the device the rest of the sentence was
|
||||||
|
// written for. Touch gets told about the menu route instead, which works.
|
||||||
|
canDragSeats() {
|
||||||
|
return typeof matchMedia !== "function" || matchMedia("(hover: hover)").matches;
|
||||||
|
},
|
||||||
|
|
||||||
renderTableHint() {
|
renderTableHint() {
|
||||||
const hint = document.getElementById("tableHint");
|
const hint = document.getElementById("tableHint");
|
||||||
// Gestures are invisible until someone tells you about them, and this is the
|
// Gestures are invisible until someone tells you about them, and this is the
|
||||||
// only place on the table with room to say so.
|
// only place on the table with room to say so.
|
||||||
hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats.";
|
hint.textContent = this.canDragSeats()
|
||||||
|
? "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats."
|
||||||
|
: "Tap a seat to edit · double-tap to fold · press and hold for quick actions, including moving a seat.";
|
||||||
},
|
},
|
||||||
|
|
||||||
renderNotes() {
|
renderNotes() {
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ const SeatMenu = {
|
|||||||
hooks: null,
|
hooks: null,
|
||||||
seatIndex: null,
|
seatIndex: null,
|
||||||
|
|
||||||
|
mode: null,
|
||||||
|
|
||||||
open(state, hooks, seatIndex) {
|
open(state, hooks, seatIndex) {
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.hooks = hooks;
|
this.hooks = hooks;
|
||||||
this.seatIndex = seatIndex;
|
this.seatIndex = seatIndex;
|
||||||
|
// Always the action list, never whatever the sheet was showing when it last
|
||||||
|
// closed — a seat menu that opens on a list of chairs is a menu that lost the
|
||||||
|
// dealer's place.
|
||||||
|
this.mode = null;
|
||||||
this.render();
|
this.render();
|
||||||
document.getElementById("seatMenuOverlay").hidden = false;
|
document.getElementById("seatMenuOverlay").hidden = false;
|
||||||
},
|
},
|
||||||
@@ -63,29 +69,81 @@ const SeatMenu = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
|
||||||
|
// generated from a touch screen — so on the device this tool is mostly used on,
|
||||||
|
// the "drag to swap seats" the hint advertised did nothing at all. This is the
|
||||||
|
// same move, by the route a thumb can actually take.
|
||||||
|
if (this.state.seats.length > 1) {
|
||||||
|
actions.push({ label: "Move to…", run: () => this.openMovePicker() });
|
||||||
|
}
|
||||||
|
|
||||||
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
||||||
return actions;
|
return actions;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The chairs this player could move to. Every other chair: swapping into an empty
|
||||||
|
// one is how a player moves, and swapping with an occupied one is two players
|
||||||
|
// changing places, which is the other half of the same job.
|
||||||
|
destinationsFor(index) {
|
||||||
|
return this.state.seats
|
||||||
|
.map((player, seat) => ({ player, seat }))
|
||||||
|
.filter(entry => entry.seat !== index);
|
||||||
|
},
|
||||||
|
|
||||||
|
openMovePicker() {
|
||||||
|
this.mode = "move";
|
||||||
|
this.render();
|
||||||
|
},
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const player = this.state.seats[this.seatIndex];
|
const player = this.state.seats[this.seatIndex];
|
||||||
document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, this.seatIndex);
|
const title = document.getElementById("seatMenuTitle");
|
||||||
|
const subtitle = document.getElementById("seatMenuSubtitle");
|
||||||
|
const list = document.getElementById("seatMenuActions");
|
||||||
|
list.innerHTML = "";
|
||||||
|
|
||||||
|
if (this.mode === "move") {
|
||||||
|
title.textContent = `Move ${HoldemState.displayName(player, this.seatIndex)} to…`;
|
||||||
|
subtitle.textContent = "Their chips, cards and bet go with them. The button and the blinds stay on the chair.";
|
||||||
|
this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "seatMenuBtn";
|
||||||
|
// displayName falls back to "Seat 5" for a chair with nobody in it,
|
||||||
|
// which reads as "Seat 5 — Seat 5". An empty chair says it's empty.
|
||||||
|
const named = (other.name || "").trim();
|
||||||
|
btn.textContent = named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const from = this.seatIndex;
|
||||||
|
this.close();
|
||||||
|
this.hooks.swapSeats(from, seat);
|
||||||
|
});
|
||||||
|
list.appendChild(btn);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
title.textContent = HoldemState.displayName(player, this.seatIndex);
|
||||||
|
|
||||||
const stack = Betting.stackOf(this.state, player);
|
const stack = Betting.stackOf(this.state, player);
|
||||||
const owed = Betting.toCall(this.state, player);
|
const owed = Betting.toCall(this.state, player);
|
||||||
const bits = [];
|
const bits = [];
|
||||||
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
|
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
|
||||||
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
|
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
|
||||||
document.getElementById("seatMenuSubtitle").textContent = bits.join(" · ");
|
subtitle.textContent = bits.join(" · ");
|
||||||
|
|
||||||
const list = document.getElementById("seatMenuActions");
|
|
||||||
list.innerHTML = "";
|
|
||||||
this.actionsFor(player, this.seatIndex).forEach(action => {
|
this.actionsFor(player, this.seatIndex).forEach(action => {
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.type = "button";
|
btn.type = "button";
|
||||||
btn.className = `seatMenuBtn ${action.className || ""}`;
|
btn.className = `seatMenuBtn ${action.className || ""}`;
|
||||||
btn.textContent = action.label;
|
btn.textContent = action.label;
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
|
// "Move to…" stays on the sheet and swaps it for the seat list; every
|
||||||
|
// other action is the end of the interaction.
|
||||||
|
if (action.label === "Move to…") {
|
||||||
|
action.run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.close();
|
this.close();
|
||||||
action.run();
|
action.run();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -467,6 +467,21 @@ const HoldemState = {
|
|||||||
.sort((a, b) => b.net - a.net);
|
.sort((a, b) => b.net - a.net);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Two players change chairs.
|
||||||
|
//
|
||||||
|
// The whole seat moves — name, stack, cards, what they have in the pot — because
|
||||||
|
// that is what changing chairs is. What stays behind is everything that belongs to
|
||||||
|
// the *position*: the button, the blinds and the acting index all name a chair, and
|
||||||
|
// a player who moves into the big blind's chair is the big blind. That is the same
|
||||||
|
// answer a card room gives.
|
||||||
|
swapSeats(state, a, b) {
|
||||||
|
const seats = state.seats || [];
|
||||||
|
if (a === b) return false;
|
||||||
|
if (!seats[a] || !seats[b]) return false;
|
||||||
|
[seats[a], seats[b]] = [seats[b], seats[a]];
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
// The one check a banker actually needs, and the one nothing here did.
|
// The one check a banker actually needs, and the one nothing here did.
|
||||||
//
|
//
|
||||||
// Every chip on the table is a promise to pay cash for it later. If the chips in
|
// Every chip on the table is a promise to pay cash for it later. If the chips in
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// The chips to actually push, for an amount.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { Money } = require("./load");
|
||||||
|
|
||||||
|
// A standard house set.
|
||||||
|
const LEGEND = [
|
||||||
|
{ value: 1, color: "#fff" },
|
||||||
|
{ value: 5, color: "#c0392b" },
|
||||||
|
{ value: 25, color: "#27ae60" },
|
||||||
|
{ value: 100, color: "#2c3e50" },
|
||||||
|
{ value: 500, color: "#8e44ad" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const say = (amount, legend = LEGEND) => Money.formatChipBreakdown(amount, legend);
|
||||||
|
|
||||||
|
test("a payout comes back as chips, largest first", () => {
|
||||||
|
assert.equal(say(132), "1×$100, 1×$25, 1×$5, 2×$1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it uses the fewest chips on a normal set", () => {
|
||||||
|
const { chips } = Money.chipBreakdown(500, LEGEND);
|
||||||
|
assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["1×500"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a denomination that doesn't fit is skipped rather than listed as none", () => {
|
||||||
|
assert.equal(say(7), "1×$5, 2×$1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nothing to pay is nothing to say", () => {
|
||||||
|
assert.equal(say(0), "");
|
||||||
|
assert.equal(say(-5), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no legend means no advice, not a wrong answer", () => {
|
||||||
|
// A table that never set chip colours up gets the amount on its own.
|
||||||
|
assert.equal(say(132, []), "");
|
||||||
|
assert.equal(say(132, null), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legend with no values in it yet is the same as none", () => {
|
||||||
|
assert.equal(say(132, [{ value: "", color: "#fff" }, { value: 0, color: "#000" }]), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("what the smallest chip can't make is reported, not rounded away", () => {
|
||||||
|
// 50c in a game whose smallest chip is a dollar is the dealer's problem to solve,
|
||||||
|
// and they can only solve it if they're told.
|
||||||
|
const { chips, remainder } = Money.chipBreakdown(10.5, LEGEND);
|
||||||
|
assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["2×5"]);
|
||||||
|
assert.equal(remainder, 0.5);
|
||||||
|
assert.equal(say(10.5), "2×$5, $0.50 no chip covers");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cent chips make cent amounts exactly", () => {
|
||||||
|
const cents = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }];
|
||||||
|
assert.equal(say(0.41, cents), "1×$0.25, 3×$0.05, 1×$0.01");
|
||||||
|
assert.equal(Money.chipBreakdown(0.41, cents).remainder, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the chips add up to the amount", () => {
|
||||||
|
for (const amount of [1, 7, 33, 132, 499, 500, 1234, 9999]) {
|
||||||
|
const { chips, remainder } = Money.chipBreakdown(amount, LEGEND);
|
||||||
|
const total = chips.reduce((sum, c) => sum + c.count * c.value, 0) + remainder;
|
||||||
|
assert.equal(Money.round(total), amount, `${amount} should be accounted for`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legend in any order still pays largest first", () => {
|
||||||
|
const jumbled = [{ value: 25 }, { value: 1 }, { value: 500 }, { value: 5 }, { value: 100 }];
|
||||||
|
assert.equal(say(631, jumbled), "1×$500, 1×$100, 1×$25, 1×$5, 1×$1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the colour comes along, so the felt can say it in chips", () => {
|
||||||
|
const { chips } = Money.chipBreakdown(25, LEGEND);
|
||||||
|
assert.equal(chips[0].color, "#27ae60");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legend that exists but can't reach the amount does say so", () => {
|
||||||
|
// Different from having no legend at all: this table has told us its chips, and
|
||||||
|
// the answer is that they don't go that low.
|
||||||
|
assert.equal(say(3, [{ value: 5 }, { value: 25 }]), "$3 no chip covers");
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Two players changing chairs.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||||
|
|
||||||
|
test("the whole seat moves", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||||
|
state.seats[0].name = "Ann";
|
||||||
|
state.seats[0].cards = ["AS", "KD"];
|
||||||
|
state.seats[0].stack = 175;
|
||||||
|
state.seats[4].name = "";
|
||||||
|
|
||||||
|
HoldemState.swapSeats(state, 0, 4);
|
||||||
|
|
||||||
|
assert.equal(state.seats[4].name, "Ann");
|
||||||
|
assert.deepEqual(state.seats[4].cards, ["AS", "KD"]);
|
||||||
|
assert.equal(state.seats[4].stack, 175);
|
||||||
|
assert.equal(state.seats[0].name, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("what they have in the pot goes with them", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const posted = state.seats.map(p => p.committed);
|
||||||
|
const before = chipsOnTable(state);
|
||||||
|
|
||||||
|
HoldemState.swapSeats(state, 1, 4);
|
||||||
|
|
||||||
|
assert.equal(state.seats[4].committed, posted[1]);
|
||||||
|
assert.equal(state.seats[1].committed, posted[4]);
|
||||||
|
assert.equal(chipsOnTable(state), before, "a swap never creates or destroys a chip");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the button stays on the chair, not on the player", () => {
|
||||||
|
// A card room moves the button around the table, not around the people. Somebody
|
||||||
|
// who sits down in the big blind's chair is the big blind.
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, dealerSeatIndex: 0 });
|
||||||
|
state.seats[0].name = "Ann";
|
||||||
|
|
||||||
|
HoldemState.swapSeats(state, 0, 2);
|
||||||
|
|
||||||
|
assert.equal(state.dealerSeatIndex, 0);
|
||||||
|
assert.notEqual(state.seats[0].name, "Ann");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the blinds follow the chairs too", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const before = HoldemState.getBlindSeats(state);
|
||||||
|
|
||||||
|
HoldemState.swapSeats(state, before.sbIndex, before.bbIndex);
|
||||||
|
const after = HoldemState.getBlindSeats(state);
|
||||||
|
|
||||||
|
assert.equal(after.sbIndex, before.sbIndex);
|
||||||
|
assert.equal(after.bbIndex, before.bbIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("swapping a seat with itself does nothing and says so", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||||
|
const before = JSON.stringify(state);
|
||||||
|
|
||||||
|
assert.equal(HoldemState.swapSeats(state, 1, 1), false);
|
||||||
|
assert.equal(JSON.stringify(state), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a chair that isn't there is refused rather than swallowed", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||||
|
const before = JSON.stringify(state);
|
||||||
|
|
||||||
|
assert.equal(HoldemState.swapSeats(state, 0, 99), false);
|
||||||
|
assert.equal(HoldemState.swapSeats(state, -1, 0), false);
|
||||||
|
assert.equal(JSON.stringify(state), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("swapping twice puts everything back", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const before = JSON.stringify(state);
|
||||||
|
|
||||||
|
HoldemState.swapSeats(state, 1, 3);
|
||||||
|
HoldemState.swapSeats(state, 1, 3);
|
||||||
|
|
||||||
|
assert.equal(JSON.stringify(state), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moving into an empty chair leaves an empty chair behind", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||||
|
state.seats[1].name = "Ben";
|
||||||
|
|
||||||
|
HoldemState.swapSeats(state, 1, 5);
|
||||||
|
|
||||||
|
assert.equal(state.seats[5].name, "Ben");
|
||||||
|
assert.equal(state.seats[5].inHand, true);
|
||||||
|
assert.equal(state.seats[1].inHand, false);
|
||||||
|
});
|
||||||
@@ -522,8 +522,15 @@ const LayoutAudit = {
|
|||||||
const chips = [...document.querySelectorAll(".dealerChip")];
|
const chips = [...document.querySelectorAll(".dealerChip")];
|
||||||
const shown = chips.filter(chip => {
|
const shown = chips.filter(chip => {
|
||||||
const style = getComputedStyle(chip);
|
const style = getComputedStyle(chip);
|
||||||
return style.display !== "none" && style.visibility !== "hidden" &&
|
if (style.display === "none" || style.visibility === "hidden") return false;
|
||||||
parseFloat(style.opacity) > 0.05;
|
if (parseFloat(style.opacity) <= 0.05) return false;
|
||||||
|
// A hovered seat deliberately fades its empty slot in — that's the "put the
|
||||||
|
// button here" preview, and it is on screen because a pointer is sitting on
|
||||||
|
// that seat. Counting it reports two buttons whenever the mouse happens to
|
||||||
|
// rest on the table, which is most of the time a mouse exists.
|
||||||
|
const seat = chip.closest(".seat");
|
||||||
|
if (seat && seat.matches(":hover") && !chip.classList.contains("active")) return false;
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
const offenders = [];
|
const offenders = [];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user