The card feature had two halves and only one of them ever asked a question. Turn the
setting on and every street opens a sheet for the cards coming off the deck — that
worked, on a phone and on a desk, in a normal hand and in an all-in run-out. The
other half never asked at all. A dealer who entered all five community cards, pressed
"Showdown — resolve pot", and got a wizard saying
Christopher, Mike from work, Alexandra and Bob haven't turned over, so who
won is still yours to say.
with nothing in that sheet to turn anybody over with. The only route in was to close
the wizard, tap a seat puck, find the two card slots in the seat editor, do it four
times and re-open the wizard, with the pot on the table and four people waiting. The
one moment the tool is certain to know the hands is the moment it stopped asking.
So the showdown button asks. The sheet opens on whoever the bar has just named as
showing first, takes their two cards, and moves round the table from there; hands
already entered are skipped, because a hand entered is a question already answered.
Four sheets later the wizard opens reading "Chris wins $40 with three of a kind,
nines" and its button says "Pay Chris" instead of "Confirm Winner(s)".
Leaving the sheet stops the asking rather than skipping a player: closing it says the
tool has the wrong end of the stick, and the answer to that is to get out of the way.
It isn't a dead end — the wizard still names whoever didn't turn over, the bar still
says "Showdown — resolve pot", and pressing it again resumes at the player the dealer
stopped on, because the list is read off the cards on the table rather than remembered.
CardPicker.close() takes a reason so the chain can tell "that sheet filled its last
slot" from "the dealer left". Two callers pass nothing and don't care.
The setting kept its behaviour and lost its jargon: "Track the board and read the
showdown" was the feature's name rather than what turning it on does to your evening.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
208 lines
8.3 KiB
JavaScript
208 lines
8.3 KiB
JavaScript
// Tap a card instead of typing it.
|
|
//
|
|
// Typing "AH" into a two-character box is fine at a desk and miserable at a table:
|
|
// it needs a keyboard, it accepts nonsense like "ZZ", and it happily lets the same
|
|
// card land in two players' hands. The picker is one tap per card, greys out cards
|
|
// already dealt somewhere else at the table, and stores exactly the same two-character
|
|
// string the rest of the tool has always read.
|
|
const CardPicker = {
|
|
RANKS: ["A", "K", "Q", "J", "T", "9", "8", "7", "6", "5", "4", "3", "2"],
|
|
SUITS: [
|
|
{ code: "S", symbol: "♠", red: false },
|
|
{ code: "H", symbol: "♥", red: true },
|
|
{ code: "D", symbol: "♦", red: true },
|
|
{ code: "C", symbol: "♣", red: false },
|
|
],
|
|
|
|
state: null,
|
|
hooks: null,
|
|
// Which set of slots is being filled: a seat's two hole cards, or the five
|
|
// community cards. The grid, the duplicate check and the tap-to-fill behaviour are
|
|
// the same job either way — what changes is where the card is written and how many
|
|
// slots there are to walk along.
|
|
target: null,
|
|
seatIndex: null,
|
|
cardIndex: 0,
|
|
|
|
open(state, hooks, seatIndex, cardIndex) {
|
|
this.state = state;
|
|
this.hooks = hooks;
|
|
this.target = "seat";
|
|
this.seatIndex = seatIndex;
|
|
this.cardIndex = cardIndex;
|
|
this.render();
|
|
document.getElementById("cardPickerOverlay").hidden = false;
|
|
},
|
|
|
|
// The board. `limit` is how many of the five are actually out — three on the flop,
|
|
// four on the turn — so the sheet never offers a slot for a card the dealer has not
|
|
// turned over yet.
|
|
openBoard(state, hooks, cardIndex, limit = 5) {
|
|
this.state = state;
|
|
this.hooks = hooks;
|
|
this.target = "board";
|
|
this.seatIndex = null;
|
|
this.boardLimit = Math.max(1, Math.min(5, limit));
|
|
this.cardIndex = Math.min(cardIndex, this.boardLimit - 1);
|
|
this.render();
|
|
document.getElementById("cardPickerOverlay").hidden = false;
|
|
},
|
|
|
|
onBoard() {
|
|
return this.target === "board";
|
|
},
|
|
|
|
// The slots the sheet is filling, whichever set that is.
|
|
slotValues() {
|
|
return this.onBoard()
|
|
? (this.state.board || []).slice(0, this.boardLimit)
|
|
: this.state.seats[this.seatIndex].cards;
|
|
},
|
|
|
|
write(index, card) {
|
|
if (this.onBoard()) {
|
|
if (!Array.isArray(this.state.board)) this.state.board = ["", "", "", "", ""];
|
|
this.state.board[index] = card;
|
|
return;
|
|
}
|
|
this.state.seats[this.seatIndex].cards[index] = card;
|
|
},
|
|
|
|
// Why the sheet is closing, because one caller needs to tell the difference.
|
|
//
|
|
// "filled" is the sheet finishing its own job — the last empty slot it was offering
|
|
// got a card. "dismissed" is the dealer leaving: Done, Escape, or a tap on the scrim.
|
|
// A single seat's cards don't care which. A showdown does: it walks the players one
|
|
// at a time, and the two answers are "next player" and "stop asking".
|
|
//
|
|
// The hooks are dropped before the callback runs, so a caller that opens the sheet
|
|
// again from inside `onDone` installs its own and this close can't fire twice.
|
|
close(reason = "dismissed") {
|
|
document.getElementById("cardPickerOverlay").hidden = true;
|
|
const done = this.hooks && this.hooks.onDone;
|
|
this.hooks = null;
|
|
if (done) done(reason);
|
|
},
|
|
|
|
// Every card already dealt somewhere — in front of a player or face up in the
|
|
// middle. The slot being edited is excluded, so re-picking the card that's already
|
|
// there isn't treated as a duplicate.
|
|
//
|
|
// The board belongs in here as much as the hands do, and in both directions: the
|
|
// seven of clubs cannot be on the flop and in somebody's hand, whichever of the two
|
|
// the dealer is filling in at the time.
|
|
cardsInUse() {
|
|
const used = new Map();
|
|
this.state.seats.forEach((player, seat) => {
|
|
(player.cards || []).forEach((card, slot) => {
|
|
if (!card) return;
|
|
if (!this.onBoard() && seat === this.seatIndex && slot === this.cardIndex) return;
|
|
used.set(card.toUpperCase(), HoldemState.displayName(player, seat));
|
|
});
|
|
});
|
|
(this.state.board || []).forEach((card, slot) => {
|
|
if (!card) return;
|
|
if (this.onBoard() && slot === this.cardIndex) return;
|
|
used.set(card.toUpperCase(), "the board");
|
|
});
|
|
return used;
|
|
},
|
|
|
|
assign(card) {
|
|
this.write(this.cardIndex, card);
|
|
this.hooks.onPick(this.seatIndex, this.cardIndex, card);
|
|
|
|
// Straight on to the next empty slot — a dealer picking hole cards is always
|
|
// picking two and a flop is always three, and making them re-open the picker
|
|
// between each one multiplies the taps by the only thing the sheet is for.
|
|
const values = this.slotValues();
|
|
const next = values.findIndex((value, i) => i > this.cardIndex && !value);
|
|
if (next !== -1) {
|
|
this.cardIndex = next;
|
|
this.render();
|
|
return;
|
|
}
|
|
this.close("filled");
|
|
},
|
|
|
|
clearSlot() {
|
|
this.write(this.cardIndex, "");
|
|
this.hooks.onPick(this.seatIndex, this.cardIndex, "");
|
|
this.render();
|
|
},
|
|
|
|
// What this slot is called out loud: the flop's three cards, then the turn, then
|
|
// the river — which is what a dealer is holding when they open this, rather than
|
|
// "card 4".
|
|
boardSlotLabel(slot) {
|
|
return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
|
|
},
|
|
|
|
render() {
|
|
const used = this.cardsInUse();
|
|
const values = this.slotValues();
|
|
|
|
document.getElementById("cardPickerTitle").textContent = this.onBoard()
|
|
? `Board — ${this.boardSlotLabel(this.cardIndex).toLowerCase()}`
|
|
: `${HoldemState.displayName(this.state.seats[this.seatIndex], this.seatIndex)} — card ${this.cardIndex + 1}`;
|
|
|
|
// Which slot the next tap fills, switchable without closing the sheet.
|
|
const slots = document.getElementById("cardPickerSlots");
|
|
slots.innerHTML = "";
|
|
values.forEach((_, slot) => {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "pickerSlot";
|
|
btn.classList.toggle("active", slot === this.cardIndex);
|
|
const value = values[slot] || "";
|
|
btn.textContent = value || "—";
|
|
if (this.onBoard()) btn.title = this.boardSlotLabel(slot);
|
|
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
|
|
btn.addEventListener("click", () => {
|
|
this.cardIndex = slot;
|
|
this.render();
|
|
});
|
|
slots.appendChild(btn);
|
|
});
|
|
|
|
const grid = document.getElementById("cardPickerGrid");
|
|
grid.innerHTML = "";
|
|
this.RANKS.forEach(rank => {
|
|
this.SUITS.forEach(suit => {
|
|
const card = `${rank}${suit.code}`;
|
|
const takenBy = used.get(card);
|
|
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "pickerCard";
|
|
btn.classList.toggle("cardRed", suit.red);
|
|
btn.disabled = !!takenBy;
|
|
if (takenBy) {
|
|
btn.title = takenBy === "the board"
|
|
? "Already face up on the board"
|
|
: `Already dealt to ${takenBy}`;
|
|
}
|
|
if ((values[this.cardIndex] || "").toUpperCase() === card) {
|
|
btn.classList.add("selected");
|
|
}
|
|
|
|
const rankEl = document.createElement("span");
|
|
rankEl.className = "pickerRank";
|
|
rankEl.textContent = rank;
|
|
const suitEl = document.createElement("span");
|
|
suitEl.className = "pickerSuit";
|
|
suitEl.textContent = suit.symbol;
|
|
btn.appendChild(rankEl);
|
|
btn.appendChild(suitEl);
|
|
|
|
btn.addEventListener("click", () => this.assign(card));
|
|
grid.appendChild(btn);
|
|
});
|
|
});
|
|
},
|
|
};
|
|
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = CardPicker;
|
|
}
|