Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf22e3153 | ||
|
|
f0994a8aed | ||
|
|
ddb3907ace |
@@ -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;
|
||||||
|
|||||||
@@ -458,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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user