Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 c19575ab10 Count the house's cut instead of calling it missing chips
A game that takes a rake has chips leaving the table every hand, and the bank
check had no idea. It added up the buy-ins, added up the chips on the felt, and
told the dealer somebody had been paid for chips they were never given — a
shortfall that grows all night and points at a miscount that never happened.
Every home game that takes a drop for the beer fund hits this on hand one.

So the tool takes it, rather than watching it go. Under Table Setup: a
percentage, an optional cap, and "no flop, no drop" — the rule almost everyone
plays, on by default, where a hand that ends before the flop pays nothing. Zero
percent is a game that doesn't rake, which is most of them and the default, and
nothing about that game changes.

Where it shows up is the point. The pot wizard says "House takes $2 of $40 — 5%,
capped at $5" above the payouts, before a chip moves, because the summary is the
dealer's last chance to disagree with the arithmetic and money taken quietly is
the one thing a tool like this must never do. The bank check gets a House take
line and says "balanced ... once the $2 in the house's cut is counted". The hand
log keeps the pot as the table saw it with the cut recorded beside it, and the
export has a column for it, so the night's total is one sum in a spreadsheet.

Two decisions worth stating. The rake rounds *down* to a chip the table actually
has: a house that can't make 40c takes nothing and the players keep it, which is
the only way to be wrong about this that nobody argues with. And on a split pot
it comes off the top, then what is left is shared in proportion — with the
rounding leftovers going back to the winners rather than to the house, which
asked for a number and gets that number, not that number plus dust.

Measured end to end through the UI: 5% capped at $5 on a $40 pot pays $38 and
banks $2; four stacks plus the cut come to exactly the $800 bought in; the bank
check reads balanced. With no-flop-no-drop on, a hand folded round pre-flop pays
the full $15 and takes nothing; with it off the same hand pays $14 and takes $1.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 05:12:33 -06:00
jheapsandClaude Opus 5 8aa90e90a4 Give a phone on its side the table shape it needs
The last two rounds left one thing failing and said so: a landscape phone with
six or twelve seats stacked two of them 25px apart at each end of the felt, with
pucks 64px tall. It has been there since seats were first spaced by distance
rather than by angle. Three things were wrong at once, and it needed all three.

The rim is the wrong shape when the table is very flat. An ellipse 616px wide and
96px tall has nowhere to put the seats near its ends: the curve doubles back on
itself, so two seats a long walk apart come out one above the other. Past two and
a half times wider than tall the rim is now a stadium — two straight rails with a
half-round cap at each end, which is how a real poker table is built and exactly
why it is built that way. A cap is a half-circle of the felt's own height, so an
end takes one seat and the rest is rail. Only past that ratio: measured, the
cut-off lands between a landscape phone at 4.4 and every other layout the tool
has, 1.45 at 1280x900 and 1.0 to 1.4 upright, so no working table changes shape.

The ring had less height than the felt did. The seat layer is inset by half a
puck to keep the outermost seats on screen, and 26px off the top and bottom of a
96px felt left 44px to work with — less than half the table, which is why no rim
shape could have fixed it alone. The inset is a percentage where the felt is
short. The landscape branch never had that rule at all, because it is matched on
height while the inset lives in a width query, so an 844px-wide phone on its side
drew its ring against the felt's whole box; with a flat-topped stadium rail that
put a dealer button over Resolve Pot in the header.

And the puck was a line too tall. In the tight state the hole cards give up their
row: of the four things on a puck, the cards are the one the dealer is holding in
their other hand, and the seat editor still shows them. That took the puck from
64px to 55px, which is what finally cleared the ends.

Measured across 36 combinations of nine viewports and 2, 6, 9 and 12 seats: the
closest two seats at 640x360 go from 25px apart to 55px, and every combination is
clean. Then 216 audits across thirteen viewports, three seat counts and both a
mouse and a thumb: no failures, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 04:58:59 -06:00
jheapsandClaude Opus 5 dc5db16ffd Let the phone table use the width it has
Last round capped the felt so the ring would clear the action bar, and capped the
wrong dimension. The wide layout sizes the felt from its width, so capping the
width is how you shorten it there — on a phone the felt is already the full width
of the column, and capping that threw away 96px of a 360px screen: a 240x240
table inside a 336px space, with nine pucks crowding it.

A max-height instead leaves the width alone and lets the aspect ratio give way,
which turns a short screen into a wide oval — the shape a poker table is. At
360x640: 336x240 rather than 240x240, same clearance over the bar, and the
twelve-seat ring that was crowded at 240 comes out clean, which closes the one
combination left failing last round. Where the room isn't the binding constraint,
390x844 and up, nothing changes and the table stays round. The landscape branch
now says max-height: none, because it already spends the same measured room on
its own height and applying it twice took the felt to the floor value.

The measurement that decides whether pucks shrink was also wrong, in a way that
only showed up once it was asked a hard question. Rim length per seat measures
the walk between two seats, not the gap between them, and on a long oval the rim
doubles back at the ends: twelve seats at 640x360 had 193px of rim each and sat
60px apart. It now measures the closest two neighbours directly, off the ring
itself — and off the seat layer rather than the felt, because the layer is inset
by half a puck on a phone and the felt's box put the answer out by 30px.

That makes the rule geometric rather than about screen size, so it is no longer
scoped to a phone media query: a wide landscape felt crowds at its ends while its
rim claims there is room, and now it is told so.

