Home games don't run like a cardroom. People walk up mid-hand, storm off mid-hand, and pass the button round three times before anyone deals. Probing each of those found four problems, one of which loses chips. A player taken out mid-hand took their chips with them `computeContribution` opened with `if (!player.inHand) return 0`, so the moment a dealer switched a seat off, everything that seat had already put in vanished from the pot. Measured: four players in for $2 each, one taken out, pot drops from $8 to $6. Their $2 was still sitting on the seat record — it just stopped counting. A dealer following the tool would have paid out $6 of an $8 pot. Chips already pushed into the middle are dead money. They stay in the pot and go to whoever wins, whether or not the player is still in the hand, and it is not theirs to take back. The `inHand` check now guards only the table-wide base-pot default, which does have to stay tied to being dealt in or every empty chair would be treated as having anted. The pot wizard counts a departed seat as folded, so its chips are in the pot but it can never be picked as a winner. Passing the button before the cards come out Moving the button left the posted blinds where they were while everything positional started reading off the new spot, so the money and the blind positions silently disagreed. While nothing but the blinds are in, moving the button now refunds them and re-posts from where it lands — pass it round six times and the chip count is unchanged. Once anybody has acted the blinds are settled and it stops offering. Naming the blinds by hand Blinds were purely positional with no way to overrule them. The seat menu now offers "Make small blind" / "Make big blind" while only the blinds are in, and a badge with a ring marks a blind that was named rather than dealt. Naming one pins the other where it is. It's refused if it would put both blinds on one player or post to somebody who isn't dealt in, and it lasts one hand — carrying it forward would freeze the blinds on two people for the night. The first hand, and a table that empties out A fresh table puts the button on seat 1 whether or not anyone is sitting there, and a table can empty out around it. The button is moved onto a real player before a hand is dealt. Joining mid-hand A newcomer was added straight into the live seats and would be asked to act in a hand they hadn't been paying into. They now sit the hand out and are a full player from the next one. Nothing is covered on a phone any more Swept 13 viewport sizes with elementFromPoint, five points per control, on the table and every overlay. Before: the action bar covered the bottom seats on short screens (62px of felt at 360x640), the toast covered up to six seats at once — Undo button included, so a tap meant for a player hit "Undo" — and in landscape the table came out 765px tall in a 390px window, putting six of nine seats off-screen entirely. - the action bar's real height is published as `--actionBarHeight` and drives both the page's bottom padding and the toast, replacing a hardcoded 9rem that was wrong the moment the raise pad opened - landscape sizes the felt from the height that's left, not from width. The first attempt did nothing: `padding-bottom` is a percentage of the containing block's *width*, so narrowing the wrap left it full height, and a `max-width: 900px` rule later in the file was overriding it anyway. It's set outright now and lives at the end of the sheet. - on short screens the toast floats over the action bar's status readout rather than over the seats. Nowhere is clear of both; covering something you re-read beats covering something you tap. - headers go compact on short screens, which is where the vertical room came from 13/13 sizes clean, 360x640 through 844x390, portrait and landscape. Also fixes a marker that flickered: `classList.toggle(cls, undefined)` toggles rather than clearing, and `manual` is undefined for positional blinds, so the hand-picked ring appeared on alternate renders. 18 new tests (76 total, all passing). Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
728 lines
30 KiB
JavaScript
728 lines
30 KiB
JavaScript
// All DOM rendering + drag/drop for the table. Talks to the outside world only through
|
||
// the `hooks` object passed to mount(): hooks.save(state) persists, hooks.rerender()
|
||
// re-draws everything (used after structural changes like resizing seats or the chip
|
||
// legend).
|
||
//
|
||
// The table itself only shows compact seat "pucks" — name, cards, total, blind/dealer
|
||
// badges — because a full data-entry form (chip counts, custom fields, etc.) doesn't fit
|
||
// next to nine others arranged in a circle at any screen size. Clicking a puck opens the
|
||
// full form in the seat editor panel instead.
|
||
const Render = {
|
||
state: null,
|
||
hooks: null,
|
||
selectedSeatIndex: null,
|
||
|
||
mount(state, hooks) {
|
||
this.state = state;
|
||
this.hooks = hooks;
|
||
this.selectedSeatIndex = null;
|
||
this.renderAll();
|
||
},
|
||
|
||
renderAll() {
|
||
if (this.selectedSeatIndex !== null && this.selectedSeatIndex >= this.state.seatCount) {
|
||
this.selectedSeatIndex = null;
|
||
}
|
||
this.renderSeats();
|
||
this.renderSeatEditor();
|
||
this.renderChipLegend();
|
||
this.renderNotes();
|
||
this.updatePotBadge();
|
||
this.renderSetupFields();
|
||
this.renderTableHint();
|
||
this.renderStandings();
|
||
this.renderHandHistory();
|
||
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
|
||
},
|
||
|
||
renderSetupFields() {
|
||
document.getElementById("seatCountInput").value = this.state.seatCount;
|
||
document.getElementById("directionSelect").value = this.state.direction;
|
||
document.getElementById("basePotInput").value = this.state.basePotAmount ?? "";
|
||
document.getElementById("dedicatedDealerToggle").checked = !!this.state.dedicatedDealer;
|
||
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
|
||
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
|
||
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
|
||
},
|
||
|
||
renderTableHint() {
|
||
const hint = document.getElementById("tableHint");
|
||
// Gestures are invisible until someone tells you about them, and this is the
|
||
// 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.";
|
||
},
|
||
|
||
renderNotes() {
|
||
const notesEl = document.getElementById("tableNotes");
|
||
if (document.activeElement !== notesEl) notesEl.value = this.state.notes || "";
|
||
},
|
||
|
||
updatePotBadge() {
|
||
// Chips still sitting in front of players on the current street are part of
|
||
// the pot as far as anyone at the table is concerned, so count them — waiting
|
||
// until the street closes would show a stale number for most of every hand.
|
||
const swept = HoldemState.totalPot(this.state);
|
||
const onStreet = this.state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
|
||
document.getElementById("potBadgeAmount").textContent = Money.format(swept + onStreet);
|
||
},
|
||
|
||
// ---- Seats around the table (compact pucks) ----
|
||
|
||
seatPosition(index, count) {
|
||
return this.positionOnRing(index, count, 44, 42);
|
||
},
|
||
|
||
// Bets sit on the felt between their player and the pot, the way chips actually
|
||
// do — closer in than the seats, on the same spoke. The bets layer is inset to
|
||
// match the seat ring (see the CSS), so this one radius holds at every size.
|
||
betPosition(index, count) {
|
||
return this.positionOnRing(index, count, 27, 25);
|
||
},
|
||
|
||
positionOnRing(index, count, rx, ry) {
|
||
const angle = -90 + (360 / count) * index;
|
||
const radians = (angle * Math.PI) / 180;
|
||
return {
|
||
left: 50 + rx * Math.cos(radians),
|
||
top: 50 + ry * Math.sin(radians),
|
||
};
|
||
},
|
||
|
||
// Colour a bet by the biggest chip that fits it, so a table with a chip legend
|
||
// gets bets that look like the chips actually being pushed out.
|
||
chipColorFor(amount) {
|
||
const legend = [...(this.state.chipLegend || [])]
|
||
.filter(chip => Number(chip.value) > 0)
|
||
.sort((a, b) => Number(b.value) - Number(a.value));
|
||
const match = legend.find(chip => amount >= Number(chip.value));
|
||
return (match || legend[legend.length - 1] || {}).color || "#8a8a8a";
|
||
},
|
||
|
||
// ---- Bets on the felt ----
|
||
|
||
makeBetChip(bet) {
|
||
const chip = document.createElement("div");
|
||
chip.className = "betChip";
|
||
|
||
const disc = document.createElement("span");
|
||
disc.className = "betChipDisc";
|
||
disc.style.background = this.chipColorFor(bet);
|
||
|
||
const amount = document.createElement("span");
|
||
amount.className = "betChipAmount";
|
||
amount.textContent = Money.format(bet);
|
||
|
||
chip.appendChild(disc);
|
||
chip.appendChild(amount);
|
||
return chip;
|
||
},
|
||
|
||
// A phone's felt has no room for a third ring: measured every radius between the
|
||
// seats and the pot badge and each one collides with one or the other. So on a
|
||
// narrow screen the bet clings to its own puck instead — same chip, no ring —
|
||
// sitting on whichever side faces the middle of the table.
|
||
renderBets() {
|
||
const layer = document.getElementById("betsLayer");
|
||
if (!layer) return;
|
||
layer.innerHTML = "";
|
||
|
||
const anchorToSeat = window.matchMedia("(max-width: 700px)").matches;
|
||
|
||
this.state.seats.forEach((player, index) => {
|
||
const bet = Betting.committed(player);
|
||
if (bet <= 0) return;
|
||
|
||
const chip = this.makeBetChip(bet);
|
||
const pos = this.betPosition(index, this.state.seatCount);
|
||
|
||
if (anchorToSeat) {
|
||
const seat = document.querySelector(`.seat[data-seat-index="${index}"]`);
|
||
if (!seat) return;
|
||
const seatPos = this.seatPosition(index, this.state.seatCount);
|
||
chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow");
|
||
seat.appendChild(chip);
|
||
return;
|
||
}
|
||
|
||
chip.style.left = `${pos.left}%`;
|
||
chip.style.top = `${pos.top}%`;
|
||
layer.appendChild(chip);
|
||
});
|
||
},
|
||
|
||
puckLabel(player, seatIndex) {
|
||
return HoldemState.displayName(player, seatIndex);
|
||
},
|
||
|
||
renderSeats() {
|
||
const layer = document.getElementById("seatsLayer");
|
||
layer.innerHTML = "";
|
||
const template = document.getElementById("seatTemplate");
|
||
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
|
||
|
||
this.state.seats.forEach((player, index) => {
|
||
const node = template.content.firstElementChild.cloneNode(true);
|
||
const pos = this.seatPosition(index, this.state.seatCount);
|
||
node.style.left = `${pos.left}%`;
|
||
node.style.top = `${pos.top}%`;
|
||
node.dataset.seatIndex = String(index);
|
||
|
||
node.querySelector(".seatChairNumber").textContent = index + 1;
|
||
|
||
const dealerChip = node.querySelector(".dealerChip");
|
||
dealerChip.classList.toggle("active", index === dealerIndex);
|
||
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
|
||
// Tapping the puck is the same act as "Give the button" in the seat menu,
|
||
// so it goes through the same hook — otherwise this one path would move
|
||
// the button without moving the blinds that follow it.
|
||
dealerChip.addEventListener("click", e => {
|
||
e.stopPropagation();
|
||
this.hooks.onGiveButton(index);
|
||
});
|
||
|
||
const sbBadge = node.querySelector(".sbBadge");
|
||
const bbBadge = node.querySelector(".bbBadge");
|
||
sbBadge.classList.toggle("visible", index === sbIndex);
|
||
bbBadge.classList.toggle("visible", index === bbIndex);
|
||
// A blind the dealer named by hand isn't where the button would have put
|
||
// it, so it's marked — otherwise the table looks like it's just wrong.
|
||
// The `!!` matters: getBlindSeats leaves `manual` undefined when the
|
||
// blinds are positional, and classList.toggle with an undefined second
|
||
// argument *flips* the class instead of clearing it, so the marker
|
||
// appeared and disappeared on alternate renders.
|
||
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
|
||
bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex);
|
||
|
||
this.updatePuckContent(node, player, index);
|
||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||
|
||
this.wireSeatGestures(node, index);
|
||
this.wireDragAndDrop(node, index);
|
||
|
||
layer.appendChild(node);
|
||
});
|
||
|
||
this.renderBets();
|
||
},
|
||
|
||
isRedSuit(card) {
|
||
return /[HD]$/i.test(card || "");
|
||
},
|
||
|
||
SUIT_SYMBOLS: { S: "♠", H: "♥", D: "♦", C: "♣" },
|
||
|
||
// "KD" -> "K♦". Stored cards stay two plain characters so every existing reader
|
||
// keeps working; the suit only becomes a symbol on its way to a human.
|
||
formatCard(card) {
|
||
const value = (card || "").toUpperCase();
|
||
if (!value) return "";
|
||
return `${value[0]}${this.SUIT_SYMBOLS[value[1]] || value[1] || ""}`;
|
||
},
|
||
|
||
// An empty slot has to look like something you can tap, so it gets a placeholder
|
||
// rather than going blank.
|
||
paintCardSlot(slot, card) {
|
||
const value = (card || "").toUpperCase();
|
||
slot.classList.toggle("cardRed", this.isRedSuit(value));
|
||
slot.classList.toggle("cardSlotEmpty", !value);
|
||
slot.textContent = value ? this.formatCard(value) : "+";
|
||
},
|
||
|
||
// A chair nobody has claimed: no name typed, not dealt in, nothing in the pot.
|
||
// Worth calling out, because at a short-handed table the open seats are what
|
||
// the dealer is looking for.
|
||
isEmptySeat(player) {
|
||
const named = !!(player.name && player.name.trim());
|
||
const contributing =
|
||
HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount) > 0;
|
||
return !named && !player.inHand && !contributing;
|
||
},
|
||
|
||
updatePuckContent(node, player, index) {
|
||
const empty = this.isEmptySeat(player);
|
||
const nameEl = node.querySelector(".seatPuckName");
|
||
nameEl.textContent = empty ? "Empty" : (player.name || "").trim();
|
||
nameEl.classList.toggle("seatEmptyLabel", empty);
|
||
|
||
const cardsEl = node.querySelector(".seatPuckCards");
|
||
cardsEl.innerHTML = "";
|
||
(player.cards || []).filter(Boolean).forEach((card, i) => {
|
||
if (i > 0) cardsEl.appendChild(document.createTextNode(" "));
|
||
const span = document.createElement("span");
|
||
if (this.isRedSuit(card)) span.className = "cardRed";
|
||
span.textContent = this.formatCard(card);
|
||
cardsEl.appendChild(span);
|
||
});
|
||
// Always this player's total for the hand. What they've just pushed out on this
|
||
// street is shown as a chip on the felt instead — the puck used to flip between
|
||
// the two meanings depending on the moment, which is a bad thing for a number
|
||
// sitting next to a stack size to do.
|
||
const handTotal = Betting.handTotal(player);
|
||
const total = handTotal > 0
|
||
? handTotal
|
||
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
||
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
|
||
|
||
const stackEl = node.querySelector(".seatPuckStack");
|
||
const stack = Betting.stackOf(this.state, player);
|
||
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
|
||
|
||
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
||
node.classList.toggle("seatEmpty", empty);
|
||
node.classList.toggle("seatInactive", !player.inHand && !empty);
|
||
node.classList.toggle("seatFolded", player.folded);
|
||
node.classList.toggle("seatLive", player.inHand && !player.folded);
|
||
},
|
||
|
||
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
|
||
// (which live in a separate panel) never lose focus.
|
||
refreshPuck(index) {
|
||
const node = document.querySelector(`.seat[data-seat-index="${index}"]`);
|
||
if (node) this.updatePuckContent(node, this.state.seats[index], index);
|
||
},
|
||
|
||
// ---- Seat gestures ----
|
||
//
|
||
// One puck, three intents: tap to edit, double-tap to fold, press and hold for
|
||
// the quick menu. The single tap waits out the double-tap window before it
|
||
// fires — a fifth of a second of latency on opening a form, in exchange for a
|
||
// fold that doesn't also fling a sheet up over the table.
|
||
|
||
LONG_PRESS_MS: 500,
|
||
DOUBLE_TAP_MS: 220,
|
||
DRAG_SLOP_PX: 8,
|
||
pendingTap: null,
|
||
|
||
wireSeatGestures(node, index) {
|
||
let longPressTimer = null;
|
||
let longPressFired = false;
|
||
let startX = 0;
|
||
let startY = 0;
|
||
|
||
const cancelLongPress = () => {
|
||
if (longPressTimer) {
|
||
clearTimeout(longPressTimer);
|
||
longPressTimer = null;
|
||
}
|
||
};
|
||
|
||
node.addEventListener("pointerdown", e => {
|
||
longPressFired = false;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
cancelLongPress();
|
||
longPressTimer = setTimeout(() => {
|
||
longPressTimer = null;
|
||
longPressFired = true;
|
||
this.cancelPendingTap();
|
||
node.classList.remove("seatPressing");
|
||
this.hooks.onSeatLongPress(index);
|
||
}, this.LONG_PRESS_MS);
|
||
node.classList.add("seatPressing");
|
||
});
|
||
|
||
// Moving means they're dragging the seat, not holding it.
|
||
node.addEventListener("pointermove", e => {
|
||
if (Math.abs(e.clientX - startX) > this.DRAG_SLOP_PX || Math.abs(e.clientY - startY) > this.DRAG_SLOP_PX) {
|
||
cancelLongPress();
|
||
node.classList.remove("seatPressing");
|
||
}
|
||
});
|
||
|
||
["pointerup", "pointercancel", "pointerleave"].forEach(type => {
|
||
node.addEventListener(type, () => {
|
||
cancelLongPress();
|
||
node.classList.remove("seatPressing");
|
||
});
|
||
});
|
||
node.addEventListener("dragstart", () => {
|
||
cancelLongPress();
|
||
this.cancelPendingTap();
|
||
node.classList.remove("seatPressing");
|
||
});
|
||
|
||
node.addEventListener("click", e => {
|
||
// The long press already did something; don't also treat the release as a tap.
|
||
if (longPressFired) {
|
||
longPressFired = false;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
this.handleSeatTap(index);
|
||
});
|
||
},
|
||
|
||
cancelPendingTap() {
|
||
if (this.pendingTap) {
|
||
clearTimeout(this.pendingTap.timer);
|
||
this.pendingTap = null;
|
||
}
|
||
},
|
||
|
||
handleSeatTap(index) {
|
||
if (this.pendingTap && this.pendingTap.index === index) {
|
||
this.cancelPendingTap();
|
||
this.hooks.onSeatDoubleTap(index);
|
||
return;
|
||
}
|
||
|
||
this.cancelPendingTap();
|
||
const timer = setTimeout(() => {
|
||
this.pendingTap = null;
|
||
this.toggleSeatEditor(index);
|
||
}, this.DOUBLE_TAP_MS);
|
||
this.pendingTap = { index, timer };
|
||
},
|
||
|
||
// Tapping the open seat again closes it — on a phone the editor is a sheet over
|
||
// the table, and the seat you just tapped is the closest thing to hand for
|
||
// dismissing it.
|
||
toggleSeatEditor(index) {
|
||
this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
|
||
this.renderSeats();
|
||
this.renderSeatEditor();
|
||
},
|
||
|
||
wireDragAndDrop(node, index) {
|
||
node.addEventListener("dragstart", e => {
|
||
e.dataTransfer.setData("text/plain", String(index));
|
||
e.dataTransfer.effectAllowed = "move";
|
||
node.classList.add("dragging");
|
||
});
|
||
node.addEventListener("dragend", () => node.classList.remove("dragging"));
|
||
node.addEventListener("dragover", e => {
|
||
e.preventDefault();
|
||
node.classList.add("dropTarget");
|
||
});
|
||
node.addEventListener("dragleave", () => node.classList.remove("dropTarget"));
|
||
node.addEventListener("drop", e => {
|
||
e.preventDefault();
|
||
node.classList.remove("dropTarget");
|
||
const sourceIndex = Number(e.dataTransfer.getData("text/plain"));
|
||
const targetIndex = index;
|
||
if (Number.isNaN(sourceIndex) || sourceIndex === targetIndex) return;
|
||
const seats = this.state.seats;
|
||
[seats[sourceIndex], seats[targetIndex]] = [seats[targetIndex], seats[sourceIndex]];
|
||
this.hooks.save(this.state);
|
||
this.renderSeats();
|
||
if (this.selectedSeatIndex === sourceIndex || this.selectedSeatIndex === targetIndex) {
|
||
this.renderSeatEditor();
|
||
}
|
||
});
|
||
},
|
||
|
||
// ---- Seat editor panel (the full form for whichever seat is selected) ----
|
||
|
||
closeSeatEditor() {
|
||
this.selectedSeatIndex = null;
|
||
this.renderSeats();
|
||
this.renderSeatEditor();
|
||
},
|
||
|
||
renderSeatEditor() {
|
||
const title = document.getElementById("seatEditorTitle");
|
||
const body = document.getElementById("seatEditorBody");
|
||
|
||
// Drives the mobile bottom sheet: the panel only slides up over the table
|
||
// while a seat is actually being edited.
|
||
document.body.classList.toggle("seatSheetOpen", this.selectedSeatIndex !== null);
|
||
|
||
if (this.selectedSeatIndex === null) {
|
||
title.textContent = "Select a seat";
|
||
body.innerHTML = '<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>';
|
||
return;
|
||
}
|
||
|
||
const index = this.selectedSeatIndex;
|
||
const player = this.state.seats[index];
|
||
title.textContent = this.puckLabel(player, index);
|
||
|
||
body.innerHTML = "";
|
||
const template = document.getElementById("seatEditorTemplate");
|
||
const form = template.content.firstElementChild.cloneNode(true);
|
||
|
||
this.wireSeatFields(form, player, index);
|
||
this.renderChipCounts(form, player, index);
|
||
this.renderCustomFields(form, player, index);
|
||
this.renderSeatTotal(form, player);
|
||
|
||
body.appendChild(form);
|
||
},
|
||
|
||
renderSeatTotal(form, player) {
|
||
const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
||
form.querySelector(".seatTotalAmount").textContent = Money.format(total);
|
||
},
|
||
|
||
commitSeatChange(form, player, index) {
|
||
this.hooks.save(this.state);
|
||
this.updatePotBadge();
|
||
this.renderSeatTotal(form, player);
|
||
this.refreshPuck(index);
|
||
},
|
||
|
||
wireSeatFields(form, player, index) {
|
||
const nameInput = form.querySelector(".seatNameInput");
|
||
nameInput.value = player.name;
|
||
nameInput.addEventListener("input", () => {
|
||
player.name = nameInput.value;
|
||
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
|
||
this.commitSeatChange(form, player, index);
|
||
});
|
||
|
||
const inHandCheckbox = form.querySelector(".inHandCheckbox");
|
||
inHandCheckbox.checked = player.inHand;
|
||
inHandCheckbox.addEventListener("change", () => {
|
||
player.inHand = inHandCheckbox.checked;
|
||
if (!player.inHand) player.folded = false;
|
||
foldCheckboxRef.checked = player.folded;
|
||
foldCheckboxRef.disabled = !player.inHand;
|
||
this.commitSeatChange(form, player, index);
|
||
});
|
||
|
||
const foldCheckboxRef = form.querySelector(".foldCheckbox");
|
||
foldCheckboxRef.checked = player.folded;
|
||
foldCheckboxRef.disabled = !player.inHand;
|
||
foldCheckboxRef.addEventListener("change", () => {
|
||
player.folded = foldCheckboxRef.checked;
|
||
this.commitSeatChange(form, player, index);
|
||
});
|
||
|
||
const slots = [form.querySelector(".card1Slot"), form.querySelector(".card2Slot")];
|
||
slots.forEach((slot, cardIdx) => {
|
||
this.paintCardSlot(slot, player.cards[cardIdx]);
|
||
slot.addEventListener("click", () => {
|
||
CardPicker.open(this.state, {
|
||
onPick: (seatIndex, pickedIdx, card) => {
|
||
this.paintCardSlot(slots[pickedIdx], card);
|
||
this.commitSeatChange(form, player, seatIndex);
|
||
},
|
||
}, index, cardIdx);
|
||
});
|
||
});
|
||
|
||
// What they've handed over tonight, shown next to what's in front of them so a
|
||
// cash-out is one subtraction rather than a memory test.
|
||
const boughtIn = Number(player.buyIns) || 0;
|
||
const boughtInEl = form.querySelector(".seatBuyIns");
|
||
boughtInEl.textContent = boughtIn > 0 ? `Bought in ${Money.format(boughtIn)}` : "";
|
||
boughtInEl.hidden = boughtIn <= 0;
|
||
|
||
const stackInput = form.querySelector(".stackInput");
|
||
stackInput.value = player.stack ?? "";
|
||
stackInput.addEventListener("input", () => {
|
||
player.stack = stackInput.value === "" ? null : Number(stackInput.value);
|
||
this.commitSeatChange(form, player, index);
|
||
});
|
||
|
||
const amountInput = form.querySelector(".amountOverrideInput");
|
||
amountInput.value = player.amountOverride ?? "";
|
||
amountInput.addEventListener("input", () => {
|
||
player.amountOverride = amountInput.value === "" ? null : amountInput.value;
|
||
this.commitSeatChange(form, player, index);
|
||
});
|
||
|
||
const addFieldBtn = form.querySelector(".addFieldBtn");
|
||
addFieldBtn.addEventListener("click", () => {
|
||
player.customFields.push({ label: "", value: "" });
|
||
this.renderCustomFields(form, player, index);
|
||
this.hooks.save(this.state);
|
||
});
|
||
},
|
||
|
||
renderChipCounts(form, player, index) {
|
||
const container = form.querySelector(".chipCounts");
|
||
container.innerHTML = "";
|
||
const template = document.getElementById("chipCountTemplate");
|
||
|
||
this.state.chipLegend.forEach(chip => {
|
||
const row = template.content.firstElementChild.cloneNode(true);
|
||
row.querySelector(".chipSwatch").style.background = chip.color;
|
||
row.title = `$${chip.value} chips`;
|
||
const input = row.querySelector(".chipCountInput");
|
||
input.value = player.chipCounts[chip.id] || 0;
|
||
input.addEventListener("input", () => {
|
||
const count = Math.max(0, Math.floor(Number(input.value)) || 0);
|
||
player.chipCounts[chip.id] = count;
|
||
this.commitSeatChange(form, player, index);
|
||
});
|
||
container.appendChild(row);
|
||
});
|
||
},
|
||
|
||
renderCustomFields(form, player, index) {
|
||
const container = form.querySelector(".customFields");
|
||
container.innerHTML = "";
|
||
const template = document.getElementById("customFieldTemplate");
|
||
|
||
player.customFields.forEach((field, fieldIdx) => {
|
||
const row = template.content.firstElementChild.cloneNode(true);
|
||
const labelInput = row.querySelector(".customFieldLabel");
|
||
const valueInput = row.querySelector(".customFieldValue");
|
||
labelInput.value = field.label;
|
||
valueInput.value = field.value;
|
||
labelInput.addEventListener("input", () => {
|
||
field.label = labelInput.value;
|
||
this.hooks.save(this.state);
|
||
});
|
||
valueInput.addEventListener("input", () => {
|
||
field.value = valueInput.value;
|
||
this.hooks.save(this.state);
|
||
});
|
||
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
|
||
player.customFields.splice(fieldIdx, 1);
|
||
this.renderCustomFields(form, player, index);
|
||
this.hooks.save(this.state);
|
||
});
|
||
container.appendChild(row);
|
||
});
|
||
},
|
||
|
||
// ---- Chip legend ----
|
||
|
||
renderChipLegend() {
|
||
const list = document.getElementById("chipLegendList");
|
||
list.innerHTML = "";
|
||
const template = document.getElementById("chipLegendRowTemplate");
|
||
|
||
this.state.chipLegend.forEach(chip => {
|
||
const row = template.content.firstElementChild.cloneNode(true);
|
||
const colorInput = row.querySelector(".chipColorInput");
|
||
const valueInput = row.querySelector(".chipValueInput");
|
||
colorInput.value = chip.color;
|
||
valueInput.value = chip.value;
|
||
|
||
colorInput.addEventListener("input", () => {
|
||
chip.color = colorInput.value;
|
||
this.hooks.save(this.state);
|
||
});
|
||
valueInput.addEventListener("input", () => {
|
||
chip.value = Number(valueInput.value) || 0;
|
||
this.hooks.save(this.state);
|
||
this.renderSeats();
|
||
this.renderSeatEditor();
|
||
this.updatePotBadge();
|
||
});
|
||
row.querySelector(".removeChipColorBtn").addEventListener("click", () => {
|
||
this.state.chipLegend = this.state.chipLegend.filter(c => c.id !== chip.id);
|
||
this.hooks.save(this.state);
|
||
this.renderAll();
|
||
});
|
||
|
||
list.appendChild(row);
|
||
});
|
||
},
|
||
|
||
// ---- Pot breakdown ----
|
||
|
||
// Shared by the action bar (live, mid-hand) and the pot wizard (before winners are
|
||
// picked), because both are showing the dealer the same announcement.
|
||
buildPotBreakdown(pots, returns, nameFor, heading) {
|
||
if (pots.length < 2 && returns.length === 0) return null;
|
||
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "potBreakdown";
|
||
|
||
if (heading) {
|
||
const title = document.createElement("p");
|
||
title.className = "potBreakdownHeading";
|
||
title.textContent = heading;
|
||
wrap.appendChild(title);
|
||
}
|
||
|
||
const addRow = (label, amount, who, className) => {
|
||
const row = document.createElement("div");
|
||
row.className = `potLayer ${className || ""}`;
|
||
|
||
const labelEl = document.createElement("span");
|
||
labelEl.className = "potLayerLabel";
|
||
labelEl.textContent = label;
|
||
|
||
const amountEl = document.createElement("span");
|
||
amountEl.className = "potLayerAmount";
|
||
amountEl.textContent = Money.format(amount);
|
||
|
||
const whoEl = document.createElement("span");
|
||
whoEl.className = "potLayerWho";
|
||
whoEl.textContent = who;
|
||
|
||
row.appendChild(labelEl);
|
||
row.appendChild(amountEl);
|
||
row.appendChild(whoEl);
|
||
wrap.appendChild(row);
|
||
};
|
||
|
||
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.map(nameFor).join(", ")));
|
||
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${nameFor(ret.id)}`, "potLayerReturn"));
|
||
|
||
return wrap;
|
||
},
|
||
|
||
// ---- Session standings ----
|
||
|
||
renderStandings() {
|
||
const list = document.getElementById("standingsList");
|
||
const emptyHint = document.getElementById("standingsEmptyHint");
|
||
const table = document.getElementById("standingsTable");
|
||
const standings = HoldemState.sessionStandings(this.state);
|
||
|
||
list.innerHTML = "";
|
||
emptyHint.hidden = standings.length > 0;
|
||
table.hidden = standings.length === 0;
|
||
|
||
const template = document.getElementById("standingsRowTemplate");
|
||
standings.forEach(row => {
|
||
const node = template.content.firstElementChild.cloneNode(true);
|
||
node.querySelector(".standingsName").textContent = row.name;
|
||
node.querySelector(".standingsIn").textContent = Money.format(row.in);
|
||
node.querySelector(".standingsWon").textContent = Money.format(row.won);
|
||
|
||
const netEl = node.querySelector(".standingsNet");
|
||
netEl.textContent = Money.formatSigned(row.net);
|
||
netEl.classList.toggle("netUp", row.net > 0);
|
||
netEl.classList.toggle("netDown", row.net < 0);
|
||
|
||
list.appendChild(node);
|
||
});
|
||
},
|
||
|
||
// ---- Hand counter + history ----
|
||
|
||
formatHandTime(timestamp) {
|
||
try {
|
||
return new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||
} catch {
|
||
return "";
|
||
}
|
||
},
|
||
|
||
renderHandHistory() {
|
||
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
|
||
|
||
const list = document.getElementById("handHistoryList");
|
||
const emptyHint = document.getElementById("handHistoryEmptyHint");
|
||
const history = this.state.handHistory || [];
|
||
list.innerHTML = "";
|
||
emptyHint.hidden = history.length > 0;
|
||
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
|
||
|
||
const template = document.getElementById("handHistoryRowTemplate");
|
||
history.forEach(entry => {
|
||
const row = template.content.firstElementChild.cloneNode(true);
|
||
row.querySelector(".handHistoryHandNumber").textContent = `Hand #${entry.handNumber}`;
|
||
row.querySelector(".handHistoryPot").textContent = Money.format(entry.pot);
|
||
row.querySelector(".handHistoryTime").textContent = this.formatHandTime(entry.timestamp);
|
||
|
||
const payoutsEl = row.querySelector(".handHistoryPayouts");
|
||
payoutsEl.textContent = entry.payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
||
|
||
list.appendChild(row);
|
||
});
|
||
},
|
||
};
|
||
|
||
if (typeof module !== "undefined" && module.exports) {
|
||
module.exports = { Render };
|
||
}
|