Files
JoshHeaps.Net/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
T
jheapsandClaude Opus 5 edf159325b Give the seat holding the button its own middle back
On a fresh table the button is on seat 1, and seat 1 is where the first player
goes. Tapping it did nothing.

The dealer chip is a 22px disc, and a 22px disc is half the 44px a fingertip
needs, so it carried an invisible pad to catch the tap. The pad was moved twice
looking for somewhere to grow that wasn't already spoken for: outward first,
where it hung over the seat next door and gave that seat the button; then
inward, over "its own seat, which is a large target that can spare the corner".

The seat could not spare it. Measured on a 390px phone, a 72x46 puck: the pad
ran from the puck's own centre to its right edge and from its top to 3px off its
bottom, and the chip's handler calls stopPropagation. The seat holding the
button kept 52% of its face — 130 of 276 sampled points went to the chip, its
exact middle among them. Every other seat kept 97%. The button moves every hand,
so the dead puck went round the table with it.

And the chip it went to had nothing to say. Its only answer is "give the button
to this seat", and this is the seat that has it. The seat menu already knew that
and hides "Give the button" there; mid-hand the same no-op would have taken the
blinds off the felt and re-posted the same chips to announce that nothing
happened.

So the pad is gone rather than moved again — a fixed pad cannot be safe at every
puck size, and no finger reaches any chip now: the ghosts are pointer-events:
none without hover, and the one live chip is this one, which is now inert to the
pointer and still there to read. Giving the button is the long-press menu on
touch and the revealed ghost on a mouse, both unchanged. makeDealer returns
early on the seat that already has it, so the keyboard can't re-post blinds to
move the button nowhere either.

The sweep had a check for exactly this — "a seat's centre belongs to that seat",
written when the pad was reaching outward — and it ran clean through all of it,
because `seat.contains(hit)` waves through anything inside the seat and the chip
is inside the seat. The check written for this bug could not see the second half
of it. A control inside a seat is a rival for the tap now, and against the old
stylesheet it fails seven scenarios.

982 unit tests, 6 of them new; the layout sweep is 320 audits with the one
pre-existing failure (a $1,300 chip behind seat 11 on a twelve-handed 360x640
all-in).

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 15:10:55 -06:00

