Merge pull request 'Add Casino demo section with a Texas Hold'em dealer's pot-splitting assistant' (#10) from feature/casino-texas-holdem into master
Build and Deploy / build-deploy (push) Successful in 1m16s

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-08-31 14:39:02 -06:00
13 changed files with 1721 additions and 1 deletions
+49
View File
@@ -0,0 +1,49 @@
@page
@model JoshHeaps.Net.Pages.CasinoModel
@{
ViewData["Title"] = "Casino Games";
Layout = "_Layout";
}
<div class="casino-container">
<h1 class="casino-header">Casino Games</h1>
<p class="casino-subtitle">Tools for running table games without doing the math yourself</p>
<div class="games-grid">
<a href="/casino/texasholdem" class="game-card">
<div class="game-icon">🃏</div>
<div class="game-title">Texas Hold'em</div>
<div class="game-description">
A dealer's assistant: seat players around the table, track blinds, chips and
all-ins, then split main and side pots automatically.
</div>
</a>
<div class="game-card disabled">
<div class="game-icon">🎲</div>
<div class="game-title">Craps</div>
<div class="game-description">Payout calculator for the table.</div>
<div class="coming-soon">Coming soon...</div>
</div>
<div class="game-card disabled">
<div class="game-icon">🎡</div>
<div class="game-title">Roulette</div>
<div class="game-description">Bet tracking and payout reference.</div>
<div class="coming-soon">Coming soon...</div>
</div>
<div class="game-card disabled">
<div class="game-icon">🂡</div>
<div class="game-title">Blackjack</div>
<div class="game-description">Multi-hand dealer tracker.</div>
<div class="coming-soon">Coming soon...</div>
</div>
</div>
<a href="/" class="home-button">← Back to Home</a>
</div>
@section Styles {
<link href="/css/casino/page.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class CasinoModel : PageModel
{
public void OnGet()
{
}
}
}
@@ -0,0 +1,159 @@
@page
@model JoshHeaps.Net.Pages.Casino.TexasHoldemModel
@{
ViewData["Title"] = "Texas Hold'em Dealer";
Layout = "_Layout";
}
<div id="holdemApp">
<header id="holdemHeader">
<div id="holdemHeaderLeft">
<h1>Texas Hold'em Dealer</h1>
<p class="holdemSubtitle">Seat your players, track the action, split the pot.</p>
</div>
<div id="holdemHeaderActions">
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
<a href="/casino" class="holdemBtn holdemBtnGhost">← Casino</a>
</div>
</header>
<div id="holdemLayout">
<section id="tableSection">
<div id="pokerTableWrap">
<div id="pokerTable">
<div id="potBadge">
<span id="potBadgeLabel">Pot</span>
<span id="potBadgeAmount">$0</span>
</div>
</div>
<div id="seatsLayer"></div>
</div>
<p class="tableHint">Drag a seat onto another seat to swap players. Click the dealer chip on a seat to make it the button.</p>
</section>
<aside id="holdemSidebar">
<details class="sidebarPanel" open>
<summary>Table Setup</summary>
<div class="panelBody">
<label class="fieldRow">
<span>Seats at table</span>
<input type="number" id="seatCountInput" min="2" max="24" value="10" />
</label>
<label class="fieldRow">
<span>Blinds move</span>
<select id="directionSelect">
<option value="clockwise" selected>Clockwise</option>
<option value="counterclockwise">Counterclockwise</option>
</select>
</label>
<label class="fieldRow">
<span>Base pot amount ($)</span>
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
</label>
<p class="fieldHint">Every player who didn't go all-in short is assumed to have put in this amount. Enter a smaller amount on a seat only if that player is all-in for less.</p>
</div>
</details>
<details class="sidebarPanel">
<summary>Chip Legend</summary>
<div class="panelBody">
<div id="chipLegendList"></div>
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
<p class="fieldHint">Give each chip color a dollar value, then count chips per seat instead of typing a total.</p>
</div>
</details>
<details class="sidebarPanel">
<summary>Table Notes</summary>
<div class="panelBody">
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
</div>
</details>
</aside>
</div>
</div>
<template id="seatTemplate">
<div class="seat" draggable="true">
<div class="seatChairNumber"></div>
<button class="dealerChip" title="Make this seat the dealer button">D</button>
<span class="blindBadge sbBadge">SB</span>
<span class="blindBadge bbBadge">BB</span>
<div class="seatCard">
<div class="seatCardTop">
<input type="text" class="seatNameInput" placeholder="Seat" maxlength="24" />
</div>
<div class="seatCardToggles">
<label class="inHandToggle" title="This seat is playing the current hand">
<input type="checkbox" class="inHandCheckbox" />
<span>In hand</span>
</label>
<label class="foldToggle" title="Folded">
<input type="checkbox" class="foldCheckbox" />
<span>Fold</span>
</label>
</div>
<div class="seatCards">
<input type="text" class="cardInput card1Input" maxlength="2" placeholder="__" />
<input type="text" class="cardInput card2Input" maxlength="2" placeholder="__" />
</div>
<div class="seatContribution">
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
<span>$ amount</span>
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
</label>
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
</div>
<div class="customFields"></div>
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
</div>
</div>
</template>
<template id="chipLegendRowTemplate">
<div class="chipLegendRow">
<input type="color" class="chipColorInput" />
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" />
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
</div>
</template>
<template id="chipCountTemplate">
<label class="chipCountRow">
<span class="chipSwatch"></span>
<input type="number" class="chipCountInput" min="0" step="1" value="0" />
</label>
</template>
<template id="customFieldTemplate">
<div class="customFieldRow">
<input type="text" class="customFieldLabel" placeholder="Label" maxlength="20" />
<input type="text" class="customFieldValue" placeholder="Value" maxlength="40" />
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeFieldBtn">×</button>
</div>
</template>
<div id="potWizardOverlay" hidden>
<div id="potWizardModal">
<div class="potWizardHeader">
<h2>Resolve the Pot</h2>
<button id="closePotWizardBtn" class="holdemBtn holdemBtnGhost">✕</button>
</div>
<div id="potWizardBody"></div>
</div>
</div>
@section Styles {
<link href="/css/casino/holdem.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@section Scripts {
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.Casino
{
public class TexasHoldemModel : PageModel
{
public void OnGet()
{
}
}
}
+1
View File
@@ -64,6 +64,7 @@
<button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button> <button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button> <button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button> <button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
<button class="demoButton" onclick="window.location.href='/casino'">Casino Games</button>
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button> <button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
<button id="MoreFiller" class="demoButton">More coming soon...</button> <button id="MoreFiller" class="demoButton">More coming soon...</button>
</div> </div>
+1 -1
View File
@@ -1,6 +1,6 @@
@{ @{
Layout = null; Layout = null;
ViewData["cssVersion"] = "1.0.7"; // <--- change this once to bust cache ViewData["cssVersion"] = "1.0.8"; // <--- change this once to bust cache
} }
<!DOCTYPE html> <!DOCTYPE html>
+603
View File
@@ -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;
}
}
+126
View File
@@ -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;
}
}
@@ -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();
});
})();
@@ -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;
}
@@ -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? <strong>${Money.format(remaining)}</strong> is in play.`
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> 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;
}
@@ -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 };
}
@@ -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;
}