Measured on a 390x844 phone, six-handed, same table before and after:
before after
header 193px 120px
felt starts at 225px 152px
controls on screen 27 18
bordered elements 37 22
text elements 61 49
A quarter of the screen went on chrome before the dealer saw the table.
**The header.** Six equally weighted buttons in two rows, of which two are
per-hand controls. The other four have gone where they belong rather than into
a "more" menu — that relocates crowding instead of removing it. Reset Table is
in the Session panel now, beside Clear history and the save/load pair: it is
the control that throws the night away and it was sitting in the same weight as
Resolve Pot, the most-pressed button in a session. Quick Split is down with
Common rulings, where the other "open something else" link already lived. The
way back to the site is a quiet link at the top, where a back link goes.
The subtitle is gone. It was one sentence of orientation on a tool used
nightly, and the landscape and short-phone rules already hid it — the layout
had decided it was expendable on every screen this is actually used on.
**Teaching copy, once.** Table Setup shipped open, so the first screen was the
most crowded one; it now opens only on a table nobody has set up yet, decided
once at load so it cannot reopen itself under a dealer who just shut it. The
gesture hint under the felt goes after the first deal of the session — keyed to
the hand number, not to bettingOpen, which was the obvious test and the wrong
one: it would have appeared and disappeared twice a hand, shunting the panel
stack up and down all night.
**Borders.** Panels had a border, a lighter background and a shadow — three
ways of drawing one edge. The standings had a rule under every row, which is
what makes a list read as ruled paper. Both drop the line and keep the
grouping; the standings header keeps its rule, because that one marks a change
of kind. Fields and buttons keep theirs: those say "interactive", which is not
decoration.
**The felt itself.** Names wrap to two lines instead of ellipsising, so
"Christopher" and "Mike from work" are readable rather than "Chri..." and "Mike
fro..." — the truncation was eating exactly the half a dealer typed to tell two
Mikes apart. And a stack of $1,234,567 rendered at 7px; it now drops the
thousands separators before it drops the type size, so the same number is eight
characters at 10px with every digit intact. The comment above fitAmount was
right that abbreviating loses precision — but precision the dealer cannot read
is not precision.
Two things tried and reverted, both measured:
* Reserving the second name line made every puck the height of the tallest
(69-73px became a uniform 88). Fifteen pixels on all nine seats is felt
spent on chrome, which is what this round is trying to give back.
* Dropping the name a type step to fit more on one line still did not fit
"Christopher" on the button seat, where the dealer chip takes 22px of the
row — so it cost legibility and bought nothing.
Known limit: a single word too long for a puck breaks across the two lines, and
gets a hyphen only where the browser has hyphenation dictionaries. Real phones
do; headless Chromium does not, so audit screenshots show "Christo / pher". The
whole name is visible either way, which it was not before.
599 tests, 0 failures. 52 layout audits across 13 viewports x 4 table sizes
(2, 6, 9, 12 seats) and 20 across the five overlays: 0 failures, and the action
bar still does not change height mid-hand at any of them. The back link was
caught by the audit at 56x13 when it stopped being a button; its hit area is
44px now, paid for with negative margins so the header keeps the 73px.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
867 lines
41 KiB
JavaScript
867 lines
41 KiB
JavaScript
document.addEventListener("DOMContentLoaded", () => {
|
|
const state = HoldemState.load();
|
|
|
|
// Focus, Tab-trapping and Escape for every overlay, wired once by watching the
|
|
// `hidden` attribute they all already toggle.
|
|
Modal.mount();
|
|
|
|
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
|
|
// by reference to every module at mount time, so it has to be refilled in place —
|
|
// reassigning the variable would leave the renderer and the betting bar pointing at
|
|
// the old object and quietly ignoring the undo.
|
|
const snapshotState = () => JSON.parse(JSON.stringify(state));
|
|
|
|
const restoreState = snapshot => {
|
|
Object.keys(state).forEach(key => delete state[key]);
|
|
Object.assign(state, snapshot);
|
|
HoldemState.save(state);
|
|
Render.renderAll();
|
|
};
|
|
|
|
// A gesture on a seat is a shortcut for something the dealer could do the long
|
|
// way, so each one is undoable the same way the header buttons are.
|
|
const seatAction = (index, message, mutate) => {
|
|
const snapshot = snapshotState();
|
|
mutate(state.seats[index], index);
|
|
HoldemState.save(state);
|
|
Render.renderAll();
|
|
|
|
Toast.show(message, {
|
|
actionLabel: "Undo",
|
|
onAction: () => {
|
|
restoreState(snapshot);
|
|
Toast.show("Undone.", { duration: 2500 });
|
|
},
|
|
duration: 6000,
|
|
});
|
|
};
|
|
|
|
const nameOf = index => HoldemState.displayName(state.seats[index], index);
|
|
|
|
const hooks = {
|
|
save: s => HoldemState.save(s),
|
|
rerender: () => Render.renderAll(),
|
|
|
|
onSeatDoubleTap: index => {
|
|
const player = state.seats[index];
|
|
if (!player.inHand) return;
|
|
if (player.folded) {
|
|
seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; });
|
|
return;
|
|
}
|
|
seatAction(index, `${nameOf(index)} folds.`, () => {
|
|
if (state.actingSeatIndex === index) Betting.fold(state, index);
|
|
else player.folded = true;
|
|
});
|
|
},
|
|
|
|
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
|
|
|
|
// The waiting list, from the Session panel and from an empty chair's menu.
|
|
// The state change goes inside runUndoable, not before it: the snapshot is
|
|
// taken as the first thing that happens, so anything done ahead of it is
|
|
// already baked into what Undo would restore.
|
|
seatWaiting: (id, index) => {
|
|
const entry = (state.waitingList || []).find(row => row.id === id);
|
|
const seat = state.seats[index];
|
|
if (!entry || !seat) {
|
|
Toast.show("They aren't on the list any more.", { duration: 3000 });
|
|
hooks.rerender();
|
|
return;
|
|
}
|
|
const occupied = HoldemState.seatInPlay(seat);
|
|
if (occupied) {
|
|
Toast.show(`${HoldemState.displayName(seat, index)} is already in that seat.`, { duration: 3500 });
|
|
hooks.rerender();
|
|
return;
|
|
}
|
|
runUndoable(`${entry.name} takes seat ${index + 1}. Add their chips when they buy in.`,
|
|
() => HoldemState.seatFromWaitingList(state, id, index));
|
|
},
|
|
|
|
dropWaiting: id => {
|
|
const entry = (state.waitingList || []).find(row => row.id === id);
|
|
if (!entry) return;
|
|
runUndoable(`${entry.name} taken off the waiting list.`,
|
|
() => HoldemState.removeFromWaitingList(state, id));
|
|
},
|
|
onGiveButton: index => seatMenuHooks.makeDealer(index),
|
|
};
|
|
|
|
const seatMenuHooks = {
|
|
fold: index => hooks.onSeatDoubleTap(index),
|
|
unfold: index => seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; }),
|
|
allIn: index => seatAction(index, `${nameOf(index)} is all-in.`, () => Betting.allIn(state, index)),
|
|
toggleDealtIn: index => {
|
|
const player = state.seats[index];
|
|
const dealtIn = player.inHand;
|
|
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
|
|
const alreadyIn = Betting.handTotal(player);
|
|
|
|
// Somebody walking away mid-hand doesn't get their chips back — they're
|
|
// dead money the winner collects. Say so, because the pot total pointedly
|
|
// does not go down and a dealer needs to know that's deliberate.
|
|
const message = dealtIn
|
|
? (alreadyIn > 0
|
|
? `${nameOf(index)} is out — their ${Money.format(alreadyIn)} stays in the pot.`
|
|
: `${nameOf(index)} sits out.`)
|
|
: (handRunning
|
|
? `${nameOf(index)} is in from the next hand.`
|
|
: `${nameOf(index)} is dealt in.`);
|
|
|
|
seatAction(index, message, p => {
|
|
p.inHand = !dealtIn;
|
|
if (!p.inHand) {
|
|
// Their chips stay put; only the fold flag is cleared, since being
|
|
// out of the hand already covers not winning it.
|
|
p.folded = false;
|
|
if (state.actingSeatIndex === index) Betting.passAction(state, index);
|
|
} else if (handRunning) {
|
|
// Joining a hand already in progress means sitting this one out —
|
|
// you can't buy into a pot you haven't been paying for. `folded`
|
|
// is exactly that state, and the next hand clears it.
|
|
p.folded = true;
|
|
}
|
|
});
|
|
},
|
|
|
|
// Somebody is done for the night. The dealer counts their chips out, the chair
|
|
// opens, and both halves of what happened stay on the record — the cash the box
|
|
// took from them, and the chips it handed back. Wiping the seat by hand did
|
|
// neither: it left the bank check declaring their whole stack missing for the
|
|
// rest of the night.
|
|
cashOut: async index => {
|
|
const allowed = HoldemState.canCashOut(state, index);
|
|
if (!allowed.ok) {
|
|
Toast.show(allowed.reason, { duration: 4000 });
|
|
return;
|
|
}
|
|
|
|
const preview = HoldemState.cashOutPreview(state, index);
|
|
const result = preview.net === 0
|
|
? "even for the night"
|
|
: preview.net > 0
|
|
? `up ${Money.format(preview.net)}`
|
|
: `down ${Money.format(Math.abs(preview.net))}`;
|
|
const chips = preview.breakdown ? ` — ${preview.breakdown}` : "";
|
|
|
|
const ok = await Dialog.confirm({
|
|
title: `Cash out ${preview.name}?`,
|
|
message: `Count out ${Money.format(preview.chips)}${chips}. They bought in for ` +
|
|
`${Money.format(preview.boughtIn)}, so they leave ${result}. The chair opens up, and the ` +
|
|
`night's record keeps both their buy-in and this payout so the bank still balances.`,
|
|
confirmLabel: `Pay ${Money.format(preview.chips)}`,
|
|
});
|
|
if (!ok) return;
|
|
|
|
runUndoable(`${preview.name} cashed out for ${Money.format(preview.chips)}.`,
|
|
() => HoldemState.cashOut(state, index));
|
|
},
|
|
|
|
// 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 => {
|
|
// 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
|
|
// money disagree — but that's a real thing happening to real chips, and
|
|
// the dealer gets told rather than finding out later.
|
|
const willRepost = Betting.blindsArePosted(state);
|
|
|
|
seatAction(index, willRepost
|
|
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
|
|
: `Button on ${nameOf(index)}.`, () => {
|
|
state.dealerSeatIndex = index;
|
|
// Putting the button somewhere by hand is the dealer overruling the
|
|
// rotation, dead positions included. Leaving them set would hold a
|
|
// small blind dead at a table whose button has moved out from under it.
|
|
state.deadButton = false;
|
|
state.deadSmallBlind = false;
|
|
if (willRepost) Betting.repostBlinds(state);
|
|
});
|
|
},
|
|
|
|
assignBlind: (index, which) => {
|
|
const blinds = HoldemState.getBlindSeats(state);
|
|
const other = which === "sb" ? blinds.bbIndex : blinds.sbIndex;
|
|
// Naming one blind pins the other where it already is, so the dealer only
|
|
// has to state the part they're changing. If that would land both on one
|
|
// player, the other blind moves to the next seat along.
|
|
let partner = other !== null && other !== undefined && other !== index
|
|
? other
|
|
: Betting.nextLiveIndex(state, index);
|
|
if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index);
|
|
|
|
const label = which === "sb" ? "small blind" : "big blind";
|
|
// Only a hand that is already open has blinds to move. Before the deal this
|
|
// is aiming the next hand, and re-posting would have dealt it — the dealer
|
|
// says who the blind is well before they say "cards".
|
|
const posted = Betting.blindsArePosted(state);
|
|
seatAction(index, posted
|
|
? `${nameOf(index)} posts the ${label}.`
|
|
: `${nameOf(index)} has the ${label} next hand.`, () => {
|
|
state.blindOverride = which === "sb"
|
|
? { sbIndex: index, bbIndex: partner }
|
|
: { sbIndex: partner, bbIndex: index };
|
|
if (posted) Betting.repostBlinds(state);
|
|
});
|
|
},
|
|
|
|
clearBlindOverride: () => {
|
|
const posted = Betting.blindsArePosted(state);
|
|
seatAction(state.dealerSeatIndex, posted
|
|
? "Blinds follow the button again — taken back and re-posted."
|
|
: "Blinds follow the button again.", () => {
|
|
state.blindOverride = null;
|
|
if (posted) Betting.repostBlinds(state);
|
|
});
|
|
},
|
|
|
|
editSeat: index => Render.toggleSeatEditor(index),
|
|
|
|
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
|
|
// Buying chips puts you back in the game — see Betting.buyInDealsThemIn.
|
|
const dealBackIn = Betting.buyInDealsThemIn(state.seats[seatIndex]);
|
|
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
|
|
|
|
const message = `${nameOf(seatIndex)} added ${Money.format(amount)}` +
|
|
(dealBackIn
|
|
? handRunning ? " — in from the next hand." : " — dealt in."
|
|
: ".");
|
|
|
|
seatAction(seatIndex, message, p => {
|
|
// Chips in front and cash in are different questions — the stack moves
|
|
// every hand, what they've handed over only moves when they buy.
|
|
p.stack = Money.round(Betting.stackOf(state, p) + amount);
|
|
p.buyIns = Money.round((Number(p.buyIns) || 0) + amount);
|
|
|
|
if (dealBackIn) {
|
|
p.inHand = true;
|
|
// Same rule "Deal in" follows: you can't buy into a pot you haven't
|
|
// been paying for, and `folded` is exactly that state for one hand.
|
|
p.folded = handRunning;
|
|
}
|
|
});
|
|
}),
|
|
};
|
|
|
|
Render.mount(state, hooks);
|
|
|
|
// Table Setup used to ship open, so the tool's first screen was its most crowded
|
|
// one: seat count, blind direction, dedicated dealer, both blinds, starting stack
|
|
// and two sub-panels, all under a felt the dealer had come here to look at. It is
|
|
// the right panel to have open on a table nobody has touched, and the wrong one to
|
|
// have open on the fourth hand of a night.
|
|
//
|
|
// Decided once, here, rather than on every render — a panel that reopened itself
|
|
// whenever the table emptied would fight the dealer who had just shut it.
|
|
(() => {
|
|
const panel = document.getElementById("tableSetupPanel");
|
|
if (!panel) return;
|
|
const fresh = (Number(state.handNumber) || 1) === 1 &&
|
|
!state.seats.some(seat => HoldemState.seatInPlay(seat));
|
|
panel.open = fresh;
|
|
})();
|
|
|
|
const openPotWizard = () => {
|
|
// Chips still in front of players belong in the pot being priced, so bank them
|
|
// before the wizard reads anyone's contribution.
|
|
Betting.sweepToHandTotals(state);
|
|
hooks.save(state);
|
|
Render.renderAll();
|
|
|
|
PotWizard.open(state, (payouts, { rake = 0 } = {}) => {
|
|
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
|
const withRake = rake > 0 ? `${paid}, house ${Money.format(rake)}` : paid;
|
|
runUndoable(withRake ? `Paid ${withRake}.` : "Hand closed with nothing to pay out.", () => {
|
|
if (rake > 0) {
|
|
state.rakeTaken = Money.round((Number(state.rakeTaken) || 0) + rake);
|
|
}
|
|
// Anything the pot was priced with but nobody actually paid — the
|
|
// base pot and counted chips of the no-live-betting flow — comes off
|
|
// the stacks that owe it before the winner is paid. Otherwise the
|
|
// table gains the whole pot out of nowhere.
|
|
Betting.collectAssumedContributions(state);
|
|
if (payouts.length > 0) {
|
|
HoldemState.recordHand(state, payouts, rake);
|
|
// Winnings go straight back in front of the player, so the next
|
|
// hand's stacks are already right.
|
|
Betting.creditWinnings(state, payouts);
|
|
}
|
|
// Settling a pot ends the hand; it doesn't start the next one. The
|
|
// table goes back to setup so people can sit down or cash out before
|
|
// any blinds are committed.
|
|
HoldemState.startNextHand(state);
|
|
});
|
|
});
|
|
};
|
|
|
|
// A short history of actions taken from the bar, so a misclicked Fold can be taken
|
|
// back — and the one before it, since a dealer usually notices a beat late. Kept in
|
|
// memory only: this is for the mistake you just made, not last night's hand.
|
|
const MAX_UNDO_STEPS = 12;
|
|
const actionHistory = [];
|
|
|
|
ActionBar.mount(state, {
|
|
save: hooks.save,
|
|
rerender: hooks.rerender,
|
|
resolvePot: openPotWizard,
|
|
|
|
beginAction: label => {
|
|
actionHistory.push({ label, snapshot: snapshotState() });
|
|
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
|
|
},
|
|
|
|
lastActionLabel: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].label : null),
|
|
|
|
undoLast: () => {
|
|
const step = actionHistory.pop();
|
|
if (!step) return;
|
|
restoreState(step.snapshot);
|
|
Toast.show(`Took back ${step.label}.`, { duration: 2500 });
|
|
},
|
|
});
|
|
|
|
// Anything that ends the hand makes the per-action history meaningless — the
|
|
// snapshots refer to a hand that no longer exists.
|
|
const clearActionHistory = () => { actionHistory.length = 0; };
|
|
|
|
// Keyboard shortcuts press the very same buttons a dealer would tap, rather than
|
|
// calling the betting engine themselves — there's no second code path to drift out
|
|
// of step with what the bar is actually offering right now.
|
|
const pressInBar = selector => {
|
|
const btn = document.querySelector(`#actionBar ${selector}`);
|
|
if (!btn || btn.disabled) return false;
|
|
btn.click();
|
|
return true;
|
|
};
|
|
|
|
const quickRaiseButton = label =>
|
|
[...document.querySelectorAll("#actionBar .raiseQuickBtn")]
|
|
.find(b => b.querySelector(".actionBtnLabel").textContent === label);
|
|
|
|
Shortcuts.mount({
|
|
fold: () => pressInBar(".actionFold"),
|
|
checkOrCall: () => pressInBar(".actionCheck") || pressInBar(".actionCall"),
|
|
raise: () => pressInBar(".actionRaise"),
|
|
allIn: () => {
|
|
// Open the raise pad first if it isn't already, then take its All-in amount.
|
|
if (!document.querySelector("#actionBar .raisePad") && !pressInBar(".actionRaise")) return false;
|
|
const quick = quickRaiseButton("All-in");
|
|
if (!quick) return false;
|
|
quick.click();
|
|
return true;
|
|
},
|
|
// Enter confirms a raise if one is being entered, otherwise moves the hand on.
|
|
// Dealing is an advance too — Enter should carry a dealer through setup, deal,
|
|
// streets and showdown without reaching for the trackpad.
|
|
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance") || pressInBar(".actionDeal"),
|
|
undo: () => pressInBar(".actionUndo"),
|
|
cancel: () => pressInBar(".raiseCancel"),
|
|
});
|
|
|
|
// Moving to the next hand used to ask "are you sure?" every single time —
|
|
// the most-pressed button in a session, gated behind a system dialog. It now
|
|
// just happens, and the toast offers the way back for the few seconds anyone
|
|
// would notice a misclick. The snapshot is deliberately in-memory only: an
|
|
// undo is for the mistake you just made, not one from last night's session.
|
|
const runUndoable = (message, mutate) => {
|
|
const snapshot = snapshotState();
|
|
mutate();
|
|
// Starting or settling a hand invalidates the per-action history: those
|
|
// snapshots describe a hand that's over. The whole-step Undo below replaces it.
|
|
clearActionHistory();
|
|
hooks.save(state);
|
|
Render.renderAll();
|
|
|
|
Toast.show(message, {
|
|
actionLabel: "Undo",
|
|
onAction: () => {
|
|
restoreState(snapshot);
|
|
Toast.show("Undone.", { duration: 2500 });
|
|
},
|
|
});
|
|
};
|
|
|
|
// A seat that has anything of a player in it — a name, chips, cash in, or a hand
|
|
// in progress. Shrinking the table throws these away permanently, and it also
|
|
// orphans them in the session standings, where they keep their winnings but lose
|
|
// the stack and buy-in the settle-up is worked out from.
|
|
// One question, asked in one place. This predicate existed here, in the seat menu,
|
|
// in the action bar and twice in the state module, in four slightly different
|
|
// spellings — and a chair that counts as occupied for the waiting list but empty
|
|
// for the seat count is how somebody's chips get thrown away.
|
|
const seatHasSomeoneIn = player => HoldemState.seatInPlay(player);
|
|
|
|
document.getElementById("seatCountInput").addEventListener("change", async e => {
|
|
const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount));
|
|
const losing = state.seats.slice(requested).filter(seatHasSomeoneIn);
|
|
|
|
if (losing.length > 0) {
|
|
const who = losing
|
|
.map((p, i) => HoldemState.displayName(p, requested + state.seats.slice(requested).indexOf(p)))
|
|
.join(", ");
|
|
const ok = await Dialog.confirm({
|
|
title: `Remove ${losing.length} ${losing.length === 1 ? "player" : "players"}?`,
|
|
message: `Going down to ${requested} seats takes ${who} off the table along with their chips and buy-ins, and their session totals stop adding up.`,
|
|
confirmLabel: "Remove them",
|
|
danger: true,
|
|
});
|
|
if (!ok) {
|
|
e.target.value = state.seatCount;
|
|
return;
|
|
}
|
|
}
|
|
|
|
runUndoable(`Table set to ${requested} seats.`, () => HoldemState.resizeSeats(state, requested));
|
|
});
|
|
|
|
document.getElementById("directionSelect").addEventListener("change", e => {
|
|
state.direction = e.target.value;
|
|
hooks.save(state);
|
|
Render.renderSeats();
|
|
});
|
|
|
|
document.getElementById("basePotInput").addEventListener("input", e => {
|
|
state.basePotAmount = e.target.value === "" ? null : Number(e.target.value);
|
|
hooks.save(state);
|
|
Render.renderAll();
|
|
});
|
|
|
|
document.getElementById("dedicatedDealerToggle").addEventListener("change", e => {
|
|
state.dedicatedDealer = e.target.checked;
|
|
hooks.save(state);
|
|
Render.renderSeats();
|
|
Render.renderTableHint();
|
|
});
|
|
|
|
document.getElementById("tableNotes").addEventListener("input", e => {
|
|
state.notes = e.target.value;
|
|
hooks.save(state);
|
|
});
|
|
|
|
document.getElementById("addChipColorBtn").addEventListener("click", () => {
|
|
state.chipLegend.push({
|
|
id: HoldemState.makeId("chip"),
|
|
color: "#888888",
|
|
value: 1,
|
|
});
|
|
hooks.save(state);
|
|
Render.renderAll();
|
|
});
|
|
|
|
// A misdeal. Everything in the middle goes back to whoever put it there, and the
|
|
// same button deals the same hand again.
|
|
// The seats holding the table's starting stack, written down as cash taken.
|
|
document.getElementById("bankRecordBuyInsBtn").addEventListener("click", async () => {
|
|
const starting = HoldemState.seatsOwingCash(state).filter(row => row.fromStartingStack);
|
|
if (starting.length === 0) return;
|
|
const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
|
|
|
|
const ok = await Dialog.confirm({
|
|
title: `Write down ${Money.format(total)} taken?`,
|
|
message: `${starting.map(row => `${row.name} ${Money.format(row.chips)}`).join(", ")} — the starting stack ` +
|
|
`each of them is playing with. Only do this if the cash is actually in the box.`,
|
|
confirmLabel: "Write it down",
|
|
});
|
|
if (!ok) return;
|
|
|
|
runUndoable(`${Money.format(total)} recorded from ${starting.length} ${starting.length === 1 ? "player" : "players"}.`,
|
|
() => HoldemState.recordStartingBuyIns(state));
|
|
});
|
|
|
|
document.getElementById("waitingListForm").addEventListener("submit", e => {
|
|
e.preventDefault();
|
|
const field = document.getElementById("waitingNameInput");
|
|
const name = field.value.trim();
|
|
if (!name) return;
|
|
field.value = "";
|
|
runUndoable(`${name} added to the waiting list.`, () => HoldemState.addToWaitingList(state, name));
|
|
field.focus();
|
|
});
|
|
|
|
document.getElementById("misdealBtn").addEventListener("click", async () => {
|
|
const inPlay = HoldemState.unresolvedContributions(state);
|
|
const acted = state.seats.filter(p => p.hasActed).length;
|
|
|
|
const ok = await Dialog.confirm({
|
|
title: "Misdeal — take the hand back?",
|
|
message: inPlay > Betting.EPSILON_CHIPS
|
|
? `${Money.format(inPlay)} goes back to the players who put it in, cards are cleared, and the same ` +
|
|
`button deals again.${acted > 1 ? " Note that most rooms let a deal stand once two players have acted." : ""}`
|
|
: "Cards are cleared and the same button deals again. Nothing has been put in yet, so no chips move.",
|
|
confirmLabel: "Take it back",
|
|
});
|
|
if (!ok) return;
|
|
|
|
// The label is both the toast a dealer reads now and the words on the undo
|
|
// control afterwards, so it says what happened to the money rather than naming
|
|
// the ruling: "the misdeal" told a table nothing about where their chips went.
|
|
const label = inPlay > Betting.EPSILON_CHIPS
|
|
? `Misdeal — ${Money.format(inPlay)} returned, same button deals again.`
|
|
: "Misdeal — nothing was in yet, same button deals again.";
|
|
runUndoable(label, () => Betting.takeHandBack(state));
|
|
});
|
|
|
|
document.getElementById("nextHandBtn").addEventListener("click", async () => {
|
|
// Moving on wipes every seat's contribution, so an unresolved pot is simply
|
|
// destroyed — measured at a four-handed table, pressing this with $8 in the
|
|
// middle took the table from 800 chips to 792, and the toast said nothing
|
|
// about it. Chips only ever leave this tool because a dealer meant them to.
|
|
const unresolved = HoldemState.unresolvedContributions(state);
|
|
|
|
if (unresolved > Betting.EPSILON_CHIPS) {
|
|
const ok = await Dialog.confirm({
|
|
title: `Throw away ${Money.format(unresolved)}?`,
|
|
message: "There's still money in the middle from this hand. Starting the next one takes it off the table without paying anybody — use Resolve Pot if somebody won it.",
|
|
confirmLabel: "Throw it away",
|
|
danger: true,
|
|
});
|
|
if (!ok) return;
|
|
}
|
|
|
|
// Say where the button landed so the dealer can confirm the rotation without
|
|
// hunting for the badge. Asked of the rotation itself rather than guessed at as
|
|
// "the next chair along" — which was only ever right at a table with nobody
|
|
// missing, and is the exact thing the dead button makes untrue.
|
|
const rotation = HoldemState.nextButtonRotation(state);
|
|
const nextDealer = rotation.buttonIndex !== null
|
|
? rotation.buttonIndex
|
|
: HoldemState.buttonSeatForDeal({ ...state, dealerSeatIndex: HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1) });
|
|
const where = rotation.deadButton
|
|
? `dead button on seat ${nextDealer + 1}`
|
|
: state.dedicatedDealer
|
|
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
|
|
: `button on seat ${nextDealer + 1}`;
|
|
const discarded = unresolved > Betting.EPSILON_CHIPS
|
|
? ` ${Money.format(unresolved)} taken off the table.`
|
|
: "";
|
|
|
|
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
|
|
// Written down as well as announced. The toast is a moment; the bank check
|
|
// is asked at cash-out, and until now it saw only that the chips were gone
|
|
// and reported them as missing.
|
|
if (unresolved > Betting.EPSILON_CHIPS) {
|
|
state.discarded = Money.round((Number(state.discarded) || 0) + unresolved);
|
|
}
|
|
HoldemState.startNextHand(state);
|
|
});
|
|
});
|
|
|
|
document.getElementById("resetTableBtn").addEventListener("click", async () => {
|
|
const ok = await Dialog.confirm({
|
|
title: "Reset the whole table?",
|
|
message: "Every seat, chip colour and note goes back to defaults. The hand history and session standings are cleared too.",
|
|
confirmLabel: "Reset table",
|
|
danger: true,
|
|
});
|
|
if (!ok) return;
|
|
|
|
HoldemState.clear();
|
|
Object.assign(state, HoldemState.createDefault());
|
|
clearActionHistory();
|
|
Render.mount(state, hooks);
|
|
Toast.show("Table reset.", { duration: 2500 });
|
|
});
|
|
|
|
// A pre-filled amount is a suggestion, so reaching for the box replaces it.
|
|
//
|
|
// Delegated from the document rather than wired per field: the raise pad, the
|
|
// add-chips sheet and the seat editor all build their inputs fresh every render,
|
|
// and a box added later should not have to remember to ask for this.
|
|
document.addEventListener("focusin", event => {
|
|
const field = event.target;
|
|
if (!Money.selectsOnFocus(field)) return;
|
|
if (!field.value) return;
|
|
// After the browser has finished placing its own caret, or it wins the race
|
|
// and the selection collapses again the moment the tap completes.
|
|
setTimeout(() => {
|
|
if (document.activeElement !== field) return;
|
|
try {
|
|
field.select();
|
|
} catch {
|
|
// A number input refuses setSelectionRange in some browsers; select()
|
|
// is the one that works on both, and if neither does the field is
|
|
// simply left as it was.
|
|
}
|
|
}, 0);
|
|
});
|
|
|
|
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
|
|
|
|
[
|
|
["smallBlindInput", "smallBlind"],
|
|
["bigBlindInput", "bigBlind"],
|
|
["defaultStackInput", "defaultStack"],
|
|
].forEach(([id, key]) => {
|
|
document.getElementById(id).addEventListener("input", e => {
|
|
state[key] = e.target.value === "" ? null : Number(e.target.value);
|
|
hooks.save(state);
|
|
Render.renderAll();
|
|
});
|
|
});
|
|
|
|
// The house's cut. Kept as a group rather than three loose fields, because
|
|
// migrate() guarantees the shape and half a rake object would be a table nobody
|
|
// could read back.
|
|
const readRake = () => {
|
|
const percent = Number(document.getElementById("rakePercentInput").value);
|
|
const cap = Number(document.getElementById("rakeCapInput").value);
|
|
state.rake = {
|
|
percent: Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0,
|
|
cap: Number.isFinite(cap) && cap > 0 ? Money.round(cap) : null,
|
|
noFlopNoDrop: document.getElementById("rakeNoFlopInput").checked,
|
|
};
|
|
hooks.save(state);
|
|
Render.renderAll();
|
|
};
|
|
["rakePercentInput", "rakeCapInput"].forEach(id => {
|
|
document.getElementById(id).addEventListener("input", readRake);
|
|
});
|
|
document.getElementById("rakeNoFlopInput").addEventListener("change", readRake);
|
|
|
|
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
|
|
|
|
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
|
|
document.getElementById("addChipsCancelBtn").addEventListener("click", () => AddChips.close());
|
|
document.getElementById("addChipsOverlay").addEventListener("click", e => {
|
|
if (e.target.id === "addChipsOverlay") AddChips.close();
|
|
});
|
|
document.getElementById("addChipsInput").addEventListener("keydown", e => {
|
|
if (e.key === "Enter") AddChips.confirm();
|
|
});
|
|
|
|
// Escape is Modal's job now, for this sheet and the five others that never had it.
|
|
const rulingsOverlay = document.getElementById("rulingsOverlay");
|
|
const closeRulings = () => { rulingsOverlay.hidden = true; };
|
|
// The one time the browser refuses to save, say so out loud as well as leaving the
|
|
// standing warning in the Session panel. Set before the first render so a failure
|
|
// during start-up is caught too.
|
|
// Another tab writing to the same table. Said once, with the way out attached —
|
|
// reloading here adopts whatever that tab has done, which is the only resolution
|
|
// that doesn't throw somebody's work away.
|
|
HoldemState.onOtherTab = () => {
|
|
Toast.show(
|
|
"This table is open in another tab. Whichever acts last wins.",
|
|
{ actionLabel: "Reload", onAction: () => window.location.reload(), duration: 20000 });
|
|
Render.renderStorageWarning();
|
|
};
|
|
HoldemState.watchOtherTabs();
|
|
|
|
HoldemState.onSaveFailed = () => {
|
|
Toast.show(
|
|
"This table isn't being saved — refreshing would lose it. Keep this tab open.",
|
|
{ duration: 12000 });
|
|
Render.renderStorageWarning();
|
|
};
|
|
|
|
// 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.
|
|
//
|
|
// 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);
|
|
rulingsOverlay.addEventListener("click", e => {
|
|
if (e.target === rulingsOverlay) closeRulings();
|
|
});
|
|
|
|
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
|
|
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
|
|
if (e.target.id === "seatMenuOverlay") SeatMenu.close();
|
|
});
|
|
|
|
document.getElementById("cardPickerCloseBtn").addEventListener("click", () => CardPicker.close());
|
|
document.getElementById("cardPickerClearBtn").addEventListener("click", () => CardPicker.clearSlot());
|
|
document.getElementById("cardPickerOverlay").addEventListener("click", e => {
|
|
if (e.target.id === "cardPickerOverlay") CardPicker.close();
|
|
});
|
|
|
|
document.getElementById("closePotWizardBtn").addEventListener("click", () => PotWizard.close());
|
|
document.getElementById("potWizardOverlay").addEventListener("click", e => {
|
|
if (e.target.id === "potWizardOverlay") PotWizard.close();
|
|
});
|
|
|
|
document.getElementById("clearHistoryBtn").addEventListener("click", async () => {
|
|
const ok = await Dialog.confirm({
|
|
title: "Clear the hand history?",
|
|
message: "This wipes every logged hand and the session standings, and puts the hand counter back to #1. Export first if anyone might want the numbers.",
|
|
confirmLabel: "Clear history",
|
|
danger: true,
|
|
});
|
|
if (!ok) return;
|
|
|
|
HoldemState.clearHistory(state);
|
|
Render.historyFilter = "";
|
|
document.getElementById("handHistoryFilter").value = "";
|
|
hooks.save(state);
|
|
Render.renderAll();
|
|
Toast.show("Hand history cleared.", { duration: 2500 });
|
|
});
|
|
|
|
document.getElementById("saveTableBtn").addEventListener("click", () => {
|
|
const blob = new Blob([HoldemState.toBackup(state)], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = HoldemState.backupFileName();
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
Toast.show(`Table saved as ${link.download}.`, { duration: 3500 });
|
|
});
|
|
|
|
document.getElementById("loadTableBtn").addEventListener("click", () => {
|
|
document.getElementById("loadTableInput").click();
|
|
});
|
|
|
|
document.getElementById("loadTableInput").addEventListener("change", async e => {
|
|
const file = e.target.files && e.target.files[0];
|
|
// Cleared straight away so picking the same file twice still fires a change.
|
|
e.target.value = "";
|
|
if (!file) return;
|
|
|
|
let text;
|
|
try {
|
|
text = await file.text();
|
|
} catch {
|
|
Toast.show("That file couldn't be read.", { duration: 4000 });
|
|
return;
|
|
}
|
|
|
|
const result = HoldemState.fromBackup(text);
|
|
if (result.error) {
|
|
Toast.show(result.error, { duration: 5000 });
|
|
return;
|
|
}
|
|
|
|
// Loading replaces everything: stacks, buy-ins, the button, the log. Naming
|
|
// what is about to be lost is the difference between a dealer choosing that
|
|
// and discovering it — and the current table might be the live one.
|
|
const here = HoldemState.sessionStandings(state).length;
|
|
const incoming = HoldemState.sessionStandings(result.table).length;
|
|
const when = result.savedAt ? new Date(result.savedAt) : null;
|
|
const saidWhen = when && !Number.isNaN(when.getTime())
|
|
? ` It was saved ${when.toLocaleString()}.`
|
|
: "";
|
|
const ok = await Dialog.confirm({
|
|
title: "Replace this table?",
|
|
message:
|
|
`The file has a table with ${incoming} ${incoming === 1 ? "player" : "players"} in it.${saidWhen} ` +
|
|
`Loading it replaces the table on screen — ${here} ${here === 1 ? "player" : "players"}, ` +
|
|
`${(state.handHistory || []).length} logged ${(state.handHistory || []).length === 1 ? "hand" : "hands"}. ` +
|
|
`Undo will bring it back for a few seconds; after that it is gone.`,
|
|
confirmLabel: "Load it",
|
|
danger: true,
|
|
});
|
|
if (!ok) return;
|
|
|
|
runUndoable("loading a saved table", () => {
|
|
// Replace the contents rather than the reference: everything on the page
|
|
// holds a pointer to this object.
|
|
Object.keys(state).forEach(key => { delete state[key]; });
|
|
Object.assign(state, result.table);
|
|
});
|
|
});
|
|
|
|
// The filter is how the dealer looks at the log, not something about the table, so
|
|
// it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other
|
|
// tab. Clearing the history clears it too, since there is nothing left to filter.
|
|
document.getElementById("handHistoryFilter").addEventListener("input", e => {
|
|
Render.historyFilter = e.target.value;
|
|
Render.renderHandHistory();
|
|
});
|
|
|
|
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
|
if ((state.handHistory || []).length === 0) return;
|
|
// Always the whole night. A filter is for reading the panel; an export that
|
|
// quietly dropped the hands scrolled past would be a record with holes in it,
|
|
// and nothing on the CSV would say so.
|
|
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state),
|
|
{ notes: state.notes });
|
|
if (Render.historyFilter) {
|
|
Toast.show(`Exported all ${state.handHistory.length} hands, not just the ${
|
|
state.handHistory.filter(h => Render.handMatchesFilter(h, Render.historyFilter)).length} shown.`,
|
|
{ duration: 3500 });
|
|
}
|
|
});
|
|
|
|
// The load happens on the first line of this file, long before there's a Toast to
|
|
// show it in, so this is checked here rather than wired as a callback. The standing
|
|
// warning in the Session panel doesn't need it — that reads the flag every render —
|
|
// but an empty table is a thing a dealer needs told, not left to notice.
|
|
if (HoldemState.loadFailed) {
|
|
Toast.show(
|
|
"The saved table couldn't be read, so this one started empty.",
|
|
{ duration: 12000 });
|
|
}
|
|
});
|