Pucks still touch at nine and twelve seats on a small screen. Tried 46px against
58px: on a roomy phone 46 cleared every overlap where 58 left two, but in a hand
with a side pot, where the bar has grown and the felt has shrunk to suit, 46
overlapped four times and 58 twice. That is the felt being small, not the puck
being wide, and paying for it with an ellipsis on every long name buys nothing.
What has to hold is that each seat keeps its own centre, and that is measured at
every size.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 04:33:25 -06:00
10 changed files with 756 additions and 74 deletions
@@ -86,6 +86,32 @@
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
<details class="subPanel">
<summary>House take</summary>
<div class="subPanelBody">
<div class="blindsRow">
<label class="fieldRow">
<span>Rake (%)</span>
<input type="number" id="rakePercentInput" min="0" max="100" step="0.5" placeholder="0" />
</label>
<label class="fieldRow">
<span>Cap ($)</span>
<input type="number" id="rakeCapInput" min="0" step="1" placeholder="none" />
</label>
</div>
<label class="fieldRow">
<span>No flop, no drop</span>
<input type="checkbox" id="rakeNoFlopInput" />
</label>
<p class="fieldHint">
Taken from the pot when it's awarded, and shown before anything is pushed.
Rounded down to a chip the table has, so the players keep what the house
can't make. Leave the percentage at zero for a game that doesn't rake.
</p>
<p class="fieldHint" id="rakeTakenHint" hidden></p>
</div>
</details>
<details class="subPanel">
<summary>Without live betting</summary>
<div class="subPanelBody">
@@ -132,6 +158,10 @@
<span>Taken off the table</span>
<span id="bankDiscarded"></span>
</div>
<div class="bankReconcileRow" id="bankRakedRow" hidden>
<span>House take</span>
<span id="bankRaked"></span>
</div>
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
</div>
+89 -29
View File
@@ -820,6 +820,43 @@ button,
outline-offset: 1px;
}
/* Seats close enough together that full-size pucks would overlap. The class is set
from a measurement render.js takes off the ring itself — the closest two neighbours,
straight line — because no media query can see it: the same 390px phone is roomy at
six seats and crowded at nine, and a wide landscape felt crowds at its ends while
its rim says there is space. Not scoped to a screen size for the same reason. */
.seatsTight .seat {
/* 58 was chosen by measurement and 46 was tried against it. On a roomy phone 46
cleared every overlap at nine seats where 58 left two — but in the state that
actually matters, a hand with a side pot where the bar has grown and the felt has
shrunk to suit, 46 overlapped four times and 58 twice. Pucks touching at that
density is the felt being small, not the puck being wide, and paying for it with
an ellipsis on every long name buys nothing. What has to hold is that each seat
keeps its own centre, so a tap lands on the player it was aimed at, and that is
measured at every size. */
width: 58px;
min-height: 38px;
padding: 3px 4px;
}
.seatsTight .seatPuckName {
margin-top: 0.35rem;
font-size: 0.62rem;
}
.seatsTight .seatPuckStack {
font-size: 0.55rem;
}
/* The hole cards give up their line here. A puck this close to its neighbours is 64px
tall and the seats at the ends of a landscape felt are 55px apart, so the line has to
come from somewhere — and of the four things on a puck, the cards are the one the
dealer is holding in their other hand. Name, bet and stack stay. The seat editor
still shows the cards in full. */
.seatsTight .seatPuckCards {
display: none;
}
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
.seatEditorPanel {
@@ -2861,6 +2898,20 @@ body.hasActionBar #toastHost {
color: #ff6b6b;
}
/* What the house takes, above the payouts it comes out of. Stated in the summary
rather than only in the log afterwards: this is the dealer's last chance to
disagree with the arithmetic before the chips move, and money taken quietly is the
one thing a tool like this must never do. */
.potWizardRake {
margin: 0 0 0.7rem;
padding: 0.45rem 0.6rem;
border-radius: 8px;
background: rgba(216, 180, 106, 0.1);
border: 1px solid rgba(216, 180, 106, 0.35);
color: #e8cf9a;
font-size: 0.82rem;
}
.potWizardSummary ul {
list-style: none;
padding: 0;
@@ -2921,34 +2972,20 @@ body.hasActionBar #toastHost {
That trade was real then and isn't now: seats are spaced by distance around the
rim rather than by angle, so a shorter felt no longer piles them up. Re-measured
at 360x640 with the bar grown by a side pot, where the bottom seat sat 38px
inside it: capped, the felt is 240px and the ring clears the bar by 50, with the
audit clean at 6, 9 and 10 seats. Scroll room is not an answer for a seat that is
covered rather than merely below the fold. */
inside it: the ring now clears the bar by 50. Scroll room is not an answer for a
seat that is covered rather than merely below the fold.
The room is spent on height, not on both. Capping the *width* by it — the wide
layout's way, because there the felt is sized from its width — kept the table
square and threw away 96px of a 360px screen: 240x240 inside a 336px column. A
max-height leaves the width alone and lets the aspect ratio give way, so a short
screen gets a wide oval, which is the shape a poker table is anyway. Measured at
360x640: 336x240 rather than 240x240, same clearance, and the twelve-seat ring
that was crowded at 240 comes out clean. Where the room isn't the binding
constraint — 390x844 and up — nothing changes and the table stays round. */
#pokerTableWrap {
max-width: calc(var(--feltRoomPx, 100vh) / var(--feltAspect));
}
/* A felt small enough that the rim can't give each seat its own puck width. Set
from the measured rim in render.js rather than from the window, because the
question is how much table there is per player, which no media query can see:
nine seats on a 240px felt get 72px of rim each and the puck is 76 across. */
.seatsTight .seat {
width: 58px;
min-height: 38px;
padding: 3px 4px;
}
.seatsTight .seatPuckName {
margin-top: 0.35rem;
font-size: 0.62rem;
}
.seatsTight .seatPuckStack {
font-size: 0.55rem;
}
.seatsTight .seatPuckCards {
font-size: 0.58rem;
max-width: none;
max-height: var(--feltRoomPx, 100vh);
}
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
@@ -3020,10 +3057,18 @@ body.hasActionBar #toastHost {
}
/* Keeps every puck inside the viewport: the ring is drawn within this box,
so half a puck of padding is exactly enough to stop the overhang. */
so half a puck of padding is exactly enough to stop the overhang.
Half a puck is the right answer when the felt is tall enough to spare it. On a
phone held sideways it isn't: the felt is 96px tall there once the action bar has
grown, and 26px off the top and bottom left the ring 44px of height to work with
— less than half the table. That is what kept the seats at the ends stacked no
matter what shape the rim was.
Above about 216px of felt the flat 26px still wins, so nothing upright changes. */
#seatsLayer,
#betsLayer {
inset: 26px 44px;
inset: min(26px, 12%) 44px;
}
/* Narrower than the desktop puck: at a full ten-handed table the ring is
@@ -3312,6 +3357,11 @@ body.hasActionBar #toastHost {
a table that is already as short as the screen allows. */
aspect-ratio: auto;
max-width: none;
/* This branch already spends the measured room on its own height. The phone
block's max-height is the same number arriving twice, and applying it here
took the felt down to the floor value and crowded the ring — measured at
640x360, two seats lost their centres to their neighbours. */
max-height: none;
}
/* The page's own top padding is 32px of nothing, on the screen that has the least
@@ -3387,6 +3437,16 @@ body.hasActionBar #toastHost {
min-height: 40px;
}
/* The same inset the phone layout uses, which this branch never had because it is
matched on height rather than width — so an 844px-wide phone on its side drew its
ring against the felt's full box. With the rim a stadium, the top rail is flat,
which put the button seat's dealer chip over the Resolve Pot button in the header
above. Percentages of a short felt, so the ring keeps the height it needs. */
#seatsLayer,
#betsLayer {
inset: min(26px, 12%) 44px;
}
}
/* ---------- Short phones, upright ----------
@@ -20,7 +20,11 @@ const HandHistoryExport = {
// who owes what — doesn't work without cleaning every cell first. The commas were
// quoted correctly, so the file parsed; it just wouldn't add up.
toCsv(handHistory, standings = [], { notes = "" } = {}) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
// The rake gets its own column rather than being folded into the pot: the two
// numbers answer different questions — what was in the middle, and what the
// house took out of it — and a night's worth of the second one is what the box
// is counted against.
const rows = [["Hand #", "Time", "Pot", "House take", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
[...handHistory].reverse().forEach(entry => {
const payouts = (entry.payouts || [])
@@ -30,6 +34,7 @@ const HandHistoryExport = {
entry.handNumber,
this.formatTimestamp(entry.timestamp),
Money.forSheet(entry.pot),
Money.forSheet(entry.rake || 0),
payouts,
]);
});
+26 -3
View File
@@ -192,11 +192,15 @@ document.addEventListener("DOMContentLoaded", () => {
hooks.save(state);
Render.renderAll();
PotWizard.open(state, payouts => {
PotWizard.open(state, (payouts, { rake = 0 } = {}) => {
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
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);
}
if (payouts.length > 0) {
HoldemState.recordHand(state, payouts);
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);
@@ -462,6 +466,25 @@ document.addEventListener("DOMContentLoaded", () => {
});
});
// 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());
@@ -86,6 +86,21 @@ const Money = {
return this.fromCents(Math.ceil(cents / step) * step);
},
// And down, for money coming *out* of a pot rather than going into it.
//
// The rake is the one amount in this tool that leaves the table, so the direction
// of the rounding is a decision about whose money it is. Down: a house that can't
// make 40c out of its chips takes nothing rather than a dollar, and the players
// keep the difference. Being wrong in the players' favour is the only way to be
// wrong about this that nobody has to argue about.
downToChip(amount, legend) {
const step = this.chipStepCents(legend);
if (step <= 0) return this.round(amount);
const cents = this.toCents(amount);
if (cents <= 0) return 0;
return this.fromCents(Math.floor(cents / step) * step);
},
chipBreakdown(amount, legend) {
let cents = this.toCents(amount);
if (cents <= 0) return { chips: [], remainder: 0 };
@@ -232,6 +232,24 @@ const PotWizard = {
return wrap;
},
// What the house takes out of this pot, and what is left to push.
//
// Worked out here rather than at the end so the dealer sees it before anything
// moves: the summary is the last chance to disagree with the arithmetic, and a
// rake that only appeared in the hand log afterwards would be the tool taking
// money quietly.
rakeSplit() {
const gross = Array.from(this.totals.entries()).map(([id, amount]) => ({
id,
label: this.playerLabelById.get(id),
amount,
}));
const pot = gross.reduce((sum, p) => sum + p.amount, 0);
const sawFlop = (this.state.street || "preflop") !== "preflop";
const rake = HoldemState.rakeOn(this.state, pot, sawFlop);
return { pot, sawFlop, ...HoldemState.applyRake(gross, rake) };
},
renderSummary() {
const wrap = document.createElement("div");
wrap.className = "potWizardSummary";
@@ -240,6 +258,20 @@ const PotWizard = {
heading.textContent = "Final Payouts";
wrap.appendChild(heading);
const split = this.rakeSplit();
const rakeSet = Number((this.state.rake || {}).percent) > 0;
if (rakeSet && this.totals.size > 0) {
const line = document.createElement("p");
line.className = "potWizardRake";
const cap = Number(this.state.rake.cap) > 0 ? `, capped at ${Money.format(this.state.rake.cap)}` : "";
line.textContent = split.rake > 0
? `House takes ${Money.format(split.rake)} of ${Money.format(split.pot)} — ${this.state.rake.percent}%${cap}.`
: split.sawFlop
? `No rake on this one — ${this.state.rake.percent}% of ${Money.format(split.pot)} doesn't make a chip the table has.`
: "No flop, no drop — nothing taken from a pot that ended before the flop.";
wrap.appendChild(line);
}
if (this.totals.size === 0) {
const empty = document.createElement("p");
empty.className = "fieldHint";
@@ -248,7 +280,7 @@ const PotWizard = {
} else {
const legend = (this.state && this.state.chipLegend) || [];
const list = document.createElement("ul");
for (const [id, amount] of this.totals.entries()) {
for (const { id, amount } of split.payouts) {
const li = document.createElement("li");
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
@@ -275,13 +307,12 @@ const PotWizard = {
applyBtn.className = "holdemBtn holdemBtnAccent";
applyBtn.textContent = "Start Next Hand";
applyBtn.addEventListener("click", () => {
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
id,
label: this.playerLabelById.get(id),
amount,
}));
// Recomputed rather than reused from the render: the same numbers, but
// taken at the moment the dealer commits rather than when they last
// looked at the screen.
const paid = this.rakeSplit();
this.close();
this.onFinished(payouts);
this.onFinished(paid.payouts, { rake: paid.rake, pot: paid.pot });
});
actions.appendChild(applyBtn);
+139 -31
View File
@@ -47,6 +47,19 @@ const Render = {
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
const rake = this.state.rake || {};
document.getElementById("rakePercentInput").value = rake.percent || "";
document.getElementById("rakeCapInput").value = rake.cap ?? "";
document.getElementById("rakeNoFlopInput").checked = rake.noFlopNoDrop !== false;
// What the house has actually taken tonight, said where the rake is set rather
// than only in the bank check — a dealer who changes the percentage mid-session
// wants to see the running total right there.
const taken = Math.max(0, Number(this.state.rakeTaken) || 0);
const hint = document.getElementById("rakeTakenHint");
hint.hidden = taken <= 0;
if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`;
},
// Whether this device can actually drag a seat. HTML5 drag events are never
@@ -91,12 +104,21 @@ const Render = {
// circle when there's nothing to measure — the same answer the ring gave before it
// asked.
feltAspect() {
if (typeof document === "undefined") return 1;
const wrap = document.getElementById("pokerTableWrap");
if (!wrap) return 1;
const box = wrap.getBoundingClientRect();
if (!box.width || !box.height) return 1;
return box.width / box.height;
const box = this.ringBox();
return box ? box.width / box.height : 1;
},
// The box a seat's left/top percentages actually resolve against. Not the felt: on
// a phone the seat layer is inset by half a puck so the outermost seats don't hang
// off the screen, and measuring the felt instead put the ring's shape out by that
// inset — twelve seats at 640x360 were computed 90px apart and drawn 60px apart,
// which is the difference between "fine" and "overlapping".
ringBox() {
if (typeof document === "undefined") return null;
const layer = document.getElementById("seatsLayer") || document.getElementById("pokerTableWrap");
if (!layer) return null;
const box = layer.getBoundingClientRect();
return box.width && box.height ? box : null;
},
// Bets sit on the felt between their player and the pot, the way chips actually
@@ -108,8 +130,11 @@ const Render = {
// anything visible — it is just the honest way to say "on that player's spoke",
// and it saves the second walk.
betPosition(index, count, aspect = this.feltAspect()) {
const angle = this.ringAngles(count, 44, 42, aspect)[index];
return { left: 50 + 27 * Math.cos(angle), top: 50 + 25 * Math.sin(angle) };
const seat = this.seatPosition(index, count, aspect);
return {
left: 50 + (seat.left - 50) * (27 / 44),
top: 50 + (seat.top - 50) * (25 / 42),
};
},
// Seats spaced evenly around the rim — by the distance between them, not by the
@@ -127,33 +152,100 @@ const Render = {
// an ellipse's has no closed form — 720 steps is far more than enough for a rim a
// few hundred pixels long, and it runs once per render.
positionOnRing(index, count, rx, ry, aspect = 1) {
const target = this.ringAngles(count, rx, ry, aspect)[index];
return {
left: 50 + rx * Math.cos(target),
top: 50 + ry * Math.sin(target),
};
return this.ringPositions(count, rx, ry, aspect)[index];
},
// Past this much wider than tall, the rim stops being an ellipse and becomes a
// stadium — two straight rails with a semicircular cap at each end.
//
// An ellipse that flat has nowhere to put the seats near its ends. At 640x360 with
// the bar grown by a side pot the felt is 616x96, and six seats came out with two
// of them stacked 25px apart at each end, pucks 64px tall. No puck size fixes 25px:
// the shape is wrong. A stadium's cap is a half-circle of the felt's own height, so
// an end holds one seat and the rest of the table is rail — which is how a real
// poker table is built, and why it is shaped that way.
//
// Only where it is needed. Anything rounder than this keeps the ellipse it has
// always had: measured, the cut-off lands between a landscape phone (4.4 and up)
// and every other layout the tool has (1.45 at 1280x900, 1.0 to 1.4 on a phone
// upright), so no currently-working table changes shape.
STADIUM_RATIO: 2.5,
// Seats spaced evenly around whichever rim this table has, as percentages.
ringPositions(count, rx, ry, aspect = 1) {
if (count <= 0) return [];
const halfWidth = rx * aspect;
const halfHeight = ry;
if (halfWidth <= halfHeight * this.STADIUM_RATIO) {
return this.ringAngles(count, rx, ry, aspect).map(angle => ({
left: 50 + rx * Math.cos(angle),
top: 50 + ry * Math.sin(angle),
}));
}
return this.stadiumPositions(count, halfWidth, halfHeight, aspect);
},
// The same walk, around a stadium instead. Its perimeter has a closed form, so
// this needs no sampling: two rails of 2(A-B) and two caps of piB.
stadiumPositions(count, halfWidth, halfHeight, aspect) {
const rail = halfWidth - halfHeight;
const cap = Math.PI * halfHeight;
const perimeter = 4 * rail + 2 * cap;
return Array.from({ length: count }, (_, seat) => {
// Clockwise from the top centre, so seat one sits where it always has.
let walked = (perimeter * seat) / count;
let x;
let y;
if (walked <= rail) { // top rail, going right
x = walked;
y = -halfHeight;
} else if (walked <= rail + cap) { // right cap
const turned = (walked - rail) / halfHeight;
x = rail + halfHeight * Math.sin(turned);
y = -halfHeight * Math.cos(turned);
} else if (walked <= 3 * rail + cap) { // bottom rail, going left
x = rail - (walked - rail - cap);
y = halfHeight;
} else if (walked <= 3 * rail + 2 * cap) { // left cap
const turned = (walked - 3 * rail - cap) / halfHeight;
x = -rail - halfHeight * Math.sin(turned);
y = halfHeight * Math.cos(turned);
} else { // top rail, back to the start
x = -rail + (walked - 3 * rail - 2 * cap);
y = -halfHeight;
}
return { left: 50 + x / aspect, top: 50 + y };
});
},
RING_STEPS: 720,
ringCache: {},
// How much rim each seat gets, in pixels. Ramanujan's approximation for an
// ellipse's perimeter, which is well inside a pixel at these sizes.
rimPerSeat(count) {
const wrap = document.getElementById("pokerTableWrap");
if (!wrap || !count) return Infinity;
const box = wrap.getBoundingClientRect();
const a = (box.width * 44) / 100;
const b = (box.height * 42) / 100;
if (!a || !b) return Infinity;
const perimeter = Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b)));
return perimeter / count;
// The closest two neighbouring seats come to each other, straight line, in pixels.
//
// Rim length divided by seat count was the first answer and it is the wrong one:
// it measures the walk between two seats rather than the gap between them, and on
// a flat ellipse those are nothing alike. At 640x360 six seats had 193px of rim
// each and sat 56px apart, because at the ends of a long oval the rim doubles back
// and two seats a long walk apart are stacked one above the other.
closestSeatGapPx(count, box = this.ringBox()) {
if (count < 2 || !box || !box.width || !box.height) return Infinity;
const points = this.ringPositions(count, 44, 42, box.width / box.height).map(seat => ({
x: (seat.left / 100) * box.width,
y: (seat.top / 100) * box.height,
}));
return points.reduce((closest, point, i) => {
const next = points[(i + 1) % points.length];
return Math.min(closest, Math.hypot(point.x - next.x, point.y - next.y));
}, Infinity);
},
// A puck is 76px across. Under about that much rim each, neighbours start covering
// each other's middles, and a tap meant for one player lands on the next — so the
// pucks come down a size rather than the table pretending it has room it hasn't.
SEAT_ROOM_PX: 88,
// A puck is 76px across, so neighbours whose centres are closer than that overlap
// by definition; a few pixels more keeps them from touching. Under it the pucks
// come down a size rather than the table pretending it has room it hasn't.
SEAT_ROOM_PX: 84,
// Called on every render and again whenever the felt is resized — the action bar
// growing mid-hand shortens the table, and the seats have to answer that without
@@ -162,7 +254,7 @@ const Render = {
const layer = document.getElementById("seatsLayer");
if (!layer || !this.state) return;
layer.classList.toggle("seatsTight",
this.rimPerSeat(this.state.seatCount) < this.SEAT_ROOM_PX);
this.closestSeatGapPx(this.state.seatCount) < this.SEAT_ROOM_PX);
},
// The angles that cut the rim into `count` equal lengths, starting at the top.
@@ -1066,10 +1158,26 @@ const Render = {
}
}
// Same idea for the house's cut, and the more important of the two: a raked
// game moves chips off the table every single hand, so without this line the
// check reads as a shortfall that grows all night.
const rakedRow = document.getElementById("bankRakedRow");
if (rakedRow) {
rakedRow.hidden = !(bank.raked > 0);
if (bank.raked > 0) {
document.getElementById("bankRaked").textContent = Money.format(bank.raked);
}
}
const verdict = document.getElementById("bankReconcileVerdict");
// What has left the table on purpose, whichever way it went.
const accountedFor = [
bank.discarded > 0 ? `${Money.format(bank.discarded)} taken off it` : null,
bank.raked > 0 ? `${Money.format(bank.raked)} in the house's cut` : null,
].filter(Boolean);
if (bank.balanced) {
verdict.textContent = bank.discarded > 0
? `Balanced — the cash covers every chip on the table, once the ${Money.format(bank.discarded)} taken off it is counted.`
verdict.textContent = accountedFor.length > 0
? `Balanced — the cash covers every chip on the table, once the ${accountedFor.join(" and the ")} is counted.`
: "Balanced — the cash covers every chip on the table.";
} else if (bank.difference > 0) {
verdict.textContent =
@@ -104,6 +104,12 @@ const HoldemState = {
// announced hand by hand, so this is cleared with every other one-deal
// decision rather than standing until somebody turns it off.
straddleCount: 0,
// What the house takes, if it takes anything. Percent zero is a game that
// doesn't rake, which is nearly all of them and the default.
rake: { percent: 0, cap: null, noFlopNoDrop: true },
// What it has taken so far tonight. A session total, not a per-hand one:
// it is the number the box is checked against at the end.
rakeTaken: 0,
// Which seats actually put a straddle out when the hand was dealt, so the
// felt can badge them. Read off the deal rather than worked out again
// later, the same way the posted blinds are.
@@ -213,6 +219,15 @@ const HoldemState = {
// whatever is in front of those players is already in their committed
// chips, and nothing is owed on top of it.
if (!(Number(parsed.straddleCount) > 0)) parsed.straddleCount = 0;
// A table saved before the house took anything takes nothing, which is
// also the right answer for one that never will.
const rake = parsed.rake && typeof parsed.rake === "object" ? parsed.rake : {};
parsed.rake = {
percent: Math.min(100, Math.max(0, Number(rake.percent) || 0)),
cap: Number(rake.cap) > 0 ? Money.round(Number(rake.cap)) : null,
noFlopNoDrop: rake.noFlopNoDrop !== false,
};
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
// A table saved before dealing and betting were separate has no flag, and
@@ -679,11 +694,15 @@ const HoldemState = {
// Must be called before startNextHand() clears the seats: the per-player `ledger`
// it stores alongside the payouts is what session standings are summed from later,
// and it needs this hand's contributions while they're still on the table.
recordHand(state, payouts) {
recordHand(state, payouts, rake = 0) {
if (typeof state.handNumber !== "number") state.handNumber = 1;
if (!Array.isArray(state.handHistory)) state.handHistory = [];
const pot = payouts.reduce((sum, p) => sum + p.amount, 0);
// The pot as it was, house cut included — the number the table saw in the
// middle. What each player took is separately recorded and is net of it, so
// the two together say where every chip went.
const taken = Money.round(Math.max(0, Number(rake) || 0));
const pot = Money.round(payouts.reduce((sum, p) => sum + p.amount, 0) + taken);
const wonById = new Map(payouts.map(p => [p.id, p.amount]));
const ledger = state.seats
.map((player, index) => ({
@@ -698,6 +717,7 @@ const HoldemState = {
handNumber: state.handNumber,
timestamp: Date.now(),
pot,
rake: taken,
payouts,
ledger,
});
@@ -818,6 +838,67 @@ const HoldemState = {
//
// Chips in play counts everything a player could hand back: what's in front of
// them and what's in the middle waiting to be won.
// ---- The house's cut ----
//
// A game that takes a rake or a time charge has chips leaving the table every hand,
// and until now the bank check called them missing: it added up the buy-ins,
// added up the chips on the felt, and told the dealer somebody had been paid for
// chips they never got. The house's cut is the one thing on this table that is
// supposed to go and not come back, so it is counted rather than mourned.
//
// Percentage with a cap, which is what nearly every room and home game runs. Zero
// percent is a game that doesn't rake, which is the default and the common case.
// What the house takes from a pot of this size.
//
// "No flop, no drop" is the rule almost everyone plays: a hand that ends before the
// flop pays nothing, because the players never got a hand out of it. It is on by
// default and can be turned off for a house that rakes every pot.
rakeOn(state, pot, sawFlop = true) {
const rake = state.rake || {};
const percent = Number(rake.percent) || 0;
const amount = Money.round(Number(pot) || 0);
if (percent <= 0 || amount <= 0) return 0;
if (rake.noFlopNoDrop !== false && !sawFlop) return 0;
const uncapped = (amount * percent) / 100;
const cap = Number(rake.cap);
const capped = cap > 0 ? Math.min(uncapped, cap) : uncapped;
// Never more than the pot, however the numbers were typed in.
return Money.downToChip(Math.min(capped, amount), state.chipLegend);
},
// The same payouts with the house's cut taken out of them.
//
// Off the top of the whole pot, then shared out in proportion — which for the hand
// a dealer actually deals, with one winner, is just "winner gets the pot less the
// rake". Split pots take it proportionally, and whatever the rounding leaves over
// goes back to the winners in turn rather than to the house: the house asked for a
// number, and it gets that number, not that number plus dust.
applyRake(payouts, rake) {
const total = payouts.reduce((sum, p) => sum + p.amount, 0);
const taken = Money.round(Math.min(Math.max(0, rake), total));
if (taken <= 0) return { payouts: payouts.map(p => ({ ...p })), rake: 0 };
const kept = Money.toCents(total) - Money.toCents(taken);
const shares = payouts.map(p => {
const exact = (Money.toCents(p.amount) * kept) / Money.toCents(total);
return { payout: p, cents: Math.floor(exact) };
});
// Floor everybody, then hand the leftover cents out one at a time, biggest
// share first — the same shape as the odd-chip rule the split calculator uses.
let spare = kept - shares.reduce((sum, s) => sum + s.cents, 0);
[...shares]
.sort((a, b) => b.payout.amount - a.payout.amount)
.forEach(share => { if (spare > 0) { share.cents += 1; spare -= 1; } });
return {
payouts: shares.map(share => ({ ...share.payout, amount: Money.fromCents(share.cents) })),
rake: taken,
};
},
bankReconciliation(state) {
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
const chipsInPlay = state.seats.reduce(
@@ -837,11 +918,16 @@ const HoldemState = {
// hunting for a miscount that never happened.
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
const difference = Money.round(chipsInPlay + discarded - cashIn);
// Chips the house has taken. Off the table for good, like the discards, and
// for a better reason: the box is supposed to have them.
const raked = Math.max(0, Money.round(Number(state.rakeTaken) || 0));
const difference = Money.round(chipsInPlay + discarded + raked - cashIn);
return {
cashIn: Money.round(cashIn),
chipsInPlay: Money.round(chipsInPlay),
discarded,
raked,
difference,
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
balanced: Math.abs(difference) <= Money.EPSILON,
+230
View File
@@ -0,0 +1,230 @@
// The house's cut.
//
// A game that rakes has chips leaving the table every hand, and the bank check used to
// call them missing: buy-ins in, chips on the felt, and a dealer told somebody had been
// paid for chips they never got. The cut is the one thing here that is supposed to go
// and not come back.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money, HoldemState, Betting, makeTable, chipsOnTable } = require("./load");
const raking = (rake, extra = {}) => {
const state = makeTable({ seatCount: 6, stack: 200, ...extra });
state.rake = { percent: 0, cap: null, noFlopNoDrop: true, ...rake };
return state;
};
describe("what the house takes", () => {
test("nothing, unless somebody says otherwise", () => {
const state = makeTable({ seatCount: 6 });
assert.equal(state.rake.percent, 0);
assert.equal(HoldemState.rakeOn(state, 100), 0);
});
test("a percentage of the pot", () => {
assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 100), 5);
assert.equal(HoldemState.rakeOn(raking({ percent: 10 }), 60), 6);
});
test("no more than the cap", () => {
const state = raking({ percent: 5, cap: 5 });
assert.equal(HoldemState.rakeOn(state, 100), 5);
assert.equal(HoldemState.rakeOn(state, 400), 5, "still five on a pot four times the size");
assert.equal(HoldemState.rakeOn(state, 40), 2, "under the cap it is just the percentage");
});
test("no flop, no drop", () => {
const state = raking({ percent: 5 });
assert.equal(HoldemState.rakeOn(state, 100, false), 0, "hand ended before the flop");
assert.equal(HoldemState.rakeOn(state, 100, true), 5);
});
test("unless the house rakes every pot", () => {
const state = raking({ percent: 5, noFlopNoDrop: false });
assert.equal(HoldemState.rakeOn(state, 100, false), 5);
});
test("nothing it can't make out of the table's chips, rounded the players' way", () => {
// 5% of $30 is $1.50, and this table's smallest chip is a dollar.
const state = raking({ percent: 5 });
assert.equal(HoldemState.rakeOn(state, 30), 1);
// 5% of $10 is 50c — under a chip, so the house takes nothing at all.
assert.equal(HoldemState.rakeOn(state, 10), 0);
});
test("never more than the pot, whatever gets typed in", () => {
assert.equal(HoldemState.rakeOn(raking({ percent: 100 }), 20), 20);
assert.equal(HoldemState.rakeOn(raking({ percent: 5, cap: 500 }), 20), 1);
});
test("nothing out of an empty pot", () => {
assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 0), 0);
});
});
describe("taking it out of the pot", () => {
const winner = amount => [{ id: "a", label: "Ann", amount }];
test("one winner just gets the pot less the cut", () => {
const { payouts, rake } = HoldemState.applyRake(winner(100), 5);
assert.equal(rake, 5);
assert.deepEqual(payouts.map(p => p.amount), [95]);
});
test("a split pot shares what's left, in proportion", () => {
const { payouts } = HoldemState.applyRake(
[{ id: "a", label: "Ann", amount: 60 }, { id: "b", label: "Ben", amount: 40 }], 10);
assert.deepEqual(payouts.map(p => p.amount), [54, 36]);
});
test("the rounding leftovers go back to the players, not to the house", () => {
// $100 split three ways less $7: 93 doesn't divide evenly, and every cent of
// it has to land on somebody.
const { payouts, rake } = HoldemState.applyRake([
{ id: "a", label: "Ann", amount: 34 },
{ id: "b", label: "Ben", amount: 33 },
{ id: "c", label: "Cal", amount: 33 },
], 7);
assert.equal(rake, 7);
assert.equal(Money.round(payouts.reduce((sum, p) => sum + p.amount, 0)), 93);
});
test("what goes out plus what the house keeps is what was in the middle", () => {
[[100, 5], [37.5, 1.25], [1000, 0], [9, 9]].forEach(([pot, rake]) => {
const result = HoldemState.applyRake(winner(pot), rake);
assert.equal(Money.round(result.payouts[0].amount + result.rake), pot, `${pot} less ${rake}`);
});
});
test("asking for more than the pot takes the pot, not more", () => {
const { payouts, rake } = HoldemState.applyRake(winner(20), 50);
assert.equal(rake, 20);
assert.deepEqual(payouts.map(p => p.amount), [0]);
});
test("no rake, no change — the same payouts come back", () => {
const { payouts, rake } = HoldemState.applyRake(winner(80), 0);
assert.equal(rake, 0);
assert.deepEqual(payouts.map(p => p.amount), [80]);
});
});
describe("what the box is checked against", () => {
test("raked chips are gone but not missing", () => {
const state = raking({ percent: 5 });
state.seats.forEach(p => { p.buyIns = 200; });
const before = HoldemState.bankReconciliation(state);
assert.equal(before.balanced, true);
// The house takes $10 out of the middle: somebody's stack is $10 lighter.
state.seats[0].stack = 190;
state.rakeTaken = 10;
const after = HoldemState.bankReconciliation(state);
assert.equal(after.raked, 10);
assert.equal(after.balanced, true, "the check still balances");
});
test("and are told apart from the ones that went astray", () => {
const state = raking({ percent: 5 });
state.seats.forEach(p => { p.buyIns = 200; });
state.seats[0].stack = 180;
state.rakeTaken = 10;
const check = HoldemState.bankReconciliation(state);
assert.equal(check.raked, 10);
assert.equal(check.difference, -10, "ten dollars really are missing");
assert.equal(check.balanced, false);
});
test("a table that has never raked reads exactly as it did before", () => {
const state = makeTable({ seatCount: 6 });
state.seats.forEach(p => { p.buyIns = 100; p.stack = 100; });
const check = HoldemState.bankReconciliation(state);
assert.equal(check.raked, 0);
assert.equal(check.balanced, true);
});
});
describe("the record of it", () => {
test("the hand log keeps the pot as the table saw it, and the cut beside it", () => {
const state = raking({ percent: 5 });
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 95 }], 5);
const [entry] = state.handHistory;
assert.equal(entry.pot, 100, "the pot in the middle");
assert.equal(entry.rake, 5);
assert.equal(entry.payouts[0].amount, 95, "what the winner actually took");
});
test("a hand with no rake records a zero rather than nothing", () => {
const state = makeTable({ seatCount: 6 });
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 40 }]);
assert.equal(state.handHistory[0].rake, 0);
assert.equal(state.handHistory[0].pot, 40);
});
test("a table saved before the house took anything takes nothing", () => {
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
delete parsed.rake;
delete parsed.rakeTaken;
const migrated = HoldemState.migrate(parsed);
assert.equal(migrated.rake.percent, 0);
assert.equal(migrated.rake.noFlopNoDrop, true);
assert.equal(migrated.rakeTaken, 0);
});
test("nonsense in a saved rake is cleaned up rather than trusted", () => {
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
parsed.rake = { percent: -5, cap: -1, noFlopNoDrop: false };
parsed.rakeTaken = -20;
const migrated = HoldemState.migrate(parsed);
assert.equal(migrated.rake.percent, 0);
assert.equal(migrated.rake.cap, null);
assert.equal(migrated.rake.noFlopNoDrop, false, "that one is a real choice");
assert.equal(migrated.rakeTaken, 0);
});
});
describe("the file the dealer hands over", () => {
const { HandHistoryExport } = require("./load");
test("has a column for what the house took", () => {
const state = raking({ percent: 5 });
HoldemState.recordHand(state, [{ id: "a", label: "Ann", amount: 95 }], 5);
HoldemState.recordHand(state, [{ id: "b", label: "Ben", amount: 40 }], 0);
// Oldest hand first, and money as bare numbers so a spreadsheet can total it.
const csv = HandHistoryExport.toCsv(state.handHistory);
const [header, raked, unraked] = csv.split("\n");
assert.match(header, /Pot,House take,Payouts/);
assert.match(raked, /,100\.00,5\.00,/, "the raked hand says how much was taken");
assert.match(unraked, /,40\.00,0\.00,/, "and the unraked one says nothing was");
});
});
describe("a raked hand, end to end", () => {
test("the chips add up: stacks plus the house's cut equal what was bought in", () => {
// 5/10 blinds, so six callers make a pot big enough for 5% to be a whole chip.
// At 1/2 the same hand makes $12, 5% of which is 60c and rounds away to
// nothing — which is the rule working, not a hand worth testing the bank on.
const state = raking({ percent: 5, cap: 5 }, { smallBlind: 5, bigBlind: 10 });
state.seats.forEach(p => { p.buyIns = 200; });
Betting.beginHand(state);
// Everyone calls the big blind, then it is checked down.
state.seats.forEach((p, i) => { if (p.inHand) Betting.putIn(state, i, Betting.toCall(state, p)); });
Betting.sweepToHandTotals(state);
const pot = state.seats.reduce((sum, p) => sum + (Number(p.amountOverride) || 0), 0);
const rake = HoldemState.rakeOn(state, pot, true);
assert.equal(pot, 60);
assert.equal(rake, 3);
const { payouts } = HoldemState.applyRake([{ id: state.seats[0].id, label: "P1", amount: pot }], rake);
state.rakeTaken = rake;
HoldemState.recordHand(state, payouts, rake);
Betting.creditWinnings(state, payouts);
HoldemState.startNextHand(state);
assert.equal(Money.round(chipsOnTable(state) + state.rakeTaken), 1200);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
});
+94
View File
@@ -92,6 +92,100 @@ describe("a felt wider than it is tall", () => {
});
});
describe("how close the closest two seats come", () => {
// What decides whether the pucks shrink. Rim length per seat was the first answer
// and it is the wrong question: it measures the walk between two seats, not the
// gap. On a long oval the rim doubles back at the ends, so two seats a long walk
// apart end up stacked one above the other.
const gap = (count, width, height) => Render.closestSeatGapPx(count, { width, height });
test("a flat table no longer stacks two seats at each end", () => {
// 616x96: the landscape felt on a 640x360 phone once the bar has grown. On an
// ellipse that flat the end pair came out 25px apart with 64px pucks. The rim
// is a stadium at this shape, so an end holds one seat.
assert.ok(gap(6, 616, 96) > 70, `${gap(6, 616, 96).toFixed(0)}px apart`);
assert.ok(gap(12, 616, 96) > 40, `${gap(12, 616, 96).toFixed(0)}px apart at twelve seats`);
});
test("an ellipse's ends are still closer than its rim suggests, which is why the shape changes", () => {
// Same felt, forced through the ellipse the tool uses for rounder tables.
const asEllipse = Render.ringAngles(6, 44, 42, 616 / 96).map(angle => ({
x: ((50 + 44 * Math.cos(angle)) / 100) * 616,
y: ((50 + 42 * Math.sin(angle)) / 100) * 96,
}));
const closest = asEllipse.reduce((least, point, i) => {
const next = asEllipse[(i + 1) % asEllipse.length];
return Math.min(least, Math.hypot(point.x - next.x, point.y - next.y));
}, Infinity);
assert.ok(closest < 70, `${closest.toFixed(0)}px`);
assert.ok(gap(6, 616, 96) > closest * 1.3,
`stadium ${gap(6, 616, 96).toFixed(0)}px against ellipse ${closest.toFixed(0)}px`);
});
test("a round table's neighbours are about as far apart as the rim says", () => {
const rim = (Math.PI * 2 * 0.43 * 400) / 9;
assert.ok(Math.abs(gap(9, 400, 400) - rim) < rim * 0.15);
});
test("more seats, less room, in that order", () => {
assert.ok(gap(12, 400, 400) < gap(9, 400, 400));
assert.ok(gap(9, 400, 400) < gap(6, 400, 400));
assert.ok(gap(6, 400, 400) < gap(6, 800, 800), "and a bigger table gives more");
});
test("nothing to measure between fewer than two seats", () => {
assert.equal(gap(1, 400, 400), Infinity);
assert.equal(gap(9, 0, 0), Infinity);
});
});
describe("which rim a table gets", () => {
// An ellipse until the table is much wider than tall, then a stadium: two rails
// and a half-round cap at each end, which is how a real poker table is built.
const shape = (width, height) => {
const aspect = width / height;
return 44 * aspect > 42 * Render.STADIUM_RATIO ? "stadium" : "ellipse";
};
test("every layout the tool had keeps the ellipse", () => {
assert.equal(shape(844, 608), "ellipse", "desktop");
assert.equal(shape(600, 600), "ellipse", "tablet");
assert.equal(shape(278, 314), "ellipse", "phone upright");
assert.equal(shape(336, 240), "ellipse", "short phone upright");
});
test("a phone on its side gets the stadium", () => {
assert.equal(shape(528, 73), "stadium");
assert.equal(shape(780, 113), "stadium");
});
test("the cap holds one seat instead of stacking two", () => {
// Six seats on a 616x96 rim: the walk between neighbours has to be longer than
// the cap, or two of them end up on the same half-circle.
const halfWidth = 44 * (616 / 96), halfHeight = 42;
const perimeter = 4 * (halfWidth - halfHeight) + 2 * Math.PI * halfHeight;
assert.ok(perimeter / 6 > Math.PI * halfHeight, "one seat per cap at six-handed");
});
test("seat one is still at the top centre, and the ring still mirrors", () => {
const seats = Array.from({ length: 8 }, (_, i) => Render.positionOnRing(i, 8, 44, 42, 616 / 96));
assert.ok(Math.abs(seats[0].left - 50) < 0.01);
assert.ok(seats[0].top < 50);
for (let seat = 1; seat < 4; seat++) {
const mirrored = seats[8 - seat];
assert.ok(Math.abs((seats[seat].left - 50) + (mirrored.left - 50)) < 0.01, `seat ${seat} across`);
assert.ok(Math.abs(seats[seat].top - mirrored.top) < 0.01, `seat ${seat} down`);
}
});
test("nobody is drawn off the felt", () => {
Array.from({ length: 12 }, (_, i) => Render.positionOnRing(i, 12, 44, 42, 616 / 96)).forEach((seat, i) => {
assert.ok(seat.left >= 5 && seat.left <= 95, `seat ${i + 1} across at ${seat.left.toFixed(1)}%`);
assert.ok(seat.top >= 7 && seat.top <= 93, `seat ${i + 1} down at ${seat.top.toFixed(1)}%`);
});
});
});
describe("the shape of the ring", () => {
test("is a mirror image left to right", () => {
const seats = ringPixels(10, 601, 126);