Compare commits

...
Author SHA1 Message Date
jheapsandClaude Sonnet 5 bcfc1f2f52 Track session standings so the dealer can settle up at the end of the night
The hand log answered "what happened in hand 12"; it couldn't answer "what
does Alice owe". Each resolved pot now records a per-seat ledger (what that
player put in, what they took down) alongside the payouts, and a Session
Standings panel sums it into one row per player — paid in, won, net, sorted
by who's up. The CSV export carries the same table, so one file covers both
the hand-by-hand trail and the cash-out numbers.

Keyed by seat id rather than name, so renaming or reseating a player doesn't
split their totals. Hands recorded before this change have no ledger and are
skipped rather than counted wrong.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 17:37:48 -06:00
jheapsandClaude Sonnet 5 d168fedf1f Add a CSV export for the Texas Hold'em hand history log
A dedicated dealer needs a paper trail that outlives the browser tab, not
just a scrollable in-app panel — the Export CSV button downloads the full
resolved-pot log (hand #, time, pot, payouts) for recordkeeping or a
disputed chip count after the session ends.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 17:26:11 -06:00
jheapsandClaude Sonnet 5 68e263f325 Add a persistent hand history log to the Texas Hold'em dealer tool
Give the dealer a running record of resolved pots — hand number, total
pot, who got paid what, and the time — instead of losing that
information the moment the pot wizard closes. It's the kind of audit
trail a real dealer needs when a chip count gets disputed after the
fact.

- state.js tracks state.handNumber and state.handHistory (capped at 200
  entries) and persists both through the existing localStorage save/load
  path, with backfill defaults for tables saved before this change.
- potWizard.js now hands its final payouts back to the caller instead of
  discarding them once "Start Next Hand" is clicked.
- main.js records a history entry (skipping hands where nobody actually
  won anything) before advancing to the next hand, and wires up a "Clear
  history" control that also resets the hand counter.
- A new sidebar panel lists history entries newest-first; the header
  shows a running "Hand #N" badge next to the existing subtitle.

Verified via Playwright MCP: seated two players, resolved a pot, and
confirmed the header's hand counter incremented, the history panel
listed the payout, the entry survived a full page reload (localStorage
persistence), and Clear History reset both the log and the counter. No
console errors during the flow.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01C3NiiUDimoKDJ8GjC3KL3x
2026-08-31 17:15:23 -06:00
jheaps 105a74bc51 Merge pull request 'Feature/casino texas holdem' (#13) from feature/casino-texas-holdem into master
Build and Deploy / build-deploy (push) Successful in 1m11s
Reviewed-on: #13
2026-08-31 16:06:56 -06:00
jheaps dce7e0f688 Merge pull request 'Add dedicated-dealer mode and redesign the Hold'em table' (#12) from feature/casino-holdem-dealer-mode-polish into feature/casino-texas-holdem
Reviewed-on: #12
2026-08-31 15:58:17 -06:00
jheapsandClaude Sonnet 5 3851786b90 Add dedicated-dealer mode and redesign the table's visuals
Add a "Dedicated dealer (not a seat)" toggle for tables run by someone
outside the game: it hides every seat's dealer button while blinds keep
rotating automatically off the same internal position each hand.

Also gave the table a real casino look, based on reference felt/rail and
seat-pod patterns found via web search (huggingface.co/spaces/Estazz/
PokerGameDesigner, github.com/Tehes/poker): a richer radial felt gradient,
a gold piping ring inside a wood-toned rail, a subtle center watermark, a
pill-shaped chip-well pot badge, softer seat pucks with a pill-badge total
and red/black suit coloring on cards, and casino-chip-styled dealer/blind
badges (the dealer chip now dims to a faint disc on other seats and only
lights up on hover, decluttering the ring of ten clickable buttons down to
one visible button on the actual dealer).

Verified via Playwright MCP: toggle hides/reveals dealer chips correctly,
SB/BB still track it, no console errors.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 15:26:32 -06:00
jheaps 8e7d94e6f9 Merge pull request 'Fix Texas Hold'em dealer page: script timing crash and seat overlap' (#11) from feature/casino-texas-holdem into master
Build and Deploy / build-deploy (push) Successful in 1m8s
Reviewed-on: #11
2026-08-31 15:03:37 -06:00
jheapsandClaude Sonnet 5 25bf67062a Fix Texas Hold'em dealer page: script timing crash and seat overlap
main.js ran an IIFE immediately, but the layout renders @section Scripts
inside <head> before <body> exists, so every getElementById() call hit
null. Wrapped init in DOMContentLoaded, matching the rest of the codebase
(e.g. ChessScripts/chessMain.js).

Separately, the original seat cards packed a full data-entry form (name,
toggles, 2 cards, 5 chip counters, $ override, custom fields) into each of
up to 24 positions around a circle — at any screen size, adjacent seats
overlapped badly. Replaced with compact seat "pucks" for the table view
(name, cards, total, blind/dealer badges) plus a dedicated seat editor
panel in the sidebar that opens for whichever seat is clicked.

Verified via Playwright MCP: load with no console errors, drag-and-drop
seat swap, and a 3-way side-pot resolution (short all-in capped correctly,
remainder awarded to the next-best hand) end to end.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 15:01:18 -06:00
jheaps 5d71572df6 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
2026-08-31 14:39:02 -06:00
jheapsandClaude Sonnet 5 98f95df8fb Add Casino demo section with a Texas Hold'em dealer's pot-splitting assistant
New /casino hub page (Texas Hold'em live, other table games marked coming
soon) and /casino/texasholdem: a drag-and-drop seating table with
auto-rotating blinds, per-seat chip/dollar contribution tracking, and a
side-pot resolution wizard that asks who won and who's next until every
dollar in the pot (including dead money from folds) is assigned.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 14:25:44 -06:00
jheaps b7c9227bb8 Merge pull request 'Learn fractional win-rate advantage instead of unbounded plus/minus' (#9) from feature/fractional-learned-weights into master
Build and Deploy / build-deploy (push) Successful in 1m25s
2026-08-31 13:43:09 -06:00
jheapsandClaude Sonnet 5 ddb309f6db Learn fractional win-rate advantage instead of unbounded plus/minus
Piece-square and feature weights now converge to a presence-weighted
fractional win-rate per cell (percent of games won with a piece there
minus percent lost), tracked via running visits/score sums instead of
an incrementally clamped nudge. The training signal is also scaled by
how much of a game's result is NOT already explained by the winner's
average material edge (via the standard centipawn win-probability
curve), so a piece no longer gets credit merely for existing on the
winning side of a material blowout in the endgame.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 13:40:57 -06:00
jheaps 2db4048ed2 Merge pull request 'Time out Stockfish process exit so finished self-play games cant wedge in the store' (#8) from fix/stockfish-dispose-hang into master
Build and Deploy / build-deploy (push) Successful in 1m18s
Reviewed-on: #8
2026-08-31 12:22:28 -06:00
jheapsandClaude Sonnet 5 2f53c2f0ed Time out Stockfish process exit so finished self-play games can't wedge in the store
DisposeAsync awaited WaitForExitAsync with no timeout; a Stockfish process that
doesn't exit on "quit" blocked GameStore's removal task forever, leaving the
finished game stuck in the store and cluttering the live-watch list indefinitely.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 11:23:59 -06:00
jheaps 7562ded92a Merge pull request 'Bound how long the blog cache serves stale entries' (#7) from fix/blog-cache-staleness into master
Build and Deploy / build-deploy (push) Successful in 1m4s
Reviewed-on: #7
2026-08-26 17:24:29 -06:00
Josh-HeapsandClaude Opus 5 392b9ec212 Bound how long the blog cache serves stale entries
A failed fetch fell back to the cached value with no limit, so an unreachable
blog API kept the post list rendering while every uncached slug returned 404.
Stale entries now survive an hour past their TTL, then get dropped and logged
at error so the outage surfaces the same way for every key.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 17:22:51 -06:00
jheaps 769c7819e0 Merge pull request 'fix workflows take 2' (#6) from fix/workflows into master
Build and Deploy / build-deploy (push) Successful in 1m6s
Reviewed-on: #6
2026-08-26 15:46:12 -06:00
jheaps e8eb90a085 Merge pull request 'Put workflow in the right spot' (#4) from fix/workflows into master
Build and Deploy / build-deploy (push) Failing after 1m7s
Reviewed-on: #4
2026-08-26 15:39:47 -06:00
18 changed files with 2537 additions and 41 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,229 @@
@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>
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
</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="tableWatermark">Texas Hold'em</div>
<div id="potBadge">
<span id="potBadgeLabel">Pot</span>
<span id="potBadgeAmount">$0</span>
</div>
</div>
<div id="seatsLayer"></div>
</div>
<p id="tableHint" class="tableHint"></p>
</section>
<aside id="holdemSidebar">
<section id="seatEditorPanel" class="sidebarPanel seatEditorPanel">
<h2 id="seatEditorTitle">Select a seat</h2>
<div id="seatEditorBody">
<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>
</div>
</section>
<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>Dedicated dealer (not a seat)</span>
<input type="checkbox" id="dedicatedDealerToggle" />
</label>
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
<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>
<details class="sidebarPanel">
<summary>Session Standings</summary>
<div class="panelBody">
<div id="standingsTable">
<div class="standingsRow standingsHeader">
<span>Player</span>
<span>In</span>
<span>Won</span>
<span>Net</span>
</div>
<div id="standingsList"></div>
</div>
<p id="standingsEmptyHint" class="fieldHint">Once you resolve a pot, everyone's running total for the night shows up here.</p>
</div>
</details>
<details class="sidebarPanel">
<summary>Hand History</summary>
<div class="panelBody">
<div id="handHistoryList"></div>
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots show up here as you play, in case a chip count is disputed later.</p>
<div class="handHistoryActions">
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
</div>
</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="seatPuckName"></div>
<div class="seatPuckCards"></div>
<div class="seatPuckTotal"></div>
</div>
</template>
<template id="seatEditorTemplate">
<div class="seatEditorForm">
<label class="fieldRow">
<span>Name</span>
<input type="text" class="seatNameInput" placeholder="Seat" maxlength="24" />
</label>
<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>
</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>
<template id="standingsRowTemplate">
<div class="standingsRow">
<span class="standingsName"></span>
<span class="standingsIn"></span>
<span class="standingsWon"></span>
<span class="standingsNet"></span>
</div>
</template>
<template id="handHistoryRowTemplate">
<div class="handHistoryRow">
<div class="handHistoryRowHeader">
<span class="handHistoryHandNumber"></span>
<span class="handHistoryPot"></span>
<span class="handHistoryTime"></span>
</div>
<div class="handHistoryPayouts"></div>
</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/handHistoryExport.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='/particles'">Particle Simulator</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 id="MoreFiller" class="demoButton">More coming soon...</button>
</div>
+1 -1
View File
@@ -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
}
<!DOCTYPE html>
@@ -1,4 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using System.Text.Json;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
@@ -10,6 +10,7 @@ public class BlogService : IBlogService
private readonly HttpClient _httpClient;
private readonly ILogger<BlogService> _logger;
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1);
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
@@ -65,7 +66,9 @@ public class BlogService : IBlogService
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
{
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > DateTime.UtcNow)
_cache.TryGetValue(key, out var entry);
if (entry is not null && entry.ExpiresAt > DateTime.UtcNow)
return (T?)entry.Value;
var result = await factory();
@@ -73,15 +76,26 @@ public class BlogService : IBlogService
if (result is not null)
{
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
return result;
}
else if (entry is not null)
return entry is null ? null : ServeStale<T>(key, entry);
}
// Stale entries are dropped once the grace window closes so that a dead API fails the same
// way for every key. Serving them indefinitely let a cached post list render next to 404s on
// the posts themselves, which reads as a site bug rather than an outage.
private T? ServeStale<T>(string key, CacheEntry entry) where T : class
{
if (entry.ExpiresAt.Add(_staleGrace) > DateTime.UtcNow)
{
// API unreachable — serve stale cache
_logger.LogWarning("Serving stale cache for key {Key}", key);
_logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key);
return (T?)entry.Value;
}
return result;
_cache.TryRemove(key, out _);
_logger.LogError("Blog API unreachable for over {StaleGrace}, dropping stale cache for key {Key}", _staleGrace, key);
return null;
}
public void ClearCache()
@@ -100,11 +100,26 @@ public sealed class Stockfish : IChessEngine
if (line == token) break;
}
// If Stockfish doesn't exit on "quit" (crashed, zombie, ignores stdin close), don't hang
// here forever — that would block GameStore's removal task and leave the finished game
// stuck in the store, cluttering the watch page indefinitely.
private static readonly TimeSpan _exitTimeout = TimeSpan.FromSeconds(5);
public async ValueTask DisposeAsync()
{
Send("quit");
_stdin.Close();
await _p.WaitForExitAsync();
try
{
await _p.WaitForExitAsync().WaitAsync(_exitTimeout);
}
catch (TimeoutException)
{
if (!_p.HasExited)
_p.Kill(entireProcessTree: true);
}
_p.Dispose();
}
}
+845
View File
@@ -0,0 +1,845 @@
body {
background: radial-gradient(ellipse at 50% -10%, #1c1c1c 0%, #101010 55%, #0a0a0a 100%);
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;
position: relative;
display: inline-block;
padding-bottom: 0.4rem;
}
#holdemHeader h1::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 42px;
height: 3px;
border-radius: 2px;
background: linear-gradient(90deg, #d8b46a, #a9782f);
}
.holdemSubtitle {
margin: 0.25rem 0 0;
color: #999;
font-size: 0.9rem;
}
.handCounterBadge {
display: inline-block;
margin-top: 0.5rem;
padding: 0.2rem 0.65rem;
border-radius: 999px;
background: rgba(216, 180, 106, 0.12);
border: 1px solid rgba(216, 180, 106, 0.4);
color: #d8b46a;
font-size: 0.75rem;
letter-spacing: 0.04em;
}
#holdemHeaderActions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.holdemBtn {
background: linear-gradient(180deg, #2e2e2e, #232323);
color: #eee;
border: 1px solid #3a3a3a;
border-radius: 7px;
padding: 0.6rem 1rem;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.3rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.holdemBtn:hover {
background: linear-gradient(180deg, #383838, #2a2a2a);
border-color: #4a4a4a;
transform: translateY(-1px);
}
.holdemBtnAccent {
background: linear-gradient(180deg, #16783a, #0d5225);
border-color: #1e8449;
color: #ecffef;
}
.holdemBtnAccent:hover {
background: linear-gradient(180deg, #1a8a44, #0f602c);
}
.holdemBtnDanger {
background: linear-gradient(180deg, #4a1f1f, #351515);
border-color: #6b2626;
color: #ffd6d6;
}
.holdemBtnDanger:hover {
background: linear-gradient(180deg, #572525, #401818);
}
.holdemBtnGhost {
background: transparent;
}
.holdemBtnSmall {
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
}
.holdemBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.handHistoryActions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
/* ---------- 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: 72%;
}
#pokerTable {
position: absolute;
inset: 6%;
border-radius: 50%;
background: radial-gradient(ellipse at 50% 42%, #227a45 0%, #175f37 45%, #0e4527 75%, #082c19 100%);
border: 14px solid #6b4225;
box-shadow:
inset 0 0 0 3px rgba(216, 180, 106, 0.55),
inset 0 0 80px rgba(0, 0, 0, 0.55),
0 0 0 4px #2c1a0d,
0 20px 50px rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
}
#tableWatermark {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.06);
font-size: 2.4rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
text-align: center;
pointer-events: none;
user-select: none;
}
#potBadge {
position: relative;
background: radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.08), rgba(0, 0, 0, 0.55));
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
padding: 0.7rem 1.7rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 0.15rem;
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.6), 0 4px 14px rgba(0, 0, 0, 0.45);
}
#potBadgeLabel {
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #d8b46a;
}
#potBadgeAmount {
font-size: 1.5rem;
font-weight: 700;
color: #fff;
}
#seatsLayer {
position: absolute;
inset: 0;
}
/* ---------- Seats (compact pucks on the table) ---------- */
.seat {
position: absolute;
transform: translate(-50%, -50%);
width: 96px;
min-height: 54px;
background: linear-gradient(160deg, #2c2c2c, #1b1b1b);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 0.3rem 0.4rem;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.05);
cursor: grab;
z-index: 1;
text-align: center;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.seat.dragging {
opacity: 0.4;
}
.seat.dropTarget {
outline: 2px dashed #d8b46a;
outline-offset: 2px;
}
.seat.seatInactive {
opacity: 0.4;
}
.seat.seatFolded {
border-style: dashed;
opacity: 0.55;
}
.seat.seatSelected {
border-color: rgba(9, 255, 0, 0.6);
box-shadow: 0 0 0 2px rgba(9, 255, 0, 0.4), 0 4px 14px rgba(0, 0, 0, 0.5);
}
.seatChairNumber {
position: absolute;
top: 2px;
left: 6px;
font-size: 0.58rem;
color: #777;
}
.seatPuckName {
font-size: 0.72rem;
font-weight: 600;
color: #f0f0f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.6rem;
}
.seatPuckCards {
font-size: 0.68rem;
font-weight: 700;
font-family: var(--font-mono, monospace);
color: #ddd;
min-height: 0.95em;
letter-spacing: 0.05em;
}
.seatPuckCards .cardRed {
color: #ff6b6b;
}
.seatPuckTotal {
display: inline-block;
margin-top: 0.15rem;
font-size: 0.64rem;
font-weight: 700;
color: #a9f5b8;
background: rgba(9, 255, 0, 0.1);
border-radius: 999px;
padding: 0 0.35rem;
min-height: 1.1em;
line-height: 1.4;
}
.dealerChip {
position: absolute;
top: -10px;
right: -10px;
width: 22px;
height: 22px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, #d8d8d8 55%, #b8b8b8 100%);
color: #222;
border: 2px solid #999;
font-weight: 700;
font-size: 0.68rem;
cursor: pointer;
z-index: 2;
opacity: 0.32;
transition: opacity 0.15s ease;
}
.seat:hover .dealerChip,
.dealerChip:focus-visible {
opacity: 1;
}
.dealerChip.active {
background: radial-gradient(circle at 35% 30%, #ffe9a8, #f5d547 55%, #cba428 100%);
border-color: #a9782f;
color: #3a2e00;
opacity: 1;
}
.dealerChip.hidden {
display: none;
}
.blindBadge {
display: none;
position: absolute;
top: -9px;
font-size: 0.58rem;
font-weight: 700;
padding: 0.05rem 0.34rem;
border-radius: 999px;
z-index: 2;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
}
.blindBadge.visible {
display: inline-block;
}
.sbBadge {
left: -6px;
background: linear-gradient(180deg, #3f82d1, #2b5fa0);
color: #eaf3ff;
}
.bbBadge {
left: 22px;
background: linear-gradient(180deg, #dba13f, #b7791f);
color: #fff7e6;
}
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
.seatEditorPanel {
padding: 0.9rem;
}
.seatEditorPanel h2 {
margin: 0 0 0.6rem;
padding-bottom: 0.5rem;
font-size: 0.95rem;
font-weight: 500;
color: #f5f5f5;
border-bottom: 1px solid rgba(216, 180, 106, 0.35);
}
.seatEditorForm {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.seatCardToggles {
display: flex;
gap: 0.6rem;
font-size: 0.8rem;
color: #aaa;
}
.inHandToggle,
.foldToggle {
display: flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
}
.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;
}
.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: 6px;
padding: 0.35rem 0;
font-weight: 700;
font-family: var(--font-mono, monospace);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.cardInput.cardRed {
color: #c0392b;
}
.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: linear-gradient(160deg, #212121, #1a1a1a);
border: 1px solid #333;
border-radius: 10px;
padding: 0.2rem 0.9rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.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;
}
#standingsTable {
max-height: 260px;
overflow-y: auto;
}
.standingsRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 0.5rem;
align-items: baseline;
padding: 0.3rem 0.1rem;
font-size: 0.78rem;
border-bottom: 1px solid #232323;
}
.standingsRow > span:not(:first-child) {
text-align: right;
font-variant-numeric: tabular-nums;
min-width: 4.5rem;
}
.standingsHeader {
color: #777;
font-size: 0.7rem;
letter-spacing: 0.04em;
text-transform: uppercase;
border-bottom-color: #3a3a3a;
position: sticky;
top: 0;
background: #1b1b1b;
}
.standingsName {
color: #eee;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.standingsIn,
.standingsWon {
color: #999;
}
.standingsNet {
color: #bbb;
font-weight: 600;
}
.standingsNet.netUp {
color: #4ec97a;
}
.standingsNet.netDown {
color: #e07b7b;
}
#handHistoryList {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 260px;
overflow-y: auto;
}
.handHistoryRow {
background: #141414;
border: 1px solid #2c2c2c;
border-radius: 6px;
padding: 0.45rem 0.6rem;
}
.handHistoryRowHeader {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.8rem;
}
.handHistoryHandNumber {
color: #d8b46a;
font-weight: 600;
}
.handHistoryPot {
color: #eee;
}
.handHistoryTime {
margin-left: auto;
color: #777;
font-size: 0.72rem;
}
.handHistoryPayouts {
margin-top: 0.15rem;
color: #aaa;
font-size: 0.75rem;
line-height: 1.4;
}
#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: 78px;
min-height: 44px;
}
}
+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 @@
// Turns the in-memory hand history into a CSV file the dealer can download for a
// paper trail — a house manager or a disputed chip count needs something that
// outlives the browser tab, not just a scrollable panel in the sidebar.
const HandHistoryExport = {
csvField(value) {
const str = String(value ?? "");
return /[",\n]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
},
formatTimestamp(timestamp) {
try {
return new Date(timestamp).toLocaleString();
} catch {
return "";
}
},
toCsv(handHistory, standings = []) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
[...handHistory].reverse().forEach(entry => {
const payouts = (entry.payouts || [])
.map(p => `${p.label}: ${Money.format(p.amount)}`)
.join("; ");
rows.push([
entry.handNumber,
this.formatTimestamp(entry.timestamp),
Money.format(entry.pot),
payouts,
]);
});
// The settle-up numbers go in the same file as the hands they came from, so a
// dealer handing over one export isn't asked for a second one at cash-out.
if (standings.length > 0) {
rows.push([]);
rows.push(["Session Standings"]);
rows.push(["Player", "Paid In", "Won", "Net"]);
standings.forEach(row => {
rows.push([row.name, Money.format(row.in), Money.format(row.won), Money.formatSigned(row.net)]);
});
}
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
},
fileName() {
const now = new Date();
const pad = n => String(n).padStart(2, "0");
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
return `holdem-hand-history-${stamp}.csv`;
},
download(handHistory, standings = []) {
const csv = this.toCsv(handHistory, standings);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = this.fileName();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = HandHistoryExport;
}
@@ -0,0 +1,90 @@
document.addEventListener("DOMContentLoaded", () => {
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("dedicatedDealerToggle").addEventListener("change", e => {
state.dedicatedDealer = e.target.checked;
hooks.save(state);
Render.renderSeats();
Render.renderTableHint();
});
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, payouts => {
if (payouts.length > 0) HoldemState.recordHand(state, payouts);
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();
});
document.getElementById("clearHistoryBtn").addEventListener("click", () => {
if (!confirm("Clear the hand history and reset the hand counter to 1? This can't be undone.")) return;
HoldemState.clearHistory(state);
hooks.save(state);
Render.renderAll();
});
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
if ((state.handHistory || []).length === 0) return;
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
});
});
@@ -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,201 @@
// 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 = HoldemState.displayName(player, seatIndex);
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", () => {
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
id,
label: this.playerLabelById.get(id),
amount,
}));
this.close();
this.onFinished(payouts);
});
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,436 @@
// 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).
//
// The table itself only shows compact seat "pucks" — name, cards, total, blind/dealer
// badges — because a full data-entry form (chip counts, custom fields, etc.) doesn't fit
// next to nine others arranged in a circle at any screen size. Clicking a puck opens the
// full form in the seat editor panel instead.
const Render = {
state: null,
hooks: null,
selectedSeatIndex: null,
mount(state, hooks) {
this.state = state;
this.hooks = hooks;
this.selectedSeatIndex = null;
this.renderAll();
},
renderAll() {
if (this.selectedSeatIndex !== null && this.selectedSeatIndex >= this.state.seatCount) {
this.selectedSeatIndex = null;
}
this.renderSeats();
this.renderSeatEditor();
this.renderChipLegend();
this.renderNotes();
this.updatePotBadge();
this.renderSetupFields();
this.renderTableHint();
this.renderStandings();
this.renderHandHistory();
},
renderSetupFields() {
document.getElementById("seatCountInput").value = this.state.seatCount;
document.getElementById("directionSelect").value = this.state.direction;
document.getElementById("basePotInput").value = this.state.basePotAmount ?? "";
document.getElementById("dedicatedDealerToggle").checked = !!this.state.dedicatedDealer;
},
renderTableHint() {
const hint = document.getElementById("tableHint");
hint.textContent = this.state.dedicatedDealer
? "Drag a seat onto another seat to swap players."
: "Drag a seat onto another seat to swap players. Click the dealer chip on a seat to make it the button.";
},
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);
},
// ---- Seats around the table (compact pucks) ----
seatPosition(index, count) {
const angle = -90 + (360 / count) * index;
const radians = (angle * Math.PI) / 180;
const rx = 44;
const ry = 42;
const left = 50 + rx * Math.cos(radians);
const top = 50 + ry * Math.sin(radians);
return { left, top };
},
puckLabel(player, seatIndex) {
return HoldemState.displayName(player, seatIndex);
},
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 = index + 1;
const dealerChip = node.querySelector(".dealerChip");
dealerChip.classList.toggle("active", index === dealerIndex);
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
dealerChip.addEventListener("click", e => {
e.stopPropagation();
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.updatePuckContent(node, player, index);
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
node.addEventListener("click", () => {
this.selectedSeatIndex = index;
this.renderSeats();
this.renderSeatEditor();
});
this.wireDragAndDrop(node, index);
layer.appendChild(node);
});
},
isRedSuit(card) {
return /[HD]$/i.test(card || "");
},
updatePuckContent(node, player, index) {
node.querySelector(".seatPuckName").textContent = player.name && player.name.trim() ? player.name.trim() : "";
const cardsEl = node.querySelector(".seatPuckCards");
cardsEl.innerHTML = "";
(player.cards || []).filter(Boolean).forEach((card, i) => {
if (i > 0) cardsEl.appendChild(document.createTextNode(" "));
const span = document.createElement("span");
if (this.isRedSuit(card)) span.className = "cardRed";
span.textContent = card;
cardsEl.appendChild(span);
});
const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
node.classList.toggle("seatInactive", !player.inHand);
node.classList.toggle("seatFolded", player.folded);
},
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
// (which live in a separate panel) never lose focus.
refreshPuck(index) {
const node = document.querySelector(`.seat[data-seat-index="${index}"]`);
if (node) this.updatePuckContent(node, this.state.seats[index], index);
},
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();
if (this.selectedSeatIndex === sourceIndex || this.selectedSeatIndex === targetIndex) {
this.renderSeatEditor();
}
});
},
// ---- Seat editor panel (the full form for whichever seat is selected) ----
renderSeatEditor() {
const title = document.getElementById("seatEditorTitle");
const body = document.getElementById("seatEditorBody");
if (this.selectedSeatIndex === null) {
title.textContent = "Select a seat";
body.innerHTML = '<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>';
return;
}
const index = this.selectedSeatIndex;
const player = this.state.seats[index];
title.textContent = this.puckLabel(player, index);
body.innerHTML = "";
const template = document.getElementById("seatEditorTemplate");
const form = template.content.firstElementChild.cloneNode(true);
this.wireSeatFields(form, player, index);
this.renderChipCounts(form, player, index);
this.renderCustomFields(form, player, index);
this.renderSeatTotal(form, player);
body.appendChild(form);
},
renderSeatTotal(form, player) {
const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
form.querySelector(".seatTotalAmount").textContent = Money.format(total);
},
commitSeatChange(form, player, index) {
this.hooks.save(this.state);
this.updatePotBadge();
this.renderSeatTotal(form, player);
this.refreshPuck(index);
},
wireSeatFields(form, player, index) {
const nameInput = form.querySelector(".seatNameInput");
nameInput.value = player.name;
nameInput.addEventListener("input", () => {
player.name = nameInput.value;
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
this.commitSeatChange(form, player, index);
});
const inHandCheckbox = form.querySelector(".inHandCheckbox");
inHandCheckbox.checked = player.inHand;
inHandCheckbox.addEventListener("change", () => {
player.inHand = inHandCheckbox.checked;
if (!player.inHand) player.folded = false;
foldCheckboxRef.checked = player.folded;
foldCheckboxRef.disabled = !player.inHand;
this.commitSeatChange(form, player, index);
});
const foldCheckboxRef = form.querySelector(".foldCheckbox");
foldCheckboxRef.checked = player.folded;
foldCheckboxRef.disabled = !player.inHand;
foldCheckboxRef.addEventListener("change", () => {
player.folded = foldCheckboxRef.checked;
this.commitSeatChange(form, player, index);
});
const card1 = form.querySelector(".card1Input");
const card2 = form.querySelector(".card2Input");
card1.value = player.cards[0] || "";
card2.value = player.cards[1] || "";
[card1, card2].forEach((input, cardIdx) => {
input.classList.toggle("cardRed", this.isRedSuit(input.value));
input.addEventListener("input", () => {
input.value = input.value.toUpperCase();
input.classList.toggle("cardRed", this.isRedSuit(input.value));
player.cards[cardIdx] = input.value;
this.commitSeatChange(form, player, index);
});
});
const amountInput = form.querySelector(".amountOverrideInput");
amountInput.value = player.amountOverride ?? "";
amountInput.addEventListener("input", () => {
player.amountOverride = amountInput.value === "" ? null : amountInput.value;
this.commitSeatChange(form, player, index);
});
const addFieldBtn = form.querySelector(".addFieldBtn");
addFieldBtn.addEventListener("click", () => {
player.customFields.push({ label: "", value: "" });
this.renderCustomFields(form, player, index);
this.hooks.save(this.state);
});
},
renderChipCounts(form, player, index) {
const container = form.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.commitSeatChange(form, player, index);
});
container.appendChild(row);
});
},
renderCustomFields(form, player, index) {
const container = form.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(form, player, index);
this.hooks.save(this.state);
});
container.appendChild(row);
});
},
// ---- 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);
});
valueInput.addEventListener("input", () => {
chip.value = Number(valueInput.value) || 0;
this.hooks.save(this.state);
this.renderSeats();
this.renderSeatEditor();
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);
});
},
// ---- Session standings ----
renderStandings() {
const list = document.getElementById("standingsList");
const emptyHint = document.getElementById("standingsEmptyHint");
const table = document.getElementById("standingsTable");
const standings = HoldemState.sessionStandings(this.state);
list.innerHTML = "";
emptyHint.hidden = standings.length > 0;
table.hidden = standings.length === 0;
const template = document.getElementById("standingsRowTemplate");
standings.forEach(row => {
const node = template.content.firstElementChild.cloneNode(true);
node.querySelector(".standingsName").textContent = row.name;
node.querySelector(".standingsIn").textContent = Money.format(row.in);
node.querySelector(".standingsWon").textContent = Money.format(row.won);
const netEl = node.querySelector(".standingsNet");
netEl.textContent = Money.formatSigned(row.net);
netEl.classList.toggle("netUp", row.net > 0);
netEl.classList.toggle("netDown", row.net < 0);
list.appendChild(node);
});
},
// ---- Hand counter + history ----
formatHandTime(timestamp) {
try {
return new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
} catch {
return "";
}
},
renderHandHistory() {
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
const list = document.getElementById("handHistoryList");
const emptyHint = document.getElementById("handHistoryEmptyHint");
const history = this.state.handHistory || [];
list.innerHTML = "";
emptyHint.hidden = history.length > 0;
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
const template = document.getElementById("handHistoryRowTemplate");
history.forEach(entry => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".handHistoryHandNumber").textContent = `Hand #${entry.handNumber}`;
row.querySelector(".handHistoryPot").textContent = Money.format(entry.pot);
row.querySelector(".handHistoryTime").textContent = this.formatHandTime(entry.timestamp);
const payoutsEl = row.querySelector(".handHistoryPayouts");
payoutsEl.textContent = entry.payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
list.appendChild(row);
});
},
};
const Money = {
format(amount) {
const value = Number(amount) || 0;
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
},
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
// glance, and the sign belongs out front where the eye lands first.
formatSigned(amount) {
const value = Number(amount) || 0;
if (value > 0) return `+${this.format(value)}`;
if (value < 0) return `-${this.format(Math.abs(value))}`;
return this.format(0);
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = { Render, Money };
}
@@ -0,0 +1,224 @@
// 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: [],
};
},
MAX_HISTORY_ENTRIES: 200,
createDefault() {
const seatCount = this.DEFAULT_SEAT_COUNT;
return {
seatCount,
direction: "clockwise",
basePotAmount: null,
dealerSeatIndex: 0,
dedicatedDealer: false,
chipLegend: this.DEFAULT_CHIP_LEGEND.map(c => ({ ...c })),
seats: Array.from({ length: seatCount }, () => this.makePlayer()),
notes: "",
handNumber: 1,
handHistory: [],
};
},
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();
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
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;
},
// What to call a seat in logs, payouts and standings when the dealer hasn't
// typed a name in yet.
displayName(player, seatIndex) {
return player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
},
// 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;
});
},
// Appends a resolved-pot record to the running session log a dealer can point to
// for chip-count disputes, then advances the hand counter. `payouts` is
// [{ id, label, amount }, ...] — who got paid what out of this hand's pot(s).
//
// Must be called before startNextHand() clears the seats: the per-player `ledger`
// it stores alongside the payouts is what session standings are summed from later,
// and it needs this hand's contributions while they're still on the table.
recordHand(state, payouts) {
if (typeof state.handNumber !== "number") state.handNumber = 1;
if (!Array.isArray(state.handHistory)) state.handHistory = [];
const pot = payouts.reduce((sum, p) => sum + p.amount, 0);
const wonById = new Map(payouts.map(p => [p.id, p.amount]));
const ledger = state.seats
.map((player, index) => ({
id: player.id,
name: this.displayName(player, index),
in: this.computeContribution(player, state.chipLegend, state.basePotAmount),
won: wonById.get(player.id) || 0,
}))
.filter(row => row.in > 0 || row.won > 0);
state.handHistory.unshift({
handNumber: state.handNumber,
timestamp: Date.now(),
pot,
payouts,
ledger,
});
if (state.handHistory.length > this.MAX_HISTORY_ENTRIES) {
state.handHistory.length = this.MAX_HISTORY_ENTRIES;
}
state.handNumber += 1;
},
clearHistory(state) {
state.handHistory = [];
state.handNumber = 1;
},
// Running per-player totals for the whole session, so a dealer settling up at the
// end of the night reads one number per person instead of adding up every hand.
// Keyed by seat id, which survives renames and reseating; hands recorded before
// the ledger existed simply don't contribute to the totals.
sessionStandings(state) {
const totals = new Map();
(state.handHistory || []).forEach(entry => {
(entry.ledger || []).forEach(row => {
// History is newest-first, so the first name seen for an id is the
// most recent one the dealer typed.
const running = totals.get(row.id) || { id: row.id, name: row.name, in: 0, won: 0 };
running.in += row.in;
running.won += row.won;
totals.set(row.id, running);
});
});
return Array.from(totals.values())
.map(t => ({
...t,
in: Math.round(t.in * 100) / 100,
won: Math.round(t.won * 100) / 100,
net: Math.round((t.won - t.in) * 100) / 100,
}))
.sort((a, b) => b.net - a.net);
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = HoldemState;
}
+7 -3
View File
@@ -103,9 +103,13 @@ CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle trainer);
/* Record one played position (post-move FEN) into the accumulator. */
CHESS_API void CHESS_CALL trainer_record(TrainerHandle trainer, const char* fen);
/* Apply a finished game's outcome to the global weights and save: rewards the winner's
* occupied squares / features and punishes the loser's, scaled by `weight` (e.g. 0.5 for a
* material-imbalance draw). winner: 0 = white, 1 = black. */
/* Apply a finished game's outcome to the global weights and save. Each touched cell (a piece
* on a square, or a feature) tracks a presence-weighted running average of the game's
* "advantage" — the game's outcome (scaled by `weight`, e.g. 0.5 for a material-imbalance
* draw) minus how much of that outcome the winner's average material edge already explains —
* so mg/eg/featW end up as a fractional win-rate (percent of games won with that piece there,
* minus percent lost), not an unbounded plus/minus tally, and a piece doesn't get credit
* merely for existing on the winning side of a material blowout. winner: 0 = white, 1 = black. */
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle trainer, int winner, double weight);
#ifdef __cplusplus
+122 -30
View File
@@ -26,6 +26,7 @@
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iomanip>
#include <mutex>
#include <new>
#include <string>
@@ -115,11 +116,25 @@ struct ChessEngine {
/* The process-global learned weights: the single source of truth, loaded from disk once and
* updated in place by training. Engine handles snapshot it at creation; the visualization
* snapshots it on demand. Guarded by g_weightsMutex for updates/saves (eval reads its own
* per-handle copy, so it never touches this concurrently). */
* per-handle copy, so it never touches this concurrently).
*
* mg/eg/featW are DERIVED, not accumulated: each is always exactly OUT_SCALE * (score/visits)
* for its cell (see trainer_apply), recomputed whenever that cell's stats change. visits is
* the presence-weighted count of games that touched the cell (how much to trust the average);
* score is the presence-weighted sum of each game's material-adjusted advantage (see
* trainer_apply) — so score/visits is literally "fraction of (presence-weighted) games won
* with this piece here, minus fraction lost," bounded to [-1,1] by construction. */
struct LearnedWeights {
int mg[chess::PIECE_TYPE_NB][64] = {};
int eg[chess::PIECE_TYPE_NB][64] = {};
int featW[FEATURE_NB] = {};
double mgVisits[chess::PIECE_TYPE_NB][64] = {};
double mgScore [chess::PIECE_TYPE_NB][64] = {};
double egVisits[chess::PIECE_TYPE_NB][64] = {};
double egScore [chess::PIECE_TYPE_NB][64] = {};
double featVisits[FEATURE_NB] = {};
double featScore [FEATURE_NB] = {};
};
static LearnedWeights g_weights;
@@ -127,13 +142,15 @@ static std::mutex g_weightsMutex;
static std::string g_weightsPath;
/* Per-game training accumulator (one per learned CPU-vs-CPU game). Records, per ply, where
* each side's pieces sat (split into midgame/endgame by phase) and each side's feature
* activations; trainer_apply turns the totals into weight nudges. Squares are white-relative
* each side's pieces sat (split into midgame/endgame by phase), each side's feature
* activations, and the white-minus-black material balance; trainer_apply turns the totals
* into a presence-weighted update to the running per-cell averages. Squares are white-relative
* (black indexes sq ^ 56), so a side's tally lines up with the shared white-relative table. */
struct Trainer {
double mgOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
double egOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
double featAcc[chess::COLOR_NB][FEATURE_NB] = {};
double matDiffAcc = 0.0; /* sum over plies of (white material - black material), cp */
int plies = 0;
};
@@ -172,9 +189,12 @@ static int parse_variant(const char* options) {
}
/* On-disk format: 6*64 mg ints (PAWN..KING, squares 0..63), then 6*64 eg ints, then
* FEATURE_NB feature ints, whitespace-separated. A missing file or short read leaves the
* rest neutral (0), so an absent weights file just means "train from a blank slate".
* Caller holds g_weightsMutex. */
* FEATURE_NB feature ints, then the backing visits/score doubles for each (mgVisits, mgScore,
* egVisits, egScore, featVisits, featScore) in the same shapes, all whitespace-separated. A
* missing file or short read leaves the rest neutral (0), so an absent weights file just means
* "train from a blank slate" — and a file written by the pre-fractional-learning format (which
* ends after the feature ints) loads its mg/eg/featW fine and simply starts every cell's
* visits/score history fresh from the next game that touches it. Caller holds g_weightsMutex. */
static void load_global_weights(const char* path) {
g_weights = LearnedWeights{}; /* reset to neutral before loading */
@@ -190,9 +210,29 @@ static void load_global_weights(const char* path) {
if (!(f >> g_weights.eg[pt][sq])) return;
for (int i = 0; i < FEATURE_NB; ++i)
if (!(f >> g_weights.featW[i])) return;
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq)
if (!(f >> g_weights.mgVisits[pt][sq])) return;
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq)
if (!(f >> g_weights.mgScore[pt][sq])) return;
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq)
if (!(f >> g_weights.egVisits[pt][sq])) return;
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq)
if (!(f >> g_weights.egScore[pt][sq])) return;
for (int i = 0; i < FEATURE_NB; ++i)
if (!(f >> g_weights.featVisits[i])) return;
for (int i = 0; i < FEATURE_NB; ++i)
if (!(f >> g_weights.featScore[i])) return;
}
/* Persist g_weights to g_weightsPath in the format load_global_weights reads. Caller holds the lock. */
/* Persist g_weights to g_weightsPath in the format load_global_weights reads. Full round-trip
* precision on the doubles (visits/score are running sums that keep accumulating across many
* games, so truncating them would drift the derived mg/eg/featW over time). Caller holds the
* lock. */
static void save_global_weights() {
if (g_weightsPath.empty()) return;
std::ofstream f(g_weightsPath);
@@ -203,6 +243,18 @@ static void save_global_weights() {
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) f << g_weights.eg[pt][sq] << (sq == 63 ? '\n' : ' ');
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featW[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
f << std::setprecision(17);
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) f << g_weights.mgVisits[pt][sq] << (sq == 63 ? '\n' : ' ');
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) f << g_weights.mgScore[pt][sq] << (sq == 63 ? '\n' : ' ');
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) f << g_weights.egVisits[pt][sq] << (sq == 63 ? '\n' : ' ');
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) f << g_weights.egScore[pt][sq] << (sq == 63 ? '\n' : ' ');
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featVisits[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featScore[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
}
/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no
@@ -382,13 +434,18 @@ static int evaluate(const chess::Position& pos) {
* compute_features() is the single source of feature activations, used by BOTH the eval here
* and the trainer, so the two can never disagree. Constants below are the only tunables. */
/* Per-game-outcome learning rates and clamps. Squares accumulate occupancy (plies on a
* square, summed); features accumulate normalized per-ply activation (averaged, divided by a
* nominal scale so high-magnitude mobility doesn't dwarf the small pawn-structure terms). */
static constexpr double SQUARE_LR = 0.5;
static constexpr int SQ_CLAMP = 250;
static constexpr double FEAT_LR = 2.0;
static constexpr int FEAT_CLAMP = 500;
/* Per-game-outcome learning constants. Squares and features both learn a presence/activation-
* weighted AVERAGE advantage in [-1,1] (see trainer_apply) — OUT_SCALE just converts that
* average into a centipawn-ish int for the eval/table to add directly. FEAT_SCALE normalizes
* each feature's raw per-ply activation to a comparable magnitude before it's used as a weight
* (so high-count mobility doesn't dwarf the small pawn-structure terms), same as before.
* MATERIAL_ELO_SCALE is the classic centipawn-to-win-probability curve
* (expected = 1/(1+10^(-cp/400))) used to compute how much of a result material already
* explains, so training credits placement/features only for the part of the result that
* material didn't already predict. */
static constexpr int SQ_OUT_SCALE = 250;
static constexpr int FEAT_OUT_SCALE = 500;
static constexpr double MATERIAL_ELO_SCALE = 400.0;
static constexpr double FEAT_SCALE[FEATURE_NB] = { 4, 6, 8, 14, 2, 1, 1, 2 };
/* Game phase in [0,1] from remaining non-pawn material (PeSTO weights N=B=1, R=2, Q=4; max
@@ -823,7 +880,9 @@ CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
chess::Position pos = chess::Position::from_fen(fen);
double phase = game_phase(pos);
/* Per-square occupancy, split into midgame/endgame by phase, white-relative. */
/* Per-square occupancy, split into midgame/endgame by phase, white-relative; and the
* white-minus-black material balance at this ply, used at trainer_apply time to strip
* "material already explains this" out of the training signal. */
chess::Bitboard occ = pos.pieces();
while (occ) {
chess::Square s = chess::pop_lsb(occ);
@@ -833,6 +892,7 @@ CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
int relSq = (c == chess::WHITE) ? int(s) : (int(s) ^ 56);
t->mgOcc[c][pt][relSq] += (1.0 - phase);
t->egOcc[c][pt][relSq] += phase;
t->matDiffAcc += (c == chess::WHITE ? 1 : -1) * piece_value(pt);
}
/* Per-side feature activations. */
@@ -847,34 +907,66 @@ CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
t->plies++;
}
/* Applies a finished game's outcome as a presence-weighted running average per cell, instead
* of an unbounded incremental nudge:
*
* score[cell] / visits[cell] == "fraction of (presence-weighted) games won with this piece
* on this square, minus fraction lost"
*
* `weight` is the caller's confidence in the result (1.0 for a checkmate, 0.5 for a
* material-imbalance draw, as before). On top of that, the update is scaled by how much of the
* result ISN'T already explained by material: `expected` is the win probability the winner's
* average material edge over the game implies (the same centipawn->win-probability curve
* engines use for Elo estimates); `1 - expected` is the "surprise" left over once material is
* accounted for. A win where the winner was already up a rook the whole game is unsurprising
* (expected ~= 1, ~no update) — the eval already knows material matters via piece_value(), so
* square/feature tables shouldn't get credit for "a piece existed"; a win pulled off (or a draw
* held) while materially behind is highly informative and gets a correspondingly large update.
* This same magnitude applies to BOTH the winner's reward and the loser's punishment: by the
* symmetry of win probabilities (P(loser wins) = 1 - P(winner wins)), the loser's own advantage
* — actual(0) minus their expected win probability — has exactly the same magnitude, `1 -
* expected`, just negative. */
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle t, int winner, double weight) {
if (!t) return;
if (!t || t->plies <= 0) return;
double avgMaterialDiff = t->matDiffAcc / t->plies; /* white - black, cp */
double winnerMaterialDiff = (winner == chess::WHITE) ? avgMaterialDiff : -avgMaterialDiff;
double expected = 1.0 / (1.0 + std::pow(10.0, -winnerMaterialDiff / MATERIAL_ELO_SCALE));
double advantageMag = weight * (1.0 - expected);
std::lock_guard<std::mutex> lock(g_weightsMutex);
/* pass 0 = winner (reward, +1); pass 1 = loser (punish, -1). */
/* pass 0 = winner (positive advantage); pass 1 = loser (negative advantage). */
for (int pass = 0; pass < 2; ++pass) {
chess::Color side = chess::Color((pass == 0 ? winner : (winner ^ 1)) & 1);
int sign = pass == 0 ? 1 : -1;
double advantage = pass == 0 ? advantageMag : -advantageMag;
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) {
if (t->mgOcc[side][pt][sq] != 0.0) {
int d = sign * int(std::lround(SQUARE_LR * t->mgOcc[side][pt][sq] * weight));
g_weights.mg[pt][sq] = std::clamp(g_weights.mg[pt][sq] + d, -SQ_CLAMP, SQ_CLAMP);
double mgFrac = t->mgOcc[side][pt][sq] / t->plies; /* fraction of the game spent here, [0,1] */
if (mgFrac != 0.0) {
g_weights.mgVisits[pt][sq] += mgFrac;
g_weights.mgScore [pt][sq] += mgFrac * advantage;
g_weights.mg[pt][sq] = int(std::lround(
SQ_OUT_SCALE * g_weights.mgScore[pt][sq] / g_weights.mgVisits[pt][sq]));
}
if (t->egOcc[side][pt][sq] != 0.0) {
int d = sign * int(std::lround(SQUARE_LR * t->egOcc[side][pt][sq] * weight));
g_weights.eg[pt][sq] = std::clamp(g_weights.eg[pt][sq] + d, -SQ_CLAMP, SQ_CLAMP);
double egFrac = t->egOcc[side][pt][sq] / t->plies;
if (egFrac != 0.0) {
g_weights.egVisits[pt][sq] += egFrac;
g_weights.egScore [pt][sq] += egFrac * advantage;
g_weights.eg[pt][sq] = int(std::lround(
SQ_OUT_SCALE * g_weights.egScore[pt][sq] / g_weights.egVisits[pt][sq]));
}
}
if (t->plies > 0)
for (int i = 0; i < FEATURE_NB; ++i) {
double avg = t->featAcc[side][i] / t->plies; /* per-ply average, normalized */
int d = sign * int(std::lround(FEAT_LR * (avg / FEAT_SCALE[i]) * weight));
g_weights.featW[i] = std::clamp(g_weights.featW[i] + d, -FEAT_CLAMP, FEAT_CLAMP);
}
for (int i = 0; i < FEATURE_NB; ++i) {
double activation = (t->featAcc[side][i] / t->plies) / FEAT_SCALE[i]; /* per-ply average, normalized */
if (activation == 0.0) continue;
g_weights.featVisits[i] += std::abs(activation);
g_weights.featScore [i] += activation * advantage;
g_weights.featW[i] = int(std::lround(
FEAT_OUT_SCALE * g_weights.featScore[i] / g_weights.featVisits[i]));
}
}
save_global_weights();