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"; +} + +
+

Casino Games

+

Tools for running table games without doing the math yourself

+ +
+ +
🃏
+
Texas Hold'em
+
+ A dealer's assistant: seat players around the table, track blinds, chips and + all-ins, then split main and side pots automatically. +
+
+ +
+
🎲
+
Craps
+
Payout calculator for the table.
+
Coming soon...
+
+ +
+
🎡
+
Roulette
+
Bet tracking and payout reference.
+
Coming soon...
+
+ +
+
🂡
+
Blackjack
+
Multi-hand dealer tracker.
+
Coming soon...
+
+
+ + ← 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"; +} + +
+
+
+

Texas Hold'em Dealer

+

Seat your players, track the action, split the pot.

+
+
+ + + + ← Casino +
+
+ +
+
+
+
+
+ Pot + $0 +
+
+
+
+

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; +}