Hold'em dealer: 55 polish rounds, one merge #101
@@ -156,6 +156,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details class="sidebarPanel" id="keyboardPanel">
|
||||||
|
<summary>Keyboard</summary>
|
||||||
|
<div class="panelBody">
|
||||||
|
<dl id="shortcutLegend" class="shortcutLegend"></dl>
|
||||||
|
<p class="fieldHint">Nothing fires while you're typing in a field, or while a sheet is open over the table.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<details class="sidebarPanel">
|
<details class="sidebarPanel">
|
||||||
<summary>Table Notes</summary>
|
<summary>Table Notes</summary>
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
|
|||||||
@@ -1095,6 +1095,46 @@ button,
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The shortcut legend. A touch device has no keys to press, so it doesn't get a panel
|
||||||
|
telling it about them — the same pointer question the touch-target rules ask. */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
#keyboardPanel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcutLegend {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.45rem 0.6rem;
|
||||||
|
margin: 0;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcutLegend dt {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcutLegend dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #b8b8b8;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcutKey {
|
||||||
|
background: #232323;
|
||||||
|
border: 1px solid #3f3f3f;
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.05rem 0.32rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: #d8b46a;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* One chip per ruling, wrapped across the top of the sheet. Names rather than icons:
|
/* 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. */
|
a dealer scanning this is looking for the words somebody at the table just said. */
|
||||||
.rulingsIndex {
|
.rulingsIndex {
|
||||||
@@ -2117,6 +2157,20 @@ body.hasActionBar #holdemSidebar {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The undo pill's key, inline rather than pinned to a corner — see actionBar.js. */
|
||||||
|
.actionUndoKey {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
opacity: 0.55;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-transform: uppercase;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.actionKey {
|
.actionKey {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 5px;
|
top: 5px;
|
||||||
|
|||||||
@@ -176,6 +176,17 @@ const ActionBar = {
|
|||||||
undo.className = "actionUndo";
|
undo.className = "actionUndo";
|
||||||
undo.title = `Undo ${undoLabel}`;
|
undo.title = `Undo ${undoLabel}`;
|
||||||
undo.textContent = `↶ ${undoLabel}`;
|
undo.textContent = `↶ ${undoLabel}`;
|
||||||
|
// U undoes, and nothing said so — this control was the only place a dealer
|
||||||
|
// could have learned it, and it was the one action bar control without its
|
||||||
|
// key printed on it.
|
||||||
|
const key = document.createElement("kbd");
|
||||||
|
// Its own class, not .actionKey: that one pins itself to the top-right
|
||||||
|
// corner of a big action button, and this control is a 30px pill with
|
||||||
|
// overflow hidden and no positioning context — the cap escaped it and
|
||||||
|
// floated above the bar. Here the key belongs inline, after the label.
|
||||||
|
key.className = "actionUndoKey";
|
||||||
|
key.textContent = this.KEY_HINTS.actionUndo;
|
||||||
|
undo.appendChild(key);
|
||||||
undo.addEventListener("click", () => this.hooks.undoLast());
|
undo.addEventListener("click", () => this.hooks.undoLast());
|
||||||
row.appendChild(undo);
|
row.appendChild(undo);
|
||||||
}
|
}
|
||||||
@@ -281,6 +292,8 @@ const ActionBar = {
|
|||||||
actionAdvance: "↵",
|
actionAdvance: "↵",
|
||||||
raiseConfirm: "↵",
|
raiseConfirm: "↵",
|
||||||
raiseCancel: "esc",
|
raiseCancel: "esc",
|
||||||
|
raiseAllIn: "A",
|
||||||
|
actionUndo: "U",
|
||||||
},
|
},
|
||||||
|
|
||||||
button(label, className, onClick, sublabel) {
|
button(label, className, onClick, sublabel) {
|
||||||
@@ -293,7 +306,10 @@ const ActionBar = {
|
|||||||
main.textContent = label;
|
main.textContent = label;
|
||||||
btn.appendChild(main);
|
btn.appendChild(main);
|
||||||
|
|
||||||
const hint = this.KEY_HINTS[className];
|
// Looked up by any of the button's classes, so a button can carry a shared
|
||||||
|
// class for its looks and a specific one for its key — the raise pad's four
|
||||||
|
// quick amounts are all raiseQuickBtn, but only All-in has a key.
|
||||||
|
const hint = className.split(/\s+/).map(name => this.KEY_HINTS[name]).find(Boolean);
|
||||||
if (hint) {
|
if (hint) {
|
||||||
const kbd = document.createElement("kbd");
|
const kbd = document.createElement("kbd");
|
||||||
kbd.className = "actionKey";
|
kbd.className = "actionKey";
|
||||||
@@ -397,7 +413,7 @@ const ActionBar = {
|
|||||||
];
|
];
|
||||||
options.forEach(option => {
|
options.forEach(option => {
|
||||||
const value = Math.max(minTo, Math.min(option.value, maxTo));
|
const value = Math.max(minTo, Math.min(option.value, maxTo));
|
||||||
const btn = this.button(option.label, "raiseQuickBtn", () => {
|
const btn = this.button(option.label, `raiseQuickBtn${option.label === "All-in" ? " raiseAllIn" : ""}`, () => {
|
||||||
this.raiseDraft = value;
|
this.raiseDraft = value;
|
||||||
this.render();
|
this.render();
|
||||||
}, Money.format(value));
|
}, Money.format(value));
|
||||||
|
|||||||
@@ -458,6 +458,25 @@ 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; };
|
||||||
|
// The shortcut legend, built from the same list the handlers are keyed off, so a
|
||||||
|
// shortcut can't exist without a line here saying what it does.
|
||||||
|
const legend = document.getElementById("shortcutLegend");
|
||||||
|
if (legend) {
|
||||||
|
Shortcuts.LEGEND.forEach(entry => {
|
||||||
|
const term = document.createElement("dt");
|
||||||
|
entry.keys.forEach(key => {
|
||||||
|
const kbd = document.createElement("kbd");
|
||||||
|
kbd.className = "shortcutKey";
|
||||||
|
kbd.textContent = key;
|
||||||
|
term.appendChild(kbd);
|
||||||
|
});
|
||||||
|
const detail = document.createElement("dd");
|
||||||
|
detail.textContent = entry.what;
|
||||||
|
legend.appendChild(term);
|
||||||
|
legend.appendChild(detail);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// An index across the top of the rulings sheet, one chip per ruling.
|
// 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
|
// This is opened mid-argument with players waiting, and it was nine rulings of
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ const Shortcuts = {
|
|||||||
escape: "cancel",
|
escape: "cancel",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// What each one does, in a dealer's words, for the legend in the sidebar.
|
||||||
|
//
|
||||||
|
// Lives beside KEYS because the two must not drift: F, C and R are printed on the
|
||||||
|
// buttons they drive, but A and U were on nothing at all — a dealer who learned
|
||||||
|
// the first three from the bar had no way to find out the other two existed. A
|
||||||
|
// shortcut with no line here is a shortcut nobody can find, and there's a test
|
||||||
|
// that says so.
|
||||||
|
LEGEND: [
|
||||||
|
{ keys: ["F"], action: "fold", what: "Fold" },
|
||||||
|
{ keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" },
|
||||||
|
{ keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" },
|
||||||
|
{ keys: ["A"], action: "allIn", what: "Put this player all-in" },
|
||||||
|
{ keys: ["U"], action: "undo", what: "Take back the last action" },
|
||||||
|
{ keys: ["Space", "Enter"], action: "advance", what: "Deal, or move to the next street" },
|
||||||
|
{ keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" },
|
||||||
|
],
|
||||||
|
|
||||||
mount(handlers) {
|
mount(handlers) {
|
||||||
this.handlers = handlers;
|
this.handlers = handlers;
|
||||||
document.addEventListener("keydown", e => this.onKey(e));
|
document.addEventListener("keydown", e => this.onKey(e));
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// The legend and the key map must not drift apart.
|
||||||
|
//
|
||||||
|
// F, C and R are printed on the buttons they drive. A and U were on nothing at all,
|
||||||
|
// so a dealer who learned the first three from the action bar had no way to find out
|
||||||
|
// the other two existed. The guard against that happening again is this file.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const Shortcuts = require(path.join(
|
||||||
|
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "shortcuts.js"));
|
||||||
|
|
||||||
|
test("every shortcut has a line in the legend", () => {
|
||||||
|
const documented = new Set(Shortcuts.LEGEND.map(entry => entry.action));
|
||||||
|
const missing = [...new Set(Object.values(Shortcuts.KEYS))].filter(action => !documented.has(action));
|
||||||
|
assert.deepEqual(missing, [], "a shortcut with no legend line is one nobody can find");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the legend doesn't describe shortcuts that don't exist", () => {
|
||||||
|
const real = new Set(Object.values(Shortcuts.KEYS));
|
||||||
|
const invented = Shortcuts.LEGEND.map(entry => entry.action).filter(action => !real.has(action));
|
||||||
|
assert.deepEqual(invented, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every legend line names its keys and says what they do", () => {
|
||||||
|
Shortcuts.LEGEND.forEach(entry => {
|
||||||
|
assert.ok(Array.isArray(entry.keys) && entry.keys.length > 0, `${entry.action} has no keys`);
|
||||||
|
assert.ok(entry.keys.every(key => typeof key === "string" && key.trim()), `${entry.action} has a blank key`);
|
||||||
|
assert.ok(entry.what && entry.what.trim().length > 3, `${entry.action} has no description`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the keys named match the keys bound", () => {
|
||||||
|
// "Space" and "Enter" both drive advance; the map holds them as " " and "enter".
|
||||||
|
const spoken = { space: " ", enter: "enter", esc: "escape" };
|
||||||
|
Shortcuts.LEGEND.forEach(entry => {
|
||||||
|
entry.keys.forEach(label => {
|
||||||
|
const key = spoken[label.toLowerCase()] || label.toLowerCase();
|
||||||
|
assert.equal(Shortcuts.KEYS[key], entry.action,
|
||||||
|
`the legend says ${label} is ${entry.action}, the map disagrees`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no two lines claim the same key", () => {
|
||||||
|
const seen = new Set();
|
||||||
|
Shortcuts.LEGEND.forEach(entry => entry.keys.forEach(key => {
|
||||||
|
assert.ok(!seen.has(key.toLowerCase()), `${key} is listed twice`);
|
||||||
|
seen.add(key.toLowerCase());
|
||||||
|
}));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user