1217 lines
60 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();
// ---- A scroll is not an edit ----
//
// A focused `<input type="number">` is a spinbutton, and a browser lets the mouse
// wheel drive it. Every other way of driving one is deliberate: the arrow keys, the
// little stepper arrows, typing. Scrolling is the one actuation nobody means — the
// dealer is reaching for another panel and the cursor happens to be over a field
// they touched a moment ago.
//
// Measured on the running page, at 1280x900 with the sidebar open. Focus the small
// blind, scroll: $5 becomes $5.50. Focus a seat's stack, scroll: $500 becomes $501.
// Both are written straight through to the saved table, because these fields save on
// `input` like everything else here. Nothing is announced, nothing looks wrong, and
// the tool has invented a chip.
//
// Blur rather than preventDefault. Cancelling the event would stop the value moving
// and stop the page moving with it, which turns a silent bug into a sidebar that
// mysteriously will not scroll. Dropping focus lets the scroll do what the dealer
// asked for and leaves the number alone — and losing focus on a field you have just
// scrolled away from is honest rather than surprising.
//
// Captured at the document, so it covers the fields the seat editor and the chip
// legend build on the fly as well as the ones in the markup.
document.addEventListener("wheel", event => {
if (Money.wheelWouldEdit(event.target, document.activeElement)) event.target.blur();
}, { passive: true });
// 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),
// Removing a chip colour, which is only sometimes a small thing.
//
// A colour nobody has counted is a row in a legend, and taking it out should
// cost a click and nothing else. A colour seats are holding is money: the
// legend is what prices their chips, so the row leaving takes their chips off
// the table with it — and it cannot be put back, because a colour added again
// is a new one and the counts belong to the old.
//
// So the friction is on the second case only. A confirmation that fires every
// time is one nobody reads by the third night, and the one that matters here
// would go the same way as the four before it that didn't.
removeChipColor: async id => {
const usage = HoldemState.chipColorUsage(state, id);
if (!usage.chip) return;
const name = Number(usage.chip.value) > 0
? `${Money.format(usage.chip.value)} chip`
: "chip colour";
if (usage.total !== 0) {
const seatCount = `${usage.seats.length} ${usage.seats.length === 1 ? "seat" : "seats"}`;
const held = usage.seats.map(seat => `${seat.name} ${seat.count}`).join(", ");
const ok = await Dialog.confirm({
// Both directions are named rather than only the loss. Going up is
// the odd one — seats falling back to the table's standing ante once
// their chips are gone — and a dealer who is told the total will move
// and then finds it moved the other way has been told something false.
title: usage.total > 0
? `Remove the ${name} and ${Money.format(usage.total)} with it?`
: `Remove the ${name} and put the table up ${Money.format(-usage.total)}?`,
message:
`${usage.chips} of them are counted onto ${seatCount} — ${held} — and the table ` +
`${usage.total > 0 ? "loses" : "gains"} ${Money.format(Math.abs(usage.total))} ` +
`when the colour goes. Undo will put it back; adding the colour again will not.`,
confirmLabel: "Remove it",
danger: usage.total > 0,
});
if (!ok) return;
}
runUndoable(
usage.total > 0
? `${name} removed — ${Money.format(usage.total)} off the table.`
: usage.total < 0
? `${name} removed — table up ${Money.format(-usage.total)}.`
: `${name} removed.`,
() => HoldemState.removeChipColor(state, id));
},
};
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 => {
// Moving the button to where it already is is not a move. Saying so costs a
// toast nobody asked for, and mid-hand it would take the blinds back off the
// felt and re-post the same chips to say nothing happened.
if (index === state.dealerSeatIndex) return;
// 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,
keepStorage: () => askToKeepStorage(),
// The first thing a dealer does on a table nobody is sitting at. Opens the
// seat editor on the first free chair — the same panel tapping the seat gives
// them, so the button teaches the gesture rather than replacing it.
seatFirstPlayer: () => {
const index = state.seats.findIndex(seat => !HoldemState.seatInPlay(seat));
Render.toggleSeatEditor(index < 0 ? 0 : index);
const panel = document.getElementById("seatEditorPanel");
Render.bringIntoView(panel, { block: "nearest" });
},
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("trackBoardToggle").addEventListener("change", e => {
state.trackBoard = e.target.checked;
// Turning it off leaves whatever was entered in place rather than wiping it —
// a dealer who taps this by mistake mid-hand can tap it back and the flop is
// still there. The next hand clears it the way it clears everything else.
hooks.save(state);
Render.renderAll();
});
document.getElementById("tableNotes").addEventListener("input", e => {
state.notes = e.target.value;
hooks.save(state);
// The one field on the page that must not go through renderAll: it would write
// the textarea's own value back into it on every keystroke and put the caret at
// the end. The panel's shut-state summary still has to keep up, though, and it
// is the only thing on the page that reads the notes — so it gets called on its
// own. It touches the six <span>s in the sidebar headers and nothing else.
Render.renderPanelGlances();
});
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);
});
// Focus that lands underneath something fixed is moved out from under it.
//
// scroll-padding-bottom in the stylesheet is half of WCAG 2.4.11 and only half. It
// works when the browser has to scroll to reach the control at all — then the
// padding tells it where to stop. A control the browser already considers visible is
// one it will not scroll to at all, and a fixed overlay sitting on top of it does
// not change that: measured on a 1280x900 desktop, tabbing to Common rulings while a
// toast was up left it 100% covered, and scrollIntoView({block:"nearest"}) moved
// nothing, because "nearest" was already satisfied.
//
// So the overlap is worked out here and the page is scrolled by exactly it. Named
// overlays rather than every fixed element on the page, because this runs on every
// focus change and walking the whole DOM to find three known things would be work
// done ten times a second to reach the same answer. Anything inside one of them is
// left alone — the bar's own buttons are meant to be on top of the page.
const FIXED_OVERLAYS = "#actionBar, #toastHost, #seatEditorPanel";
document.addEventListener("focusin", event => {
const el = event.target;
if (!el || typeof el.getBoundingClientRect !== "function") return;
if (el.closest && el.closest(FIXED_OVERLAYS)) return;
const box = el.getBoundingClientRect();
if (box.width === 0 || box.height === 0) return;
let lift = 0;
document.querySelectorAll(FIXED_OVERLAYS).forEach(node => {
const style = getComputedStyle(node);
if (node.hidden || style.visibility === "hidden" || style.display === "none") return;
if (style.position !== "fixed") return;
const over = node.getBoundingClientRect();
if (over.width === 0 || over.height === 0) return;
// Only what is actually in the way: an overlay off to one side, like the
// rail on a phone held sideways, is not covering a control in the middle.
if (box.left >= over.right || box.right <= over.left) return;
if (box.bottom <= over.top || box.top >= over.bottom) return;
lift = Math.max(lift, box.bottom - over.top + 8);
});
// Instant rather than smooth. This is the page getting out of the way of
// something the dealer has already arrived at, and animating it would put the
// control they are typing into in motion under them.
if (lift > 0) window.scrollBy({ top: lift, behavior: "auto" });
// Scrolling is not always enough. The page only has as much room below its
// content as the padding under the app gives it, and that is sized for the bar
// — the toast stands on top of the bar, so a control at the very end of the page
// can be lifted clear of one and not the other. Measured at 1280x900: 94px of
// lift wanted, 26px of page to do it in.
//
// Sizing the padding for the toast as well would fix it by making the page grow
// and shrink by 64px every time anything is announced, which is a worse thing to
// do to a dealer than the fault. So the toast gives way instead: it is an
// eight-second message about something that has already happened, and it is
// standing on a control somebody has just navigated to. Only after the scroll
// has been tried, so a toast that is not in the way is left alone.
const toast = document.querySelector("#toastHost .toast");
if (!toast) return;
const after = el.getBoundingClientRect();
const over = toast.getBoundingClientRect();
const stillCovered = after.left < over.right && after.right > over.left &&
after.bottom > over.top && after.top < over.bottom;
if (stillCovered) Toast.dismiss();
});
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());
// The two ways out of the sheet that every other overlay in the tool already had.
//
// Modal.mount() covers the six whose ids end in "Overlay"; this one cannot join
// them, because above 700px it is not a sheet at all — it is the sidebar panel the
// seat's fields live in, and a dialog is the wrong thing for Modal to make of it.
// So the two behaviours it was missing are wired here against the same state the
// scrim is drawn from.
document.getElementById("seatSheetScrim")
.addEventListener("click", () => Render.closeSeatEditor());
// Not in capture, and behind Modal's own check: a card picker opened *from* the
// sheet is on top of it, and Escape there means "stop picking a card", not "throw
// the seat away". Modal stops that event before this listener ever sees it; the
// guard is for the case where it hasn't, and costs nothing.
document.addEventListener("keydown", event => {
if (event.key !== "Escape") return;
if (Modal.open.length > 0) return;
if (!document.body.classList.contains("seatSheetOpen")) return;
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", () => {
Render.bringIntoView(section, { block: "start" });
// 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 });
}
// ---- Keeping the screen on ----
//
// The lock itself is taken and dropped in renderAll, because that is the one place
// every start and end of a hand already passes through. All that lives here is the
// dealer's say in it: the toggle, and the way out offered by the toast the first
// time it takes hold.
WakeLock.hooks = {
turnOff: () => {
state.keepScreenAwake = false;
hooks.save(state);
Render.renderAll();
Toast.show("The screen will sleep as usual. Table Setup has it if you want it back.");
},
};
WakeLock.start(() => state);
document.getElementById("keepScreenAwakeToggle").addEventListener("change", e => {
state.keepScreenAwake = e.target.checked;
hooks.save(state);
// renderAll does the acquiring and releasing, so turning this off mid-hand
// drops the lock in the same tick rather than at the end of the hand.
Render.renderAll();
});
// ---- Asking the browser to keep the night ----
//
// Reported rather than announced. A dealer opening Save & reset is already thinking
// about losing the table, and that is the only moment this is worth a sentence.
const showStorageDurability = async () => {
const hint = document.getElementById("storageDurabilityHint");
const button = document.getElementById("keepStorageBtn");
if (!hint || !button) return;
if (!HoldemState.supportsDurableStorage()) {
// Nothing to say and nothing to offer. A browser this old still keeps the
// table exactly as well as it did yesterday.
hint.hidden = true;
button.hidden = true;
return;
}
const durable = await HoldemState.isDurable();
hint.hidden = false;
hint.textContent = durable
? "This browser has promised to keep it: the table survives the phone running short on space."
: "This browser may clear the table on its own if the phone runs short on space.";
button.hidden = durable;
};
// Fires on the first deal of every session, which costs nothing after the first
// time it is granted — persisted() is checked before persist() is asked for.
const askToKeepStorage = async () => {
await HoldemState.requestDurable();
showStorageDurability();
};
document.getElementById("keepStorageBtn").addEventListener("click", async () => {
const granted = await HoldemState.requestDurable();
showStorageDurability();
// Said either way, because a button that sometimes does nothing visible is a
// button a dealer presses four more times.
Toast.show(granted
? "This browser will keep the table now."
: "This browser wouldn't promise. Save the table to a file if tonight matters.");
});
showStorageDurability();
// ---- Working with no signal ----
//
// The night already survives anything — it lives in localStorage — but the page
// did not. Reload in a back room with one bar of signal and a dealer with nine
// players waiting got a browser error page. The worker below keeps the tool itself
// on the phone; see wwwroot/casino/sw.js for what it does and doesn't cache.
//
// The list of files to keep is read off this document rather than written down in
// the worker. Every stylesheet and script the page actually loaded is right here in
// the DOM, already carrying the ?v= release string from _Layout — so the list
// cannot drift from the markup, which is the way a hand-kept one always fails: it
// stays right until somebody adds a script tag, and then the tool is quietly
// offline-broken until a dealer finds out the hard way.
const MANIFEST_URL = "/casino/holdem.webmanifest";
// The icons are the one part of the shell the document doesn't mention: the browser
// reads them out of the manifest itself, so no <link> or <script> in the DOM points
// at them and the sweep above misses all four. An installed app that was launched
// with no signal then had no icon to show for itself.
//
// Read out of the manifest rather than typed out here, for the same reason the rest
// of the list is read out of the document — a copy of a list is a list that is one
// edit away from being wrong, and this particular wrongness is invisible until
// somebody is standing in a back room with no signal.
const manifestIcons = async () => {
try {
// The cache before the network, which is backwards from how the rest of this
// worker thinks, and right here. Offline, a plain fetch for the manifest goes
// to the network, fails, and logs ERR_INTERNET_DISCONNECTED to the console on
// every single launch — noise on the one screen where a real error needs to
// be visible, in service of re-reading a list of four icon paths that has not
// changed since the manifest was written and is already cached anyway.
const res = (typeof caches !== "undefined" && await caches.match(MANIFEST_URL))
|| await fetch(MANIFEST_URL);
if (!res || !res.ok) return [];
const manifest = await res.json();
return (manifest.icons || [])
.map(icon => icon && icon.src)
.filter(Boolean)
.map(src => new URL(src, location.origin).href);
} catch {
return [];
}
};
const registerWorker = async () => {
if (!("serviceWorker" in navigator)) return;
const shell = [
location.pathname,
MANIFEST_URL,
...await manifestIcons(),
// Stylesheets, plus the icons the document points at itself — iOS reads
// none of the manifest's icons and only <link rel="apple-touch-icon">, so
// an installed iPhone launched with no signal needs this one specifically.
...[...document.querySelectorAll(
'link[rel="stylesheet"][href], link[rel="apple-touch-icon"][href], link[rel="icon"][href]'
)].map(el => el.href),
...[...document.querySelectorAll("script[src]")].map(el => el.src),
].filter(url => new URL(url, location.origin).origin === location.origin);
try {
const reg = await navigator.serviceWorker.register("/casino/sw.js", { scope: "/casino/" });
// Whichever worker this registration has, at whatever stage it has reached.
//
// This used to fall back on navigator.serviceWorker.ready, which resolves
// with the registration *controlling this page* — and so never resolves at
// all on a page outside the worker's scope. That is not hypothetical: it is
// exactly what /Casino/TexasHoldem did before the redirect in Program.cs,
// and the symptom was the worst kind. The registration succeeded, the worker
// activated, nothing was logged, and the cache stayed completely empty — a
// dealer who had "loaded the tool for tonight" had loaded nothing.
const worker = reg.installing || reg.waiting || reg.active;
if (!worker) return;
const send = () => worker.postMessage({ type: "precache", urls: shell });
if (worker.state === "activated") send();
else worker.addEventListener("statechange", () => {
if (worker.state === "activated") send();
});
} catch {
// No worker means no offline, and nothing else. Every other part of the
// tool works exactly as it did, so this is not worth a toast at a table.
}
};
// After load, not during it: registering competes with the page's own scripts for
// the network, and the first paint matters more than the second visit does.
if (document.readyState === "complete") registerWorker();
else window.addEventListener("load", registerWorker, { once: true });
});