diff --git a/JoshHeaps.Net/Pages/Casino.cshtml b/JoshHeaps.Net/Pages/Casino.cshtml
new file mode 100644
index 0000000..35326d0
--- /dev/null
+++ b/JoshHeaps.Net/Pages/Casino.cshtml
@@ -0,0 +1,49 @@
+@page
+@model JoshHeaps.Net.Pages.CasinoModel
+@{
+ ViewData["Title"] = "Casino Games";
+ Layout = "_Layout";
+}
+
+
+
+
Tools for running table games without doing the math yourself
+
+
+
+
← Back to Home
+
+
+@section Styles {
+
+}
diff --git a/JoshHeaps.Net/Pages/Casino.cshtml.cs b/JoshHeaps.Net/Pages/Casino.cshtml.cs
new file mode 100644
index 0000000..5dc9c5b
--- /dev/null
+++ b/JoshHeaps.Net/Pages/Casino.cshtml.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace JoshHeaps.Net.Pages
+{
+ public class CasinoModel : PageModel
+ {
+ public void OnGet()
+ {
+ }
+ }
+}
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
new file mode 100644
index 0000000..6559825
--- /dev/null
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -0,0 +1,159 @@
+@page
+@model JoshHeaps.Net.Pages.Casino.TexasHoldemModel
+@{
+ ViewData["Title"] = "Texas Hold'em Dealer";
+ Layout = "_Layout";
+}
+
+
+
+
+
+
+
+ Drag a seat onto another seat to swap players. Click the dealer chip on a seat to make it the button.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@section Styles {
+
+}
+
+@section Scripts {
+
+
+
+
+
+}
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml.cs b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml.cs
new file mode 100644
index 0000000..92c1e60
--- /dev/null
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace JoshHeaps.Net.Pages.Casino
+{
+ public class TexasHoldemModel : PageModel
+ {
+ public void OnGet()
+ {
+ }
+ }
+}
diff --git a/JoshHeaps.Net/Pages/Index.cshtml b/JoshHeaps.Net/Pages/Index.cshtml
index be6aa12..e4b12ce 100644
--- a/JoshHeaps.Net/Pages/Index.cshtml
+++ b/JoshHeaps.Net/Pages/Index.cshtml
@@ -64,6 +64,7 @@
+
diff --git a/JoshHeaps.Net/Pages/_Layout.cshtml b/JoshHeaps.Net/Pages/_Layout.cshtml
index a122e08..f9cc0d7 100644
--- a/JoshHeaps.Net/Pages/_Layout.cshtml
+++ b/JoshHeaps.Net/Pages/_Layout.cshtml
@@ -1,6 +1,6 @@
@{
Layout = null;
- ViewData["cssVersion"] = "1.0.7"; // <--- change this once to bust cache
+ ViewData["cssVersion"] = "1.0.8"; // <--- change this once to bust cache
}
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
new file mode 100644
index 0000000..c4e2fdc
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -0,0 +1,603 @@
+body {
+ background: #141414;
+ min-height: 100vh;
+ margin: 0;
+ color: #e6e6e6;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+}
+
+#holdemApp {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 2rem;
+}
+
+/* ---------- Header ---------- */
+
+#holdemHeader {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+#holdemHeader h1 {
+ margin: 0;
+ font-size: 1.8rem;
+ font-weight: 300;
+ letter-spacing: 0.04em;
+ color: #f5f5f5;
+}
+
+.holdemSubtitle {
+ margin: 0.25rem 0 0;
+ color: #999;
+ font-size: 0.9rem;
+}
+
+#holdemHeaderActions {
+ display: flex;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+}
+
+.holdemBtn {
+ background: #2a2a2a;
+ color: #eee;
+ border: 1px solid #3a3a3a;
+ border-radius: 6px;
+ padding: 0.6rem 1rem;
+ font-size: 0.85rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+.holdemBtn:hover {
+ background: #333;
+ border-color: #4a4a4a;
+}
+
+.holdemBtnAccent {
+ background: #0f5c1c;
+ border-color: #1e8449;
+ color: #d6ffe0;
+}
+
+.holdemBtnAccent:hover {
+ background: #146623;
+}
+
+.holdemBtnDanger {
+ background: #3a1717;
+ border-color: #6b2626;
+ color: #ffd6d6;
+}
+
+.holdemBtnDanger:hover {
+ background: #4a1c1c;
+}
+
+.holdemBtnGhost {
+ background: transparent;
+}
+
+.holdemBtnSmall {
+ padding: 0.3rem 0.6rem;
+ font-size: 0.75rem;
+}
+
+/* ---------- Layout ---------- */
+
+#holdemLayout {
+ display: grid;
+ grid-template-columns: 1fr 320px;
+ gap: 1.5rem;
+ align-items: start;
+}
+
+#tableSection {
+ min-width: 0;
+}
+
+.tableHint {
+ color: #888;
+ font-size: 0.8rem;
+ text-align: center;
+ margin-top: 1rem;
+}
+
+/* ---------- Poker table ---------- */
+
+#pokerTableWrap {
+ position: relative;
+ width: 100%;
+ padding-bottom: 62%;
+}
+
+#pokerTable {
+ position: absolute;
+ inset: 6%;
+ border-radius: 50%;
+ background: radial-gradient(ellipse at center, #1f6b3a 0%, #124a27 70%, #0d3a1e 100%);
+ border: 10px solid #4a2e1a;
+ box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.5), 0 8px 24px rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+#potBadge {
+ background: rgba(0, 0, 0, 0.45);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 10px;
+ padding: 0.6rem 1.2rem;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+}
+
+#potBadgeLabel {
+ font-size: 0.7rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: #bde9c9;
+}
+
+#potBadgeAmount {
+ font-size: 1.4rem;
+ font-weight: 600;
+ color: #fff;
+}
+
+#seatsLayer {
+ position: absolute;
+ inset: 0;
+}
+
+/* ---------- Seats ---------- */
+
+.seat {
+ position: absolute;
+ transform: translate(-50%, -50%);
+ width: 190px;
+ background: #232323;
+ border: 1px solid #3a3a3a;
+ border-radius: 10px;
+ padding: 0.6rem;
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
+ cursor: grab;
+ z-index: 1;
+}
+
+.seat.dragging {
+ opacity: 0.4;
+}
+
+.seat.dropTarget {
+ outline: 2px dashed #09ff00;
+ outline-offset: 2px;
+}
+
+.seat.seatInactive {
+ opacity: 0.45;
+}
+
+.seat.seatFolded .seatCard {
+ opacity: 0.55;
+}
+
+.seatChairNumber {
+ font-size: 0.65rem;
+ color: #888;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.3rem;
+}
+
+.dealerChip {
+ position: absolute;
+ top: -10px;
+ right: -10px;
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ background: #444;
+ color: #ccc;
+ border: 2px solid #666;
+ font-weight: 700;
+ font-size: 0.75rem;
+ cursor: pointer;
+ z-index: 2;
+}
+
+.dealerChip.active {
+ background: #f5d547;
+ border-color: #cba428;
+ color: #3a2e00;
+}
+
+.blindBadge {
+ display: none;
+ position: absolute;
+ top: -10px;
+ font-size: 0.65rem;
+ font-weight: 700;
+ padding: 0.1rem 0.4rem;
+ border-radius: 8px;
+ z-index: 2;
+}
+
+.blindBadge.visible {
+ display: inline-block;
+}
+
+.sbBadge {
+ left: -6px;
+ background: #2b6cb0;
+ color: #dbeafe;
+}
+
+.bbBadge {
+ left: 26px;
+ background: #b7791f;
+ color: #fff7e6;
+}
+
+.seatCardTop {
+ display: flex;
+ gap: 0.4rem;
+ margin-bottom: 0.35rem;
+}
+
+.seatNameInput {
+ flex: 1;
+ min-width: 0;
+ background: #1a1a1a;
+ border: 1px solid #3a3a3a;
+ border-radius: 5px;
+ color: #eee;
+ padding: 0.3rem 0.45rem;
+ font-size: 0.8rem;
+}
+
+.seatCardToggles {
+ display: flex;
+ gap: 0.6rem;
+ margin-bottom: 0.4rem;
+ font-size: 0.7rem;
+ color: #aaa;
+}
+
+.inHandToggle,
+.foldToggle {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ cursor: pointer;
+}
+
+.seatCards {
+ display: flex;
+ gap: 0.35rem;
+ margin-bottom: 0.4rem;
+}
+
+.cardInput {
+ width: 100%;
+ text-align: center;
+ text-transform: uppercase;
+ background: #fdfdfd;
+ color: #111;
+ border: 1px solid #3a3a3a;
+ border-radius: 5px;
+ padding: 0.35rem 0;
+ font-weight: 700;
+ font-family: var(--font-mono, monospace);
+}
+
+.seatContribution {
+ border-top: 1px dashed #3a3a3a;
+ padding-top: 0.4rem;
+ margin-bottom: 0.3rem;
+}
+
+.chipCounts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ margin-bottom: 0.35rem;
+}
+
+.chipCountRow {
+ display: flex;
+ align-items: center;
+ gap: 0.2rem;
+}
+
+.chipSwatch {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ display: inline-block;
+}
+
+.chipCountInput {
+ width: 34px;
+ background: #1a1a1a;
+ border: 1px solid #3a3a3a;
+ border-radius: 4px;
+ color: #eee;
+ font-size: 0.75rem;
+ padding: 0.1rem 0.2rem;
+}
+
+.amountOverrideRow {
+ justify-content: space-between;
+ font-size: 0.75rem;
+ color: #aaa;
+ margin-bottom: 0.3rem;
+}
+
+.amountOverrideInput {
+ width: 70px;
+ background: #1a1a1a;
+ border: 1px solid #3a3a3a;
+ border-radius: 4px;
+ color: #eee;
+ padding: 0.2rem 0.3rem;
+}
+
+.seatTotal {
+ font-size: 0.8rem;
+ color: #d6ffe0;
+ font-weight: 600;
+ text-align: right;
+}
+
+.customFields {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ margin-bottom: 0.3rem;
+}
+
+.customFieldRow {
+ display: flex;
+ gap: 0.25rem;
+}
+
+.customFieldLabel,
+.customFieldValue {
+ min-width: 0;
+ flex: 1;
+ background: #1a1a1a;
+ border: 1px solid #3a3a3a;
+ border-radius: 4px;
+ color: #eee;
+ font-size: 0.7rem;
+ padding: 0.2rem 0.3rem;
+}
+
+.removeFieldBtn,
+.removeChipColorBtn {
+ line-height: 1;
+}
+
+/* ---------- Sidebar ---------- */
+
+#holdemSidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 0.8rem;
+}
+
+.sidebarPanel {
+ background: #1e1e1e;
+ border: 1px solid #333;
+ border-radius: 8px;
+ padding: 0.2rem 0.9rem;
+}
+
+.sidebarPanel summary {
+ cursor: pointer;
+ padding: 0.7rem 0;
+ font-size: 0.9rem;
+ color: #eee;
+ letter-spacing: 0.03em;
+}
+
+.panelBody {
+ padding-bottom: 0.9rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.7rem;
+}
+
+.fieldRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.6rem;
+ font-size: 0.85rem;
+ color: #ccc;
+}
+
+.fieldRow input,
+.fieldRow select {
+ background: #141414;
+ border: 1px solid #3a3a3a;
+ border-radius: 5px;
+ color: #eee;
+ padding: 0.35rem 0.5rem;
+ max-width: 140px;
+}
+
+.fieldHint {
+ font-size: 0.75rem;
+ color: #888;
+ line-height: 1.4;
+ margin: 0;
+}
+
+#chipLegendList {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+.chipLegendRow {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.chipColorInput {
+ width: 32px;
+ height: 28px;
+ background: none;
+ border: 1px solid #3a3a3a;
+ border-radius: 5px;
+ padding: 0;
+}
+
+.chipValueInput {
+ flex: 1;
+ background: #141414;
+ border: 1px solid #3a3a3a;
+ border-radius: 5px;
+ color: #eee;
+ padding: 0.3rem 0.5rem;
+}
+
+#tableNotes {
+ width: 100%;
+ box-sizing: border-box;
+ background: #141414;
+ border: 1px solid #3a3a3a;
+ border-radius: 6px;
+ color: #eee;
+ padding: 0.5rem;
+ resize: vertical;
+ font-family: inherit;
+}
+
+/* ---------- Pot wizard ---------- */
+
+#potWizardOverlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 50;
+ padding: 1.5rem;
+}
+
+#potWizardOverlay[hidden] {
+ display: none;
+}
+
+#potWizardModal {
+ background: #1e1e1e;
+ border: 1px solid #3a3a3a;
+ border-radius: 10px;
+ width: 100%;
+ max-width: 480px;
+ max-height: 85vh;
+ overflow-y: auto;
+ padding: 1.2rem 1.4rem 1.6rem;
+}
+
+.potWizardHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.8rem;
+}
+
+.potWizardHeader h2 {
+ margin: 0;
+ font-size: 1.2rem;
+ font-weight: 400;
+ color: #f5f5f5;
+}
+
+.potWizardLog {
+ margin-bottom: 0.8rem;
+}
+
+.potWizardLogLine {
+ font-size: 0.8rem;
+ color: #999;
+ margin: 0.2rem 0;
+}
+
+.potWizardPrompt {
+ font-size: 0.95rem;
+ color: #eee;
+ margin-bottom: 0.8rem;
+}
+
+.potWizardChoices {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ margin-bottom: 0.6rem;
+}
+
+.potWizardChoice {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: #141414;
+ border: 1px solid #333;
+ border-radius: 6px;
+ padding: 0.5rem 0.7rem;
+ cursor: pointer;
+ font-size: 0.85rem;
+}
+
+.potWizardChoice:hover {
+ border-color: #555;
+}
+
+.potWizardSummary ul {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 1rem;
+}
+
+.potWizardSummary li {
+ padding: 0.4rem 0;
+ border-bottom: 1px dashed #333;
+ font-size: 0.9rem;
+ color: #d6ffe0;
+}
+
+.potWizardActions {
+ display: flex;
+ gap: 0.6rem;
+}
+
+@media (max-width: 900px) {
+ #holdemLayout {
+ grid-template-columns: 1fr;
+ }
+
+ #pokerTableWrap {
+ padding-bottom: 100%;
+ }
+
+ .seat {
+ width: 150px;
+ }
+}
diff --git a/JoshHeaps.Net/wwwroot/css/casino/page.css b/JoshHeaps.Net/wwwroot/css/casino/page.css
new file mode 100644
index 0000000..a052864
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/css/casino/page.css
@@ -0,0 +1,126 @@
+body {
+ background: #1a1a1a;
+ min-height: 100vh;
+ margin: 0;
+ padding: 0;
+}
+
+.casino-container {
+ max-width: 1000px;
+ margin: 0 auto;
+ padding: 4rem 2rem;
+ text-align: center;
+}
+
+.casino-header {
+ color: #f5f5f5;
+ font-size: 2.5rem;
+ margin-bottom: 0.5rem;
+ font-weight: 300;
+ letter-spacing: 0.1em;
+}
+
+.casino-subtitle {
+ color: #a0a0a0;
+ font-size: 1rem;
+ margin-bottom: 4rem;
+ font-weight: 300;
+}
+
+.games-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 1.5rem;
+ margin: 3rem auto;
+ max-width: 900px;
+ padding: 0 1rem;
+}
+
+.game-card {
+ background: #2a2a2a;
+ border: 1px solid #3a3a3a;
+ border-radius: 4px;
+ padding: 2rem 1.5rem;
+ transition: all 0.3s ease;
+ cursor: pointer;
+ text-decoration: none;
+ color: inherit;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.game-card:hover {
+ background: #2f2f2f;
+ border-color: #4a4a4a;
+ transform: translateY(-2px);
+}
+
+.game-card.disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.game-card.disabled:hover {
+ background: #2a2a2a;
+ border-color: #3a3a3a;
+ transform: none;
+}
+
+.game-icon {
+ font-size: 2.5rem;
+ margin-bottom: 1rem;
+ opacity: 0.9;
+}
+
+.game-title {
+ font-size: 1.2rem;
+ font-weight: 400;
+ color: #f5f5f5;
+ margin-bottom: 0.75rem;
+ letter-spacing: 0.05em;
+}
+
+.game-description {
+ font-size: 0.9rem;
+ color: #999;
+ line-height: 1.6;
+ font-weight: 300;
+}
+
+.coming-soon {
+ color: #666;
+ font-style: italic;
+ margin-top: 0.5rem;
+ font-size: 0.85rem;
+}
+
+.home-button {
+ margin-top: 4rem;
+ padding: 0.75rem 1.5rem;
+ font-size: 0.9rem;
+ background: transparent;
+ color: #f5f5f5;
+ border: 1px solid #3a3a3a;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ text-decoration: none;
+ display: inline-block;
+ letter-spacing: 0.05em;
+}
+
+.home-button:hover {
+ background: #2a2a2a;
+ border-color: #4a4a4a;
+}
+
+@media (max-width: 768px) {
+ .casino-header {
+ font-size: 2rem;
+ }
+
+ .games-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
new file mode 100644
index 0000000..7b11ea4
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -0,0 +1,70 @@
+(function () {
+ const state = HoldemState.load();
+
+ const hooks = {
+ save: s => HoldemState.save(s),
+ rerender: () => Render.renderAll(),
+ };
+
+ Render.mount(state, hooks);
+
+ document.getElementById("seatCountInput").addEventListener("change", e => {
+ HoldemState.resizeSeats(state, Number(e.target.value));
+ hooks.save(state);
+ Render.renderAll();
+ });
+
+ 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("tableNotes").addEventListener("input", e => {
+ state.notes = e.target.value;
+ hooks.save(state);
+ });
+
+ document.getElementById("addChipColorBtn").addEventListener("click", () => {
+ state.chipLegend.push({
+ id: HoldemState.makeId("chip"),
+ color: "#888888",
+ value: 1,
+ });
+ hooks.save(state);
+ Render.renderAll();
+ });
+
+ document.getElementById("nextHandBtn").addEventListener("click", () => {
+ if (!confirm("Start the next hand? This moves the button, clears cards and chip counts for every seat.")) return;
+ HoldemState.startNextHand(state);
+ hooks.save(state);
+ Render.renderAll();
+ });
+
+ document.getElementById("resetTableBtn").addEventListener("click", () => {
+ if (!confirm("Reset the whole table? This clears every seat, chip legend and note.")) return;
+ HoldemState.clear();
+ Object.assign(state, HoldemState.createDefault());
+ Render.mount(state, hooks);
+ });
+
+ document.getElementById("resolvePotBtn").addEventListener("click", () => {
+ PotWizard.open(state, () => {
+ HoldemState.startNextHand(state);
+ hooks.save(state);
+ Render.renderAll();
+ });
+ });
+
+ document.getElementById("closePotWizardBtn").addEventListener("click", () => PotWizard.close());
+ document.getElementById("potWizardOverlay").addEventListener("click", e => {
+ if (e.target.id === "potWizardOverlay") PotWizard.close();
+ });
+})();
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js
new file mode 100644
index 0000000..34e7aa4
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js
@@ -0,0 +1,78 @@
+// Pure side-pot math. No DOM, no globals besides the PotCalculator namespace, so the
+// dealer's payout logic can be reasoned about (and unit tested) on its own.
+//
+// A "contribution entry" is { id, remaining, folded }. `remaining` starts as the amount
+// that player put in the pot and is whittled down round by round as money gets paid out.
+const PotCalculator = {
+ EPSILON: 0.004,
+
+ // Builds the initial entry list from raw contributions, keeping folded players in
+ // (their chips are still "dead money" owed to whoever wins) while excluding anyone
+ // who put in nothing at all.
+ buildEntries(players) {
+ return players
+ .filter(p => p.contribution > this.EPSILON)
+ .map(p => ({ id: p.id, remaining: p.contribution, folded: !!p.folded }));
+ },
+
+ totalPot(entries) {
+ return entries.reduce((sum, e) => sum + e.remaining, 0);
+ },
+
+ // Players still eligible to be picked as a winner for the money that's left.
+ getContenders(entries) {
+ return entries.filter(e => !e.folded && e.remaining > this.EPSILON);
+ },
+
+ // Awards the current pot layer to `winnerIds` (one id, or several for a split/tie).
+ // The payout is capped at the smallest remaining contribution among the winners —
+ // that's the classic side-pot rule: an all-in player can only win what everyone at
+ // the table matched, and the excess rolls forward to the next round.
+ resolveRound(entries, winnerIds) {
+ const winners = entries.filter(e => winnerIds.includes(e.id) && e.remaining > this.EPSILON);
+ if (winners.length === 0) {
+ throw new Error("No valid winners selected for this round.");
+ }
+
+ const cap = Math.min(...winners.map(w => w.remaining));
+ let payoutPool = 0;
+ const updatedEntries = entries.map(e => {
+ if (e.remaining <= this.EPSILON) return e;
+ const used = Math.min(e.remaining, cap);
+ payoutPool += used;
+ return { ...e, remaining: Math.round((e.remaining - used) * 100) / 100 };
+ });
+
+ payoutPool = Math.round(payoutPool * 100) / 100;
+ const baseShareCents = Math.floor((payoutPool * 100) / winners.length);
+ let leftoverCents = Math.round(payoutPool * 100) - baseShareCents * winners.length;
+
+ const perWinner = winnerIds
+ .filter(id => winners.some(w => w.id === id))
+ .map(id => ({ id, amountCents: baseShareCents }));
+
+ // Odd cents (can't split evenly) go one at a time to the winners, in the order
+ // they were selected, so nothing is ever lost to rounding.
+ let i = 0;
+ while (leftoverCents > 0 && perWinner.length > 0) {
+ perWinner[i % perWinner.length].amountCents += 1;
+ leftoverCents--;
+ i++;
+ }
+
+ const result = perWinner.map(w => ({ id: w.id, amount: w.amountCents / 100 }));
+ const remainingContenders = this.getContenders(updatedEntries);
+
+ return {
+ payoutPool,
+ capApplied: cap,
+ perWinner: result,
+ updatedEntries,
+ remainingContenders,
+ };
+ },
+};
+
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = PotCalculator;
+}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
new file mode 100644
index 0000000..0639e75
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
@@ -0,0 +1,196 @@
+// Walks the dealer through awarding the pot: "who won?", and if that player was
+// all-in for less than everyone else, "who's next?" — repeating until every dollar
+// in the pot has been assigned. Built on PotCalculator's pure round-resolution math.
+const PotWizard = {
+ state: null,
+ onFinished: null,
+ entries: [],
+ playerLabelById: new Map(),
+ log: [],
+
+ open(state, onFinished) {
+ this.state = state;
+ this.onFinished = onFinished;
+
+ const players = state.seats.filter(
+ p => HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount) > 0
+ );
+ this.playerLabelById = new Map(
+ players.map(p => [p.id, this.labelFor(p, state.seats.indexOf(p))])
+ );
+ this.entries = PotCalculator.buildEntries(
+ players.map(p => ({
+ id: p.id,
+ contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
+ folded: p.folded,
+ }))
+ );
+ this.log = [];
+ this.totals = new Map();
+
+ document.getElementById("potWizardOverlay").hidden = false;
+ this.renderStep();
+ },
+
+ close() {
+ document.getElementById("potWizardOverlay").hidden = true;
+ },
+
+ labelFor(player, seatIndex) {
+ const name = player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
+ const cards = (player.cards || []).filter(Boolean).join(" ");
+ return cards ? `${name} (${cards})` : name;
+ },
+
+ addToTotal(id, amount) {
+ this.totals.set(id, (this.totals.get(id) || 0) + amount);
+ },
+
+ renderStep() {
+ // Auto-resolve any round where only one player is left standing — nothing to ask.
+ let contenders = PotCalculator.getContenders(this.entries);
+ while (contenders.length === 1) {
+ const result = PotCalculator.resolveRound(this.entries, [contenders[0].id]);
+ this.recordRound(result);
+ this.entries = result.updatedEntries;
+ contenders = PotCalculator.getContenders(this.entries);
+ }
+
+ const body = document.getElementById("potWizardBody");
+ body.innerHTML = "";
+ body.appendChild(this.renderLog());
+
+ const remaining = PotCalculator.totalPot(this.entries.filter(e => e.remaining > PotCalculator.EPSILON));
+
+ if (contenders.length === 0 && remaining > PotCalculator.EPSILON) {
+ const warning = document.createElement("p");
+ warning.className = "potWizardPrompt";
+ warning.textContent = `${Money.format(remaining)} is still unassigned, but every remaining player is marked Folded. Uncheck Fold on whoever should win it.`;
+ body.appendChild(warning);
+ body.appendChild(this.renderSummary());
+ return;
+ }
+
+ if (contenders.length === 0 || remaining <= PotCalculator.EPSILON) {
+ body.appendChild(this.renderSummary());
+ return;
+ }
+
+ const round = document.createElement("div");
+ round.className = "potWizardRound";
+
+ const prompt = document.createElement("p");
+ prompt.className = "potWizardPrompt";
+ prompt.innerHTML = this.log.length === 0
+ ? `Who won the hand? ${Money.format(remaining)} is in play.`
+ : `That side pot still leaves ${Money.format(remaining)} unassigned — who's next best?`;
+ round.appendChild(prompt);
+
+ const choices = document.createElement("div");
+ choices.className = "potWizardChoices";
+ contenders.forEach(c => {
+ const label = document.createElement("label");
+ label.className = "potWizardChoice";
+ const checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.value = c.id;
+ const text = document.createElement("span");
+ text.textContent = `${this.playerLabelById.get(c.id)} — up to ${Money.format(c.remaining)}`;
+ label.appendChild(checkbox);
+ label.appendChild(text);
+ choices.appendChild(label);
+ });
+ round.appendChild(choices);
+
+ const hint = document.createElement("p");
+ hint.className = "fieldHint";
+ hint.textContent = "Check more than one to split the pot on a tie.";
+ round.appendChild(hint);
+
+ const confirmBtn = document.createElement("button");
+ confirmBtn.className = "holdemBtn holdemBtnAccent";
+ confirmBtn.textContent = "Confirm Winner(s)";
+ confirmBtn.addEventListener("click", () => {
+ const selected = Array.from(choices.querySelectorAll("input:checked")).map(i => i.value);
+ if (selected.length === 0) return;
+ const result = PotCalculator.resolveRound(this.entries, selected);
+ this.recordRound(result);
+ this.entries = result.updatedEntries;
+ this.renderStep();
+ });
+ round.appendChild(confirmBtn);
+
+ body.appendChild(round);
+ },
+
+ recordRound(result) {
+ result.perWinner.forEach(w => this.addToTotal(w.id, w.amount));
+ this.log.push({
+ amount: result.payoutPool,
+ winners: result.perWinner.map(w => `${this.playerLabelById.get(w.id)} (${Money.format(w.amount)})`),
+ });
+ },
+
+ renderLog() {
+ const wrap = document.createElement("div");
+ wrap.className = "potWizardLog";
+ if (this.log.length === 0) return wrap;
+
+ this.log.forEach((entry, idx) => {
+ const line = document.createElement("p");
+ line.className = "potWizardLogLine";
+ line.textContent = `Pot ${idx + 1}: ${Money.format(entry.amount)} → ${entry.winners.join(", ")}`;
+ wrap.appendChild(line);
+ });
+ return wrap;
+ },
+
+ renderSummary() {
+ const wrap = document.createElement("div");
+ wrap.className = "potWizardSummary";
+
+ const heading = document.createElement("h3");
+ heading.textContent = "Final Payouts";
+ wrap.appendChild(heading);
+
+ if (this.totals.size === 0) {
+ const empty = document.createElement("p");
+ empty.className = "fieldHint";
+ empty.textContent = "Nobody has money in this pot yet — mark seats \"In hand\" and set contributions first.";
+ wrap.appendChild(empty);
+ } else {
+ const list = document.createElement("ul");
+ for (const [id, amount] of this.totals.entries()) {
+ const li = document.createElement("li");
+ li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
+ list.appendChild(li);
+ }
+ wrap.appendChild(list);
+ }
+
+ const actions = document.createElement("div");
+ actions.className = "potWizardActions";
+
+ const applyBtn = document.createElement("button");
+ applyBtn.className = "holdemBtn holdemBtnAccent";
+ applyBtn.textContent = "Start Next Hand";
+ applyBtn.addEventListener("click", () => {
+ this.close();
+ this.onFinished();
+ });
+ actions.appendChild(applyBtn);
+
+ const closeBtn = document.createElement("button");
+ closeBtn.className = "holdemBtn holdemBtnGhost";
+ closeBtn.textContent = "Close";
+ closeBtn.addEventListener("click", () => this.close());
+ actions.appendChild(closeBtn);
+
+ wrap.appendChild(actions);
+ return wrap;
+ },
+};
+
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = PotWizard;
+}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
new file mode 100644
index 0000000..7938cad
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -0,0 +1,273 @@
+// All DOM rendering + drag/drop for the table. Talks to the outside world only through
+// the `hooks` object passed to mount(): hooks.save(state) persists, hooks.rerender()
+// re-draws everything (used after structural changes like resizing seats or the chip
+// legend), hooks.rerenderPotBadge(state) just updates the pot total display.
+const Render = {
+ state: null,
+ hooks: null,
+
+ mount(state, hooks) {
+ this.state = state;
+ this.hooks = hooks;
+ this.renderAll();
+ },
+
+ renderAll() {
+ this.renderSeats();
+ this.renderChipLegend();
+ this.renderNotes();
+ this.updatePotBadge();
+ this.renderSetupFields();
+ },
+
+ renderSetupFields() {
+ document.getElementById("seatCountInput").value = this.state.seatCount;
+ document.getElementById("directionSelect").value = this.state.direction;
+ document.getElementById("basePotInput").value = this.state.basePotAmount ?? "";
+ },
+
+ renderNotes() {
+ const notesEl = document.getElementById("tableNotes");
+ if (document.activeElement !== notesEl) notesEl.value = this.state.notes || "";
+ },
+
+ updatePotBadge() {
+ const total = HoldemState.totalPot(this.state);
+ document.getElementById("potBadgeAmount").textContent = Money.format(total);
+ },
+
+ commitAndRedrawSeat(seatEl, seatIndex) {
+ this.hooks.save(this.state);
+ this.updatePotBadge();
+ this.renderSeatTotal(seatEl, this.state.seats[seatIndex]);
+ },
+
+ renderSeatTotal(seatEl, player) {
+ const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
+ seatEl.querySelector(".seatTotalAmount").textContent = Money.format(total);
+ },
+
+ // ---- Seats around the table ----
+
+ seatPosition(index, count) {
+ const angle = -90 + (360 / count) * index;
+ const radians = (angle * Math.PI) / 180;
+ const rx = 44;
+ const ry = 40;
+ const left = 50 + rx * Math.cos(radians);
+ const top = 50 + ry * Math.sin(radians);
+ return { left, top };
+ },
+
+ renderSeats() {
+ const layer = document.getElementById("seatsLayer");
+ layer.innerHTML = "";
+ const template = document.getElementById("seatTemplate");
+ const { dealerIndex, sbIndex, bbIndex } = HoldemState.getBlindSeats(this.state);
+
+ this.state.seats.forEach((player, index) => {
+ const node = template.content.firstElementChild.cloneNode(true);
+ const pos = this.seatPosition(index, this.state.seatCount);
+ node.style.left = `${pos.left}%`;
+ node.style.top = `${pos.top}%`;
+ node.dataset.seatIndex = String(index);
+
+ node.querySelector(".seatChairNumber").textContent = `Seat ${index + 1}`;
+
+ const dealerChip = node.querySelector(".dealerChip");
+ dealerChip.classList.toggle("active", index === dealerIndex);
+ dealerChip.addEventListener("click", () => {
+ this.state.dealerSeatIndex = index;
+ this.hooks.save(this.state);
+ this.renderSeats();
+ });
+
+ node.querySelector(".sbBadge").classList.toggle("visible", index === sbIndex);
+ node.querySelector(".bbBadge").classList.toggle("visible", index === bbIndex);
+
+ this.wireSeatFields(node, player, index);
+ this.renderChipCounts(node, player);
+ this.renderCustomFields(node, player);
+ this.renderSeatTotal(node, player);
+ node.classList.toggle("seatInactive", !player.inHand);
+ node.classList.toggle("seatFolded", player.folded);
+
+ this.wireDragAndDrop(node, index);
+
+ layer.appendChild(node);
+ });
+ },
+
+ wireSeatFields(node, player, index) {
+ const nameInput = node.querySelector(".seatNameInput");
+ nameInput.value = player.name;
+ nameInput.addEventListener("input", () => {
+ player.name = nameInput.value;
+ this.hooks.save(this.state);
+ });
+
+ const inHandCheckbox = node.querySelector(".inHandCheckbox");
+ inHandCheckbox.checked = player.inHand;
+ inHandCheckbox.addEventListener("change", () => {
+ player.inHand = inHandCheckbox.checked;
+ if (!player.inHand) player.folded = false;
+ this.renderSeats();
+ this.hooks.save(this.state);
+ this.updatePotBadge();
+ });
+
+ const foldCheckbox = node.querySelector(".foldCheckbox");
+ foldCheckbox.checked = player.folded;
+ foldCheckbox.disabled = !player.inHand;
+ foldCheckbox.addEventListener("change", () => {
+ player.folded = foldCheckbox.checked;
+ node.classList.toggle("seatFolded", player.folded);
+ this.hooks.save(this.state);
+ });
+
+ const card1 = node.querySelector(".card1Input");
+ const card2 = node.querySelector(".card2Input");
+ card1.value = player.cards[0] || "";
+ card2.value = player.cards[1] || "";
+ [card1, card2].forEach((input, cardIdx) => {
+ input.addEventListener("input", () => {
+ input.value = input.value.toUpperCase();
+ player.cards[cardIdx] = input.value;
+ this.hooks.save(this.state);
+ });
+ });
+
+ const amountInput = node.querySelector(".amountOverrideInput");
+ amountInput.value = player.amountOverride ?? "";
+ amountInput.addEventListener("input", () => {
+ player.amountOverride = amountInput.value === "" ? null : amountInput.value;
+ this.commitAndRedrawSeat(node, index);
+ });
+
+ const addFieldBtn = node.querySelector(".addFieldBtn");
+ addFieldBtn.addEventListener("click", () => {
+ player.customFields.push({ label: "", value: "" });
+ this.renderCustomFields(node, player);
+ this.hooks.save(this.state);
+ });
+ },
+
+ renderChipCounts(node, player) {
+ const container = node.querySelector(".chipCounts");
+ container.innerHTML = "";
+ const template = document.getElementById("chipCountTemplate");
+
+ this.state.chipLegend.forEach(chip => {
+ const row = template.content.firstElementChild.cloneNode(true);
+ row.querySelector(".chipSwatch").style.background = chip.color;
+ row.title = `$${chip.value} chips`;
+ const input = row.querySelector(".chipCountInput");
+ input.value = player.chipCounts[chip.id] || 0;
+ input.addEventListener("input", () => {
+ const count = Math.max(0, Math.floor(Number(input.value)) || 0);
+ player.chipCounts[chip.id] = count;
+ this.commitAndRedrawSeat(node, Number(node.dataset.seatIndex));
+ });
+ container.appendChild(row);
+ });
+ },
+
+ renderCustomFields(node, player) {
+ const container = node.querySelector(".customFields");
+ container.innerHTML = "";
+ const template = document.getElementById("customFieldTemplate");
+
+ player.customFields.forEach((field, fieldIdx) => {
+ const row = template.content.firstElementChild.cloneNode(true);
+ const labelInput = row.querySelector(".customFieldLabel");
+ const valueInput = row.querySelector(".customFieldValue");
+ labelInput.value = field.label;
+ valueInput.value = field.value;
+ labelInput.addEventListener("input", () => {
+ field.label = labelInput.value;
+ this.hooks.save(this.state);
+ });
+ valueInput.addEventListener("input", () => {
+ field.value = valueInput.value;
+ this.hooks.save(this.state);
+ });
+ row.querySelector(".removeFieldBtn").addEventListener("click", () => {
+ player.customFields.splice(fieldIdx, 1);
+ this.renderCustomFields(node, player);
+ this.hooks.save(this.state);
+ });
+ container.appendChild(row);
+ });
+ },
+
+ wireDragAndDrop(node, index) {
+ node.addEventListener("dragstart", e => {
+ e.dataTransfer.setData("text/plain", String(index));
+ e.dataTransfer.effectAllowed = "move";
+ node.classList.add("dragging");
+ });
+ node.addEventListener("dragend", () => node.classList.remove("dragging"));
+ node.addEventListener("dragover", e => {
+ e.preventDefault();
+ node.classList.add("dropTarget");
+ });
+ node.addEventListener("dragleave", () => node.classList.remove("dropTarget"));
+ node.addEventListener("drop", e => {
+ e.preventDefault();
+ node.classList.remove("dropTarget");
+ const sourceIndex = Number(e.dataTransfer.getData("text/plain"));
+ const targetIndex = index;
+ if (Number.isNaN(sourceIndex) || sourceIndex === targetIndex) return;
+ const seats = this.state.seats;
+ [seats[sourceIndex], seats[targetIndex]] = [seats[targetIndex], seats[sourceIndex]];
+ this.hooks.save(this.state);
+ this.renderSeats();
+ });
+ },
+
+ // ---- Chip legend ----
+
+ renderChipLegend() {
+ const list = document.getElementById("chipLegendList");
+ list.innerHTML = "";
+ const template = document.getElementById("chipLegendRowTemplate");
+
+ this.state.chipLegend.forEach(chip => {
+ const row = template.content.firstElementChild.cloneNode(true);
+ const colorInput = row.querySelector(".chipColorInput");
+ const valueInput = row.querySelector(".chipValueInput");
+ colorInput.value = chip.color;
+ valueInput.value = chip.value;
+
+ colorInput.addEventListener("input", () => {
+ chip.color = colorInput.value;
+ this.hooks.save(this.state);
+ this.renderSeats();
+ });
+ valueInput.addEventListener("input", () => {
+ chip.value = Number(valueInput.value) || 0;
+ this.hooks.save(this.state);
+ this.renderSeats();
+ this.updatePotBadge();
+ });
+ row.querySelector(".removeChipColorBtn").addEventListener("click", () => {
+ this.state.chipLegend = this.state.chipLegend.filter(c => c.id !== chip.id);
+ this.hooks.save(this.state);
+ this.renderAll();
+ });
+
+ list.appendChild(row);
+ });
+ },
+};
+
+const Money = {
+ format(amount) {
+ const value = Number(amount) || 0;
+ return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
+ },
+};
+
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = { Render, Money };
+}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
new file mode 100644
index 0000000..097f564
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -0,0 +1,143 @@
+// Table state: seats, chips, blinds, notes. Persisted to localStorage so a dealer's
+// browser refresh mid-game doesn't lose the table.
+const HoldemState = {
+ STORAGE_KEY: "casino.holdem.state.v1",
+ DEFAULT_SEAT_COUNT: 10,
+
+ DEFAULT_CHIP_LEGEND: [
+ { id: "white", color: "#f5f5f5", value: 1 },
+ { id: "red", color: "#c0392b", value: 5 },
+ { id: "green", color: "#1e8449", value: 25 },
+ { id: "black", color: "#111111", value: 100 },
+ { id: "purple", color: "#6c3483", value: 500 },
+ ],
+
+ makeId(prefix) {
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
+ },
+
+ makePlayer() {
+ return {
+ id: this.makeId("player"),
+ name: "",
+ inHand: false,
+ folded: false,
+ cards: ["", ""],
+ chipCounts: {},
+ amountOverride: null,
+ customFields: [],
+ };
+ },
+
+ createDefault() {
+ const seatCount = this.DEFAULT_SEAT_COUNT;
+ return {
+ seatCount,
+ direction: "clockwise",
+ basePotAmount: null,
+ dealerSeatIndex: 0,
+ chipLegend: this.DEFAULT_CHIP_LEGEND.map(c => ({ ...c })),
+ seats: Array.from({ length: seatCount }, () => this.makePlayer()),
+ notes: "",
+ };
+ },
+
+ load() {
+ try {
+ const raw = localStorage.getItem(this.STORAGE_KEY);
+ if (!raw) return this.createDefault();
+ const parsed = JSON.parse(raw);
+ if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault();
+ return parsed;
+ } catch {
+ return this.createDefault();
+ }
+ },
+
+ save(state) {
+ try {
+ localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
+ } catch {
+ // Storage full/unavailable (private browsing, etc.) — the table still works
+ // for the rest of the session, it just won't survive a refresh.
+ }
+ },
+
+ clear() {
+ try {
+ localStorage.removeItem(this.STORAGE_KEY);
+ } catch {
+ // ignore
+ }
+ },
+
+ resizeSeats(state, newCount) {
+ const count = Math.max(2, Math.min(24, Math.floor(newCount) || this.DEFAULT_SEAT_COUNT));
+ const seats = state.seats.slice(0, count);
+ while (seats.length < count) seats.push(this.makePlayer());
+ state.seats = seats;
+ state.seatCount = count;
+ if (state.dealerSeatIndex >= count) state.dealerSeatIndex = 0;
+ return state;
+ },
+
+ // Contribution the table calculator will treat this player as having put in.
+ computeContribution(player, chipLegend, basePotAmount) {
+ if (!player.inHand) return 0;
+
+ const override = player.amountOverride;
+ if (override !== null && override !== undefined && String(override).trim() !== "") {
+ const parsed = Number(override);
+ if (!Number.isNaN(parsed)) return Math.max(0, parsed);
+ }
+
+ const chipTotal = chipLegend.reduce((sum, chip) => {
+ const count = Number(player.chipCounts[chip.id]) || 0;
+ return sum + count * Number(chip.value || 0);
+ }, 0);
+ if (chipTotal > 0) return chipTotal;
+
+ const base = Number(basePotAmount);
+ return Number.isNaN(base) ? 0 : Math.max(0, base);
+ },
+
+ totalPot(state) {
+ return state.seats.reduce(
+ (sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
+ 0
+ );
+ },
+
+ // Next occupied-by-index seat in the current blind direction. Every seat index
+ // counts (there's no separate "empty chair" concept) — a dealer running fewer
+ // players just keeps the seat count matched to who's actually sitting down.
+ nextSeatIndex(state, fromIndex, step = 1) {
+ const count = state.seatCount;
+ const delta = state.direction === "counterclockwise" ? -step : step;
+ return ((fromIndex + delta) % count + count) % count;
+ },
+
+ advanceButton(state) {
+ state.dealerSeatIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
+ },
+
+ getBlindSeats(state) {
+ const sbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
+ const bbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 2);
+ return { dealerIndex: state.dealerSeatIndex, sbIndex, bbIndex };
+ },
+
+ startNextHand(state) {
+ this.advanceButton(state);
+ state.seats.forEach(p => {
+ p.folded = false;
+ p.cards = ["", ""];
+ p.chipCounts = {};
+ p.amountOverride = null;
+ });
+ },
+};
+
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = HoldemState;
+}