Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e0a687a2f | ||
|
|
dabf1100c9 | ||
|
|
0759d89d13 | ||
|
|
7bedee2934 | ||
|
|
7143e4e701 | ||
|
|
e8797b8dc2 | ||
|
|
48bd3682f2 | ||
|
|
1e1bdcccce | ||
|
|
aee172a5cd | ||
|
|
d206758729 | ||
|
|
4afc3649cc | ||
|
|
3866bdb335 | ||
|
|
71b33f8623 | ||
|
|
d01c67fc45 | ||
|
|
a39675f975 | ||
|
|
870dee4a29 | ||
|
|
bcfc1f2f52 | ||
|
|
d168fedf1f | ||
|
|
68e263f325 | ||
|
|
105a74bc51 | ||
|
|
dce7e0f688 | ||
|
|
3851786b90 | ||
|
|
8e7d94e6f9 | ||
|
|
25bf67062a | ||
|
|
5d71572df6 | ||
|
|
98f95df8fb | ||
|
|
b7c9227bb8 | ||
|
|
ddb309f6db | ||
|
|
2db4048ed2 | ||
|
|
2f53c2f0ed | ||
|
|
7562ded92a | ||
|
|
392b9ec212 | ||
|
|
769c7819e0 | ||
|
|
e8eb90a085 |
@@ -0,0 +1,58 @@
|
|||||||
|
@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>
|
||||||
|
|
||||||
|
<a href="/casino/texasholdem/quicksplit" class="game-card">
|
||||||
|
<div class="game-icon">➗</div>
|
||||||
|
<div class="game-title">Quick Split</div>
|
||||||
|
<div class="game-description">
|
||||||
|
Chop a pot in seconds on your phone — no table setup, just the amount,
|
||||||
|
how many players are splitting it, and the smallest chip in play.
|
||||||
|
</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" />
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Pages
|
||||||
|
{
|
||||||
|
public class CasinoModel : PageModel
|
||||||
|
{
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
@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">
|
||||||
|
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
|
||||||
|
<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">
|
||||||
|
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||||
|
<div class="seatEditorHeader">
|
||||||
|
<h2 id="seatEditorTitle">Select a seat</h2>
|
||||||
|
<button id="closeSeatEditorBtn" class="seatSheetClose" aria-label="Done editing this seat">Done</button>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<div class="blindsRow">
|
||||||
|
<label class="fieldRow">
|
||||||
|
<span>Small blind</span>
|
||||||
|
<input type="number" id="smallBlindInput" min="0" step="0.5" />
|
||||||
|
</label>
|
||||||
|
<label class="fieldRow">
|
||||||
|
<span>Big blind</span>
|
||||||
|
<input type="number" id="bigBlindInput" min="0" step="0.5" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="fieldRow">
|
||||||
|
<span>Starting stack ($)</span>
|
||||||
|
<input type="number" id="defaultStackInput" min="0" step="1" placeholder="e.g. 200" />
|
||||||
|
</label>
|
||||||
|
<p class="fieldHint">Blinds post themselves each hand, and every seat without its own stack starts here. Set a seat's own stack in its editor.</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 class="seatPuckStack"></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">
|
||||||
|
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
|
||||||
|
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
|
||||||
|
</div>
|
||||||
|
<label class="fieldRow stackRow">
|
||||||
|
<span>Stack</span>
|
||||||
|
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
|
||||||
|
</label>
|
||||||
|
<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="seatMenuOverlay" hidden>
|
||||||
|
<div id="seatMenuSheet" role="dialog" aria-labelledby="seatMenuTitle">
|
||||||
|
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||||
|
<div class="seatMenuHeader">
|
||||||
|
<h2 id="seatMenuTitle"></h2>
|
||||||
|
<span id="seatMenuSubtitle"></span>
|
||||||
|
</div>
|
||||||
|
<div id="seatMenuActions"></div>
|
||||||
|
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cardPickerOverlay" hidden>
|
||||||
|
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle">
|
||||||
|
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||||
|
<div class="cardPickerHeader">
|
||||||
|
<h2 id="cardPickerTitle"></h2>
|
||||||
|
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
|
||||||
|
<button id="cardPickerCloseBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||||
|
</div>
|
||||||
|
<div id="cardPickerSlots"></div>
|
||||||
|
<div id="cardPickerGrid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="actionBar" aria-live="polite" hidden></div>
|
||||||
|
|
||||||
|
<div id="toastHost" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<div id="confirmOverlay" hidden>
|
||||||
|
<div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1">
|
||||||
|
<h2 id="confirmTitle"></h2>
|
||||||
|
<p id="confirmMessage"></p>
|
||||||
|
<div class="confirmActions">
|
||||||
|
<button id="confirmCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||||
|
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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/money.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
<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/cardPicker.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
<script src="~/js/casino/holdem/betting.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
<script src="~/js/casino/holdem/actionBar.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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
@page
|
||||||
|
@model JoshHeaps.Net.Pages.Casino.TexasHoldem.QuickSplitModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Quick Split";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div id="splitApp">
|
||||||
|
<header id="splitHeader">
|
||||||
|
<h1>Quick Split</h1>
|
||||||
|
<p class="splitSubtitle">Chop a pot without setting up a table.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="splitResult" aria-live="polite">
|
||||||
|
<div id="splitResultLabel">Each player gets</div>
|
||||||
|
<div id="splitResultAmount">$0.00</div>
|
||||||
|
<div id="splitOddNote" hidden></div>
|
||||||
|
<div id="splitDustNote" hidden></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="splitField">
|
||||||
|
<label for="potInput" class="splitLabel">Pot</label>
|
||||||
|
<div class="potInputWrap">
|
||||||
|
<span class="potCurrency">$</span>
|
||||||
|
<input type="text" id="potInput" inputmode="decimal" autocomplete="off"
|
||||||
|
placeholder="0" aria-label="Pot amount" />
|
||||||
|
<button type="button" id="clearPotBtn" class="clearPotBtn" aria-label="Clear pot amount" hidden>✕</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="splitField">
|
||||||
|
<span class="splitLabel" id="waysLabel">Split between</span>
|
||||||
|
<div id="waysChoices" class="chipRow" role="group" aria-labelledby="waysLabel"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="splitField">
|
||||||
|
<span class="splitLabel" id="chipLabel">Smallest chip in play</span>
|
||||||
|
<div id="chipChoices" class="chipRow" role="group" aria-labelledby="chipLabel"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="splitActions">
|
||||||
|
<button type="button" id="resetSplitBtn" class="splitBtn splitBtnGhost">Clear</button>
|
||||||
|
<a href="/casino/texasholdem" class="splitBtn splitBtnGhost">Full dealer table →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="splitFootnote">
|
||||||
|
Uneven pots — someone all-in for less — need the side-pot walkthrough on the
|
||||||
|
<a href="/casino/texasholdem">full table</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<link href="/css/casino/quicksplit.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
<script src="~/js/casino/holdem/splitCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
<script src="~/js/casino/holdem/quickSplit.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Pages.Casino.TexasHoldem
|
||||||
|
{
|
||||||
|
public class QuickSplitModel : PageModel
|
||||||
|
{
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
<button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
|
<button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
|
||||||
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
|
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
|
||||||
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
|
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
|
||||||
|
<button class="demoButton" onclick="window.location.href='/casino'">Casino Games</button>
|
||||||
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
|
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
|
||||||
<button id="MoreFiller" class="demoButton">More coming soon...</button>
|
<button id="MoreFiller" class="demoButton">More coming soon...</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@{
|
@{
|
||||||
Layout = null;
|
Layout = null;
|
||||||
ViewData["cssVersion"] = "1.0.7"; // <--- change this once to bust cache
|
ViewData["cssVersion"] = "1.0.8"; // <--- change this once to bust cache
|
||||||
}
|
}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using JoshHeaps.Net.Models;
|
using JoshHeaps.Net.Models;
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
@@ -10,6 +10,7 @@ public class BlogService : IBlogService
|
|||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly ILogger<BlogService> _logger;
|
private readonly ILogger<BlogService> _logger;
|
||||||
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
|
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
|
||||||
|
private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
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
|
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;
|
return (T?)entry.Value;
|
||||||
|
|
||||||
var result = await factory();
|
var result = await factory();
|
||||||
@@ -73,15 +76,26 @@ public class BlogService : IBlogService
|
|||||||
if (result is not null)
|
if (result is not null)
|
||||||
{
|
{
|
||||||
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
|
_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("Blog API unreachable, serving stale cache for key {Key}", key);
|
||||||
_logger.LogWarning("Serving stale cache for key {Key}", key);
|
|
||||||
return (T?)entry.Value;
|
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()
|
public void ClearCache()
|
||||||
|
|||||||
@@ -100,11 +100,26 @@ public sealed class Stockfish : IChessEngine
|
|||||||
if (line == token) break;
|
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()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
Send("quit");
|
Send("quit");
|
||||||
_stdin.Close();
|
_stdin.Close();
|
||||||
await _p.WaitForExitAsync();
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _p.WaitForExitAsync().WaitAsync(_exitTimeout);
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
if (!_p.HasExited)
|
||||||
|
_p.Kill(entireProcessTree: true);
|
||||||
|
}
|
||||||
|
|
||||||
_p.Dispose();
|
_p.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,301 @@
|
|||||||
|
/* Quick Split is phone-first: sized for one thumb at the table, then allowed to
|
||||||
|
breathe on a laptop. Palette matches the dealer table so the two read as one tool. */
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitApp {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.25rem 1rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Header ---------- */
|
||||||
|
|
||||||
|
#splitHeader h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #f5f5f5;
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitHeader h1::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(90deg, #d8b46a, #a9782f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitSubtitle {
|
||||||
|
margin: 0.35rem 0 1.25rem;
|
||||||
|
color: #8f8f8f;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- The answer ---------- */
|
||||||
|
|
||||||
|
#splitResult {
|
||||||
|
background: linear-gradient(160deg, #1f2a21, #161c17);
|
||||||
|
border: 1px solid #2c3a2f;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.4rem 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitResult.hasAmount {
|
||||||
|
border-color: #3c6b46;
|
||||||
|
box-shadow: 0 0 0 1px rgba(78, 201, 122, 0.08), 0 6px 20px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitResultLabel {
|
||||||
|
color: #8f9d92;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitResultAmount {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: clamp(2.6rem, 14vw, 3.4rem);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.05;
|
||||||
|
color: #f2f2f2;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitResult.hasAmount #splitResultAmount {
|
||||||
|
color: #7ee2a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitOddNote {
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
color: #d8b46a;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitDustNote {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: #e0a1a1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Inputs ---------- */
|
||||||
|
|
||||||
|
.splitField {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitLabel {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #9a9a9a;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.potInputWrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.2rem 0.75rem;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.potInputWrap:focus-within {
|
||||||
|
border-color: #d8b46a;
|
||||||
|
box-shadow: 0 0 0 3px rgba(216, 180, 106, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.potCurrency {
|
||||||
|
color: #777;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
#potInput {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: #f2f2f2;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 0.55rem 0;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#potInput::placeholder {
|
||||||
|
color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearPotBtn {
|
||||||
|
flex: none;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: #262626;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearPotBtn:hover {
|
||||||
|
background: #333;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tap targets ---------- */
|
||||||
|
|
||||||
|
.chipRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceChip {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 58px;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 11px;
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceChip:hover {
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceChip.selected {
|
||||||
|
background: linear-gradient(180deg, #3a2f18, #241d0f);
|
||||||
|
border-color: #d8b46a;
|
||||||
|
color: #f0d9a8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waysStepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 150px;
|
||||||
|
background: #191919;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepBtn {
|
||||||
|
width: 40px;
|
||||||
|
height: 44px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #262626;
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepBtn:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waysReadout {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Footer actions ---------- */
|
||||||
|
|
||||||
|
#splitActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitBtn {
|
||||||
|
flex: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitBtn:hover {
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitBtnGhost {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitFootnote {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
color: #6f6f6f;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitFootnote a {
|
||||||
|
color: #a9782f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Roomier once there's a real screen to work with. */
|
||||||
|
@media (min-width: 700px) {
|
||||||
|
#splitApp {
|
||||||
|
padding-top: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitHeader h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// The bar a dealer's thumb lives on while a hand is running: who's to act, what it
|
||||||
|
// costs them, and one tap per decision. Sits at the bottom of the screen on a phone
|
||||||
|
// so it's reachable without looking, and re-renders after every action because the
|
||||||
|
// next player's numbers are different.
|
||||||
|
const ActionBar = {
|
||||||
|
state: null,
|
||||||
|
hooks: null,
|
||||||
|
raiseOpen: false,
|
||||||
|
raiseDraft: null,
|
||||||
|
|
||||||
|
mount(state, hooks) {
|
||||||
|
this.state = state;
|
||||||
|
this.hooks = hooks;
|
||||||
|
this.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Anything to act on? A hand with nobody dealt in doesn't get a bar.
|
||||||
|
isRunning() {
|
||||||
|
return Betting.liveSeats(this.state).length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const host = document.getElementById("actionBar");
|
||||||
|
if (!host) return;
|
||||||
|
host.innerHTML = "";
|
||||||
|
|
||||||
|
if (!this.isRunning()) {
|
||||||
|
host.hidden = true;
|
||||||
|
document.body.classList.remove("hasActionBar");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
host.hidden = false;
|
||||||
|
document.body.classList.add("hasActionBar");
|
||||||
|
|
||||||
|
host.appendChild(this.renderStatus());
|
||||||
|
|
||||||
|
if (Betting.isHandOver(this.state)) {
|
||||||
|
host.appendChild(this.renderHandOver());
|
||||||
|
} else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) {
|
||||||
|
host.appendChild(this.renderStreetDone());
|
||||||
|
} else if (this.raiseOpen) {
|
||||||
|
host.appendChild(this.renderRaisePad());
|
||||||
|
} else {
|
||||||
|
host.appendChild(this.renderActions());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderStatus() {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "actionStatus";
|
||||||
|
|
||||||
|
const street = document.createElement("span");
|
||||||
|
street.className = "actionStreet";
|
||||||
|
street.textContent = Betting.streetLabel(this.state.street);
|
||||||
|
row.appendChild(street);
|
||||||
|
|
||||||
|
const pot = document.createElement("span");
|
||||||
|
pot.className = "actionPot";
|
||||||
|
pot.textContent = `Pot ${Money.format(Betting.potSoFar(this.state))}`;
|
||||||
|
row.appendChild(pot);
|
||||||
|
|
||||||
|
const index = this.state.actingSeatIndex;
|
||||||
|
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
|
||||||
|
const player = this.state.seats[index];
|
||||||
|
const who = document.createElement("span");
|
||||||
|
who.className = "actionWho";
|
||||||
|
who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`;
|
||||||
|
row.appendChild(who);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
|
||||||
|
button(label, className, onClick, sublabel) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = `actionBtn ${className}`;
|
||||||
|
|
||||||
|
const main = document.createElement("span");
|
||||||
|
main.className = "actionBtnLabel";
|
||||||
|
main.textContent = label;
|
||||||
|
btn.appendChild(main);
|
||||||
|
|
||||||
|
if (sublabel) {
|
||||||
|
const sub = document.createElement("span");
|
||||||
|
sub.className = "actionBtnSub";
|
||||||
|
sub.textContent = sublabel;
|
||||||
|
btn.appendChild(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener("click", onClick);
|
||||||
|
return btn;
|
||||||
|
},
|
||||||
|
|
||||||
|
commit(mutate) {
|
||||||
|
mutate();
|
||||||
|
this.hooks.save(this.state);
|
||||||
|
this.hooks.rerender();
|
||||||
|
},
|
||||||
|
|
||||||
|
renderActions() {
|
||||||
|
const index = this.state.actingSeatIndex;
|
||||||
|
const player = this.state.seats[index];
|
||||||
|
const owed = Betting.toCall(this.state, player);
|
||||||
|
const stack = Betting.stackOf(this.state, player);
|
||||||
|
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "actionButtons";
|
||||||
|
|
||||||
|
row.appendChild(this.button("Fold", "actionFold", () => this.commit(() => Betting.fold(this.state, index))));
|
||||||
|
|
||||||
|
if (owed <= 0) {
|
||||||
|
row.appendChild(this.button("Check", "actionCheck", () => this.commit(() => Betting.check(this.state, index))));
|
||||||
|
} else if (owed >= stack) {
|
||||||
|
// Calling would put them all-in, so say that rather than quoting a number
|
||||||
|
// they can't cover.
|
||||||
|
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(() => Betting.allIn(this.state, index)), Money.format(stack)));
|
||||||
|
} else {
|
||||||
|
row.appendChild(this.button("Call", "actionCall", () => this.commit(() => Betting.call(this.state, index)), Money.format(owed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stack > owed) {
|
||||||
|
row.appendChild(this.button(owed > 0 ? "Raise" : "Bet", "actionRaise", () => {
|
||||||
|
this.raiseOpen = true;
|
||||||
|
this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack);
|
||||||
|
this.render();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
|
||||||
|
// button, and only reaches for digits when the table does something odd.
|
||||||
|
renderRaisePad() {
|
||||||
|
const index = this.state.actingSeatIndex;
|
||||||
|
const player = this.state.seats[index];
|
||||||
|
const stack = Betting.stackOf(this.state, player);
|
||||||
|
const maxTo = Betting.round(Betting.committed(player) + stack);
|
||||||
|
const pot = Betting.potSoFar(this.state);
|
||||||
|
const minTo = Math.min(Betting.minRaiseTo(this.state), maxTo);
|
||||||
|
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "raisePad";
|
||||||
|
|
||||||
|
const quick = document.createElement("div");
|
||||||
|
quick.className = "raiseQuick";
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ label: "Min", value: minTo },
|
||||||
|
{ label: "½ pot", value: Betting.round(this.state.currentBet + pot / 2) },
|
||||||
|
{ label: "Pot", value: Betting.round(this.state.currentBet + pot) },
|
||||||
|
{ label: "All-in", value: maxTo },
|
||||||
|
];
|
||||||
|
options.forEach(option => {
|
||||||
|
const value = Math.max(minTo, Math.min(option.value, maxTo));
|
||||||
|
const btn = this.button(option.label, "raiseQuickBtn", () => {
|
||||||
|
this.raiseDraft = value;
|
||||||
|
this.render();
|
||||||
|
}, Money.format(value));
|
||||||
|
if (Betting.round(this.raiseDraft) === value) btn.classList.add("selected");
|
||||||
|
quick.appendChild(btn);
|
||||||
|
});
|
||||||
|
wrap.appendChild(quick);
|
||||||
|
|
||||||
|
const entry = document.createElement("div");
|
||||||
|
entry.className = "raiseEntry";
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.inputMode = "decimal";
|
||||||
|
input.className = "raiseInput";
|
||||||
|
input.value = this.raiseDraft ?? minTo;
|
||||||
|
input.setAttribute("aria-label", "Raise to amount");
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
|
||||||
|
});
|
||||||
|
entry.appendChild(input);
|
||||||
|
|
||||||
|
entry.appendChild(this.button("Cancel", "raiseCancel", () => {
|
||||||
|
this.raiseOpen = false;
|
||||||
|
this.render();
|
||||||
|
}));
|
||||||
|
|
||||||
|
entry.appendChild(this.button("Raise to", "raiseConfirm", () => {
|
||||||
|
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
|
||||||
|
this.raiseOpen = false;
|
||||||
|
this.commit(() => Betting.raiseTo(this.state, index, target));
|
||||||
|
}));
|
||||||
|
|
||||||
|
wrap.appendChild(entry);
|
||||||
|
return wrap;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderStreetDone() {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "actionButtons";
|
||||||
|
|
||||||
|
const isRiver = this.state.street === "river";
|
||||||
|
const label = isRiver ? "Showdown — resolve pot" : `Deal ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1])}`;
|
||||||
|
|
||||||
|
row.appendChild(this.button(label, "actionAdvance", () => {
|
||||||
|
if (isRiver) {
|
||||||
|
this.hooks.resolvePot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.commit(() => Betting.closeStreet(this.state));
|
||||||
|
}));
|
||||||
|
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHandOver() {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "actionButtons";
|
||||||
|
const live = Betting.liveSeats(this.state);
|
||||||
|
const winner = live.length === 1 ? HoldemState.displayName(live[0].player, live[0].index) : null;
|
||||||
|
|
||||||
|
row.appendChild(this.button(
|
||||||
|
winner ? `Award pot to ${winner}` : "Resolve pot",
|
||||||
|
"actionAdvance",
|
||||||
|
() => this.hooks.resolvePot()
|
||||||
|
));
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = ActionBar;
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
// Live betting: who's to act, what it costs them, and where the money goes.
|
||||||
|
//
|
||||||
|
// This is bookkeeping, not a game — the dealer taps what the humans at the table
|
||||||
|
// actually did, and the tool keeps the arithmetic straight. It deliberately never
|
||||||
|
// blocks an action it thinks is "illegal"; a dealer overruling the maths is right
|
||||||
|
// more often than the maths is.
|
||||||
|
//
|
||||||
|
// Money model: `player.committed` is what a seat has put in on the current street.
|
||||||
|
// When a street closes it sweeps into `player.amountOverride`, which is the seat's
|
||||||
|
// total for the hand — the same field the pot badge, the pot wizard, the standings
|
||||||
|
// and the CSV already read. So the betting bar is a fast way to fill in a field
|
||||||
|
// that already existed, and nothing downstream had to change.
|
||||||
|
const Betting = {
|
||||||
|
STREETS: ["preflop", "flop", "turn", "river"],
|
||||||
|
|
||||||
|
streetLabel(street) {
|
||||||
|
return { preflop: "Pre-flop", flop: "Flop", turn: "Turn", river: "River" }[street] || "Pre-flop";
|
||||||
|
},
|
||||||
|
|
||||||
|
round(amount) {
|
||||||
|
return Math.round((Number(amount) || 0) * 100) / 100;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Reading the table ----
|
||||||
|
|
||||||
|
liveSeats(state) {
|
||||||
|
return state.seats
|
||||||
|
.map((player, index) => ({ player, index }))
|
||||||
|
.filter(s => s.player.inHand && !s.player.folded);
|
||||||
|
},
|
||||||
|
|
||||||
|
committed(player) {
|
||||||
|
return this.round(player.committed || 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Everything this seat has put in across the whole hand, including the street
|
||||||
|
// still in progress.
|
||||||
|
handTotal(player) {
|
||||||
|
return this.round((Number(player.amountOverride) || 0) + this.committed(player));
|
||||||
|
},
|
||||||
|
|
||||||
|
stackOf(state, player) {
|
||||||
|
const own = player.stack;
|
||||||
|
if (own !== null && own !== undefined && String(own).trim() !== "") return this.round(own);
|
||||||
|
const fallback = Number(state.defaultStack);
|
||||||
|
return Number.isNaN(fallback) ? 0 : this.round(fallback);
|
||||||
|
},
|
||||||
|
|
||||||
|
toCall(state, player) {
|
||||||
|
return Math.max(0, this.round((state.currentBet || 0) - this.committed(player)));
|
||||||
|
},
|
||||||
|
|
||||||
|
// A raise has to at least match the last one; before any raise, the big blind
|
||||||
|
// is the yardstick.
|
||||||
|
minRaiseTo(state) {
|
||||||
|
const increment = this.round(state.lastRaiseSize || state.bigBlind || 0);
|
||||||
|
return this.round((state.currentBet || 0) + (increment || 1));
|
||||||
|
},
|
||||||
|
|
||||||
|
potSoFar(state) {
|
||||||
|
return this.round(
|
||||||
|
state.seats.reduce((sum, p) => sum + this.handTotal(p), 0)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Moving the action ----
|
||||||
|
|
||||||
|
nextLiveIndex(state, fromIndex) {
|
||||||
|
const count = state.seatCount;
|
||||||
|
for (let step = 1; step <= count; step++) {
|
||||||
|
const index = this.seatAt(state, fromIndex, step);
|
||||||
|
const player = state.seats[index];
|
||||||
|
if (player.inHand && !player.folded && this.stackOf(state, player) >= 0) return index;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
seatAt(state, fromIndex, step) {
|
||||||
|
const count = state.seatCount;
|
||||||
|
const delta = state.direction === "counterclockwise" ? -step : step;
|
||||||
|
return ((fromIndex + delta) % count + count) % count;
|
||||||
|
},
|
||||||
|
|
||||||
|
// A street is done when everyone still live has either matched the bet or has
|
||||||
|
// no chips left to argue with, and everyone has had a turn since the last raise.
|
||||||
|
isStreetComplete(state) {
|
||||||
|
const live = this.liveSeats(state);
|
||||||
|
if (live.length <= 1) return true;
|
||||||
|
|
||||||
|
const unsettled = live.filter(s => {
|
||||||
|
const allIn = this.stackOf(state, s.player) <= 0;
|
||||||
|
const matched = this.toCall(state, s.player) <= 0;
|
||||||
|
return !allIn && (!matched || !s.player.hasActed);
|
||||||
|
});
|
||||||
|
return unsettled.length === 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Actions ----
|
||||||
|
|
||||||
|
// Moves `amount` from a seat's stack into the middle for this street. Returns
|
||||||
|
// what actually moved, which is less than asked for if it puts them all-in.
|
||||||
|
putIn(state, index, amount) {
|
||||||
|
const player = state.seats[index];
|
||||||
|
const stack = this.stackOf(state, player);
|
||||||
|
const moved = this.round(Math.min(Math.max(0, amount), stack));
|
||||||
|
|
||||||
|
player.stack = this.round(stack - moved);
|
||||||
|
player.committed = this.round(this.committed(player) + moved);
|
||||||
|
return moved;
|
||||||
|
},
|
||||||
|
|
||||||
|
fold(state, index) {
|
||||||
|
state.seats[index].folded = true;
|
||||||
|
state.seats[index].hasActed = true;
|
||||||
|
this.passAction(state, index);
|
||||||
|
},
|
||||||
|
|
||||||
|
check(state, index) {
|
||||||
|
state.seats[index].hasActed = true;
|
||||||
|
this.passAction(state, index);
|
||||||
|
},
|
||||||
|
|
||||||
|
call(state, index) {
|
||||||
|
const player = state.seats[index];
|
||||||
|
this.putIn(state, index, this.toCall(state, player));
|
||||||
|
player.hasActed = true;
|
||||||
|
this.passAction(state, index);
|
||||||
|
},
|
||||||
|
|
||||||
|
// `total` is what the seat's street commitment becomes — "raise to", the way a
|
||||||
|
// dealer says it out loud, not "raise by".
|
||||||
|
raiseTo(state, index, total) {
|
||||||
|
const player = state.seats[index];
|
||||||
|
const target = this.round(total);
|
||||||
|
const delta = this.round(target - this.committed(player));
|
||||||
|
const moved = this.putIn(state, index, delta);
|
||||||
|
const newBet = this.committed(player);
|
||||||
|
|
||||||
|
if (newBet > (state.currentBet || 0)) {
|
||||||
|
state.lastRaiseSize = this.round(newBet - (state.currentBet || 0));
|
||||||
|
state.currentBet = newBet;
|
||||||
|
// A raise reopens the action: everyone else owes a response again.
|
||||||
|
state.seats.forEach((p, i) => {
|
||||||
|
if (i !== index && p.inHand && !p.folded) p.hasActed = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
player.hasActed = true;
|
||||||
|
this.passAction(state, index);
|
||||||
|
return moved;
|
||||||
|
},
|
||||||
|
|
||||||
|
allIn(state, index) {
|
||||||
|
const player = state.seats[index];
|
||||||
|
return this.raiseTo(state, index, this.round(this.committed(player) + this.stackOf(state, player)));
|
||||||
|
},
|
||||||
|
|
||||||
|
passAction(state, index) {
|
||||||
|
state.actingSeatIndex = this.isStreetComplete(state) ? null : this.nextLiveIndex(state, index);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Street and hand boundaries ----
|
||||||
|
|
||||||
|
// Pulls whatever is in front of the players into their hand totals. Anything that
|
||||||
|
// reads a seat's contribution — the pot wizard above all — has to run after this,
|
||||||
|
// or it will price the pot without the chips sitting on the current street.
|
||||||
|
sweepToHandTotals(state) {
|
||||||
|
state.seats.forEach(player => {
|
||||||
|
const moved = this.committed(player);
|
||||||
|
if (moved > 0) {
|
||||||
|
player.amountOverride = this.round((Number(player.amountOverride) || 0) + moved);
|
||||||
|
}
|
||||||
|
player.committed = 0;
|
||||||
|
player.hasActed = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sweeps the street's chips into each seat's hand total and opens the next one.
|
||||||
|
closeStreet(state) {
|
||||||
|
this.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
state.currentBet = 0;
|
||||||
|
state.lastRaiseSize = 0;
|
||||||
|
|
||||||
|
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
|
||||||
|
state.street = this.STREETS[nextStreetIndex] || "river";
|
||||||
|
state.actingSeatIndex = this.liveSeats(state).length > 1
|
||||||
|
? this.nextLiveIndex(state, state.dealerSeatIndex)
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
|
||||||
|
isHandOver(state) {
|
||||||
|
return this.liveSeats(state).length <= 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Deals a fresh betting round: blinds posted, action to the seat after the big
|
||||||
|
// blind, which is where a dealer expects to be looking when the cards go out.
|
||||||
|
beginHand(state) {
|
||||||
|
state.street = "preflop";
|
||||||
|
state.currentBet = 0;
|
||||||
|
state.lastRaiseSize = 0;
|
||||||
|
|
||||||
|
state.seats.forEach(player => {
|
||||||
|
player.committed = 0;
|
||||||
|
player.hasActed = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sb = Number(state.smallBlind) || 0;
|
||||||
|
const bb = Number(state.bigBlind) || 0;
|
||||||
|
const sbIndex = this.seatAt(state, state.dealerSeatIndex, 1);
|
||||||
|
const bbIndex = this.seatAt(state, state.dealerSeatIndex, 2);
|
||||||
|
|
||||||
|
if (sb > 0 && state.seats[sbIndex].inHand) this.putIn(state, sbIndex, sb);
|
||||||
|
if (bb > 0 && state.seats[bbIndex].inHand) this.putIn(state, bbIndex, bb);
|
||||||
|
|
||||||
|
state.currentBet = this.round(Math.max(this.committed(state.seats[sbIndex]), this.committed(state.seats[bbIndex])));
|
||||||
|
state.lastRaiseSize = bb || 0;
|
||||||
|
state.actingSeatIndex = this.nextLiveIndex(state, bbIndex);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Winners get their share put back in front of them.
|
||||||
|
creditWinnings(state, payouts) {
|
||||||
|
payouts.forEach(payout => {
|
||||||
|
const player = state.seats.find(p => p.id === payout.id);
|
||||||
|
if (!player) return;
|
||||||
|
player.stack = this.round(this.stackOf(state, player) + payout.amount);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = Betting;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// Tap a card instead of typing it.
|
||||||
|
//
|
||||||
|
// Typing "AH" into a two-character box is fine at a desk and miserable at a table:
|
||||||
|
// it needs a keyboard, it accepts nonsense like "ZZ", and it happily lets the same
|
||||||
|
// card land in two players' hands. The picker is one tap per card, greys out cards
|
||||||
|
// already dealt somewhere else at the table, and stores exactly the same two-character
|
||||||
|
// string the rest of the tool has always read.
|
||||||
|
const CardPicker = {
|
||||||
|
RANKS: ["A", "K", "Q", "J", "T", "9", "8", "7", "6", "5", "4", "3", "2"],
|
||||||
|
SUITS: [
|
||||||
|
{ code: "S", symbol: "♠", red: false },
|
||||||
|
{ code: "H", symbol: "♥", red: true },
|
||||||
|
{ code: "D", symbol: "♦", red: true },
|
||||||
|
{ code: "C", symbol: "♣", red: false },
|
||||||
|
],
|
||||||
|
|
||||||
|
state: null,
|
||||||
|
hooks: null,
|
||||||
|
seatIndex: null,
|
||||||
|
cardIndex: 0,
|
||||||
|
|
||||||
|
open(state, hooks, seatIndex, cardIndex) {
|
||||||
|
this.state = state;
|
||||||
|
this.hooks = hooks;
|
||||||
|
this.seatIndex = seatIndex;
|
||||||
|
this.cardIndex = cardIndex;
|
||||||
|
this.render();
|
||||||
|
document.getElementById("cardPickerOverlay").hidden = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
close() {
|
||||||
|
document.getElementById("cardPickerOverlay").hidden = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Every card sitting in front of anyone else. The slot being edited is excluded,
|
||||||
|
// so re-picking the card that's already there isn't treated as a duplicate.
|
||||||
|
cardsInUse() {
|
||||||
|
const used = new Map();
|
||||||
|
this.state.seats.forEach((player, seat) => {
|
||||||
|
(player.cards || []).forEach((card, slot) => {
|
||||||
|
if (!card) return;
|
||||||
|
if (seat === this.seatIndex && slot === this.cardIndex) return;
|
||||||
|
used.set(card.toUpperCase(), HoldemState.displayName(player, seat));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return used;
|
||||||
|
},
|
||||||
|
|
||||||
|
assign(card) {
|
||||||
|
const player = this.state.seats[this.seatIndex];
|
||||||
|
player.cards[this.cardIndex] = card;
|
||||||
|
this.hooks.onPick(this.seatIndex, this.cardIndex, card);
|
||||||
|
|
||||||
|
// Straight on to the second card — a dealer picking hole cards is always
|
||||||
|
// picking two, and making them re-open the picker doubles the taps.
|
||||||
|
if (this.cardIndex === 0 && !player.cards[1]) {
|
||||||
|
this.cardIndex = 1;
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.close();
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSlot() {
|
||||||
|
const player = this.state.seats[this.seatIndex];
|
||||||
|
player.cards[this.cardIndex] = "";
|
||||||
|
this.hooks.onPick(this.seatIndex, this.cardIndex, "");
|
||||||
|
this.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const player = this.state.seats[this.seatIndex];
|
||||||
|
const used = this.cardsInUse();
|
||||||
|
|
||||||
|
document.getElementById("cardPickerTitle").textContent =
|
||||||
|
`${HoldemState.displayName(player, this.seatIndex)} — card ${this.cardIndex + 1}`;
|
||||||
|
|
||||||
|
// Which slot the next tap fills, switchable without closing the sheet.
|
||||||
|
const slots = document.getElementById("cardPickerSlots");
|
||||||
|
slots.innerHTML = "";
|
||||||
|
[0, 1].forEach(slot => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "pickerSlot";
|
||||||
|
btn.classList.toggle("active", slot === this.cardIndex);
|
||||||
|
const value = player.cards[slot] || "";
|
||||||
|
btn.textContent = value || "—";
|
||||||
|
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
this.cardIndex = slot;
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
slots.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
const grid = document.getElementById("cardPickerGrid");
|
||||||
|
grid.innerHTML = "";
|
||||||
|
this.RANKS.forEach(rank => {
|
||||||
|
this.SUITS.forEach(suit => {
|
||||||
|
const card = `${rank}${suit.code}`;
|
||||||
|
const takenBy = used.get(card);
|
||||||
|
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "pickerCard";
|
||||||
|
btn.classList.toggle("cardRed", suit.red);
|
||||||
|
btn.disabled = !!takenBy;
|
||||||
|
if (takenBy) btn.title = `Already dealt to ${takenBy}`;
|
||||||
|
if ((player.cards[this.cardIndex] || "").toUpperCase() === card) {
|
||||||
|
btn.classList.add("selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankEl = document.createElement("span");
|
||||||
|
rankEl.className = "pickerRank";
|
||||||
|
rankEl.textContent = rank;
|
||||||
|
const suitEl = document.createElement("span");
|
||||||
|
suitEl.className = "pickerSuit";
|
||||||
|
suitEl.textContent = suit.symbol;
|
||||||
|
btn.appendChild(rankEl);
|
||||||
|
btn.appendChild(suitEl);
|
||||||
|
|
||||||
|
btn.addEventListener("click", () => this.assign(card));
|
||||||
|
grid.appendChild(btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = CardPicker;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Toasts and confirmation dialogs, so the table never has to fall back on the
|
||||||
|
// browser's own alert boxes. A system confirm() steals focus, looks nothing like
|
||||||
|
// the rest of the page, and on a phone it's a full-screen interruption — all of
|
||||||
|
// which is a bad trade for a dealer trying to keep a game moving.
|
||||||
|
|
||||||
|
// A short-lived message at the bottom of the screen, optionally with one action
|
||||||
|
// (in practice: Undo). Only one is ever on screen; a second replaces the first.
|
||||||
|
const Toast = {
|
||||||
|
DEFAULT_DURATION_MS: 8000,
|
||||||
|
timer: null,
|
||||||
|
|
||||||
|
show(message, options = {}) {
|
||||||
|
const host = document.getElementById("toastHost");
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
this.dismiss();
|
||||||
|
|
||||||
|
const toast = document.createElement("div");
|
||||||
|
toast.className = "toast";
|
||||||
|
toast.setAttribute("role", "status");
|
||||||
|
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.className = "toastText";
|
||||||
|
text.textContent = message;
|
||||||
|
toast.appendChild(text);
|
||||||
|
|
||||||
|
if (options.actionLabel && options.onAction) {
|
||||||
|
const action = document.createElement("button");
|
||||||
|
action.type = "button";
|
||||||
|
action.className = "toastAction";
|
||||||
|
action.textContent = options.actionLabel;
|
||||||
|
action.addEventListener("click", () => {
|
||||||
|
this.dismiss();
|
||||||
|
options.onAction();
|
||||||
|
});
|
||||||
|
toast.appendChild(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = document.createElement("button");
|
||||||
|
close.type = "button";
|
||||||
|
close.className = "toastClose";
|
||||||
|
close.setAttribute("aria-label", "Dismiss");
|
||||||
|
close.textContent = "✕";
|
||||||
|
close.addEventListener("click", () => this.dismiss());
|
||||||
|
toast.appendChild(close);
|
||||||
|
|
||||||
|
host.appendChild(toast);
|
||||||
|
// Next frame, so the entrance transition actually runs.
|
||||||
|
requestAnimationFrame(() => toast.classList.add("toastVisible"));
|
||||||
|
|
||||||
|
this.timer = setTimeout(
|
||||||
|
() => this.dismiss(),
|
||||||
|
options.duration || this.DEFAULT_DURATION_MS
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
dismiss() {
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
const host = document.getElementById("toastHost");
|
||||||
|
if (host) host.innerHTML = "";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Styled stand-in for confirm(). Resolves true if the dealer goes through with it.
|
||||||
|
const Dialog = {
|
||||||
|
confirm({ title, message, confirmLabel = "Confirm", cancelLabel = "Cancel", danger = false }) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const overlay = document.getElementById("confirmOverlay");
|
||||||
|
const modal = overlay.querySelector("#confirmModal");
|
||||||
|
overlay.querySelector("#confirmTitle").textContent = title;
|
||||||
|
overlay.querySelector("#confirmMessage").textContent = message;
|
||||||
|
|
||||||
|
const confirmBtn = overlay.querySelector("#confirmAcceptBtn");
|
||||||
|
const cancelBtn = overlay.querySelector("#confirmCancelBtn");
|
||||||
|
confirmBtn.textContent = confirmLabel;
|
||||||
|
cancelBtn.textContent = cancelLabel;
|
||||||
|
confirmBtn.classList.toggle("holdemBtnDanger", danger);
|
||||||
|
confirmBtn.classList.toggle("holdemBtnAccent", !danger);
|
||||||
|
|
||||||
|
const finish = result => {
|
||||||
|
overlay.hidden = true;
|
||||||
|
confirmBtn.removeEventListener("click", onAccept);
|
||||||
|
cancelBtn.removeEventListener("click", onCancel);
|
||||||
|
overlay.removeEventListener("click", onBackdrop);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
const onAccept = () => finish(true);
|
||||||
|
const onCancel = () => finish(false);
|
||||||
|
const onBackdrop = e => {
|
||||||
|
if (e.target === overlay) finish(false);
|
||||||
|
};
|
||||||
|
const onKey = e => {
|
||||||
|
if (e.key === "Escape") finish(false);
|
||||||
|
if (e.key === "Enter") finish(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
confirmBtn.addEventListener("click", onAccept);
|
||||||
|
cancelBtn.addEventListener("click", onCancel);
|
||||||
|
overlay.addEventListener("click", onBackdrop);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
|
||||||
|
overlay.hidden = false;
|
||||||
|
modal.focus();
|
||||||
|
confirmBtn.focus();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = { Toast, Dialog };
|
||||||
|
}
|
||||||
@@ -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,235 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const state = HoldemState.load();
|
||||||
|
|
||||||
|
// A gesture on a seat is a shortcut for something the dealer could do the long
|
||||||
|
// way, so each one is undoable the same way the header buttons are.
|
||||||
|
const seatAction = (index, message, mutate) => {
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(state));
|
||||||
|
mutate(state.seats[index], index);
|
||||||
|
HoldemState.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
|
||||||
|
Toast.show(message, {
|
||||||
|
actionLabel: "Undo",
|
||||||
|
onAction: () => {
|
||||||
|
Object.keys(state).forEach(key => delete state[key]);
|
||||||
|
Object.assign(state, snapshot);
|
||||||
|
HoldemState.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
Toast.show("Undone.", { duration: 2500 });
|
||||||
|
},
|
||||||
|
duration: 6000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const nameOf = index => HoldemState.displayName(state.seats[index], index);
|
||||||
|
|
||||||
|
const hooks = {
|
||||||
|
save: s => HoldemState.save(s),
|
||||||
|
rerender: () => Render.renderAll(),
|
||||||
|
|
||||||
|
onSeatDoubleTap: index => {
|
||||||
|
const player = state.seats[index];
|
||||||
|
if (!player.inHand) return;
|
||||||
|
if (player.folded) {
|
||||||
|
seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seatAction(index, `${nameOf(index)} folds.`, () => {
|
||||||
|
if (state.actingSeatIndex === index) Betting.fold(state, index);
|
||||||
|
else player.folded = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
|
||||||
|
};
|
||||||
|
|
||||||
|
const seatMenuHooks = {
|
||||||
|
fold: index => hooks.onSeatDoubleTap(index),
|
||||||
|
unfold: index => seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; }),
|
||||||
|
allIn: index => seatAction(index, `${nameOf(index)} is all-in.`, () => Betting.allIn(state, index)),
|
||||||
|
toggleDealtIn: index => {
|
||||||
|
const dealtIn = state.seats[index].inHand;
|
||||||
|
seatAction(index, dealtIn ? `${nameOf(index)} sits out.` : `${nameOf(index)} is dealt in.`, p => {
|
||||||
|
p.inHand = !dealtIn;
|
||||||
|
if (!p.inHand) p.folded = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { state.dealerSeatIndex = index; }),
|
||||||
|
editSeat: index => Render.toggleSeatEditor(index),
|
||||||
|
};
|
||||||
|
|
||||||
|
Render.mount(state, hooks);
|
||||||
|
|
||||||
|
const openPotWizard = () => {
|
||||||
|
// Chips still in front of players belong in the pot being priced, so bank them
|
||||||
|
// before the wizard reads anyone's contribution.
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
hooks.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
|
||||||
|
PotWizard.open(state, payouts => {
|
||||||
|
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
||||||
|
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
|
||||||
|
if (payouts.length > 0) {
|
||||||
|
HoldemState.recordHand(state, payouts);
|
||||||
|
// Winnings go straight back in front of the player, so the next
|
||||||
|
// hand's stacks are already right.
|
||||||
|
Betting.creditWinnings(state, payouts);
|
||||||
|
}
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ActionBar.mount(state, {
|
||||||
|
save: hooks.save,
|
||||||
|
rerender: hooks.rerender,
|
||||||
|
resolvePot: openPotWizard,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Moving to the next hand used to ask "are you sure?" every single time —
|
||||||
|
// the most-pressed button in a session, gated behind a system dialog. It now
|
||||||
|
// just happens, and the toast offers the way back for the few seconds anyone
|
||||||
|
// would notice a misclick. The snapshot is deliberately in-memory only: an
|
||||||
|
// undo is for the mistake you just made, not one from last night's session.
|
||||||
|
const runUndoable = (message, mutate) => {
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(state));
|
||||||
|
mutate();
|
||||||
|
hooks.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
|
||||||
|
Toast.show(message, {
|
||||||
|
actionLabel: "Undo",
|
||||||
|
onAction: () => {
|
||||||
|
Object.keys(state).forEach(key => delete state[key]);
|
||||||
|
Object.assign(state, snapshot);
|
||||||
|
hooks.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
Toast.show("Undone.", { duration: 2500 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
// startNextHand moves the button on by one; say where it landed so the
|
||||||
|
// dealer can confirm the rotation without hunting for the badge.
|
||||||
|
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
||||||
|
const where = state.dedicatedDealer
|
||||||
|
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
|
||||||
|
: `button on seat ${nextDealer + 1}`;
|
||||||
|
|
||||||
|
runUndoable(`Next hand — ${where}. Blinds posted.`, () => {
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("resetTableBtn").addEventListener("click", async () => {
|
||||||
|
const ok = await Dialog.confirm({
|
||||||
|
title: "Reset the whole table?",
|
||||||
|
message: "Every seat, chip colour and note goes back to defaults. The hand history and session standings are cleared too.",
|
||||||
|
confirmLabel: "Reset table",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
HoldemState.clear();
|
||||||
|
Object.assign(state, HoldemState.createDefault());
|
||||||
|
Render.mount(state, hooks);
|
||||||
|
Toast.show("Table reset.", { duration: 2500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
|
||||||
|
|
||||||
|
[
|
||||||
|
["smallBlindInput", "smallBlind"],
|
||||||
|
["bigBlindInput", "bigBlind"],
|
||||||
|
["defaultStackInput", "defaultStack"],
|
||||||
|
].forEach(([id, key]) => {
|
||||||
|
document.getElementById(id).addEventListener("input", e => {
|
||||||
|
state[key] = e.target.value === "" ? null : Number(e.target.value);
|
||||||
|
hooks.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
|
||||||
|
|
||||||
|
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
|
||||||
|
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
|
||||||
|
if (e.target.id === "seatMenuOverlay") SeatMenu.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("cardPickerCloseBtn").addEventListener("click", () => CardPicker.close());
|
||||||
|
document.getElementById("cardPickerClearBtn").addEventListener("click", () => CardPicker.clearSlot());
|
||||||
|
document.getElementById("cardPickerOverlay").addEventListener("click", e => {
|
||||||
|
if (e.target.id === "cardPickerOverlay") CardPicker.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
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", async () => {
|
||||||
|
const ok = await Dialog.confirm({
|
||||||
|
title: "Clear the hand history?",
|
||||||
|
message: "This wipes every logged hand and the session standings, and puts the hand counter back to #1. Export first if anyone might want the numbers.",
|
||||||
|
confirmLabel: "Clear history",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
HoldemState.clearHistory(state);
|
||||||
|
hooks.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
Toast.show("Hand history cleared.", { duration: 2500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
||||||
|
if ((state.handHistory || []).length === 0) return;
|
||||||
|
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Shared money formatting for every casino surface. Kept apart from the table's
|
||||||
|
// rendering so the quick-split calculator can pull in the formatter without dragging
|
||||||
|
// the whole seat/chip UI along with it.
|
||||||
|
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 = Money;
|
||||||
|
}
|
||||||
@@ -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).map(c => Render.formatCard(c)).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,166 @@
|
|||||||
|
// The quick-split screen: pot in, payout out, nothing to set up and nothing saved.
|
||||||
|
// A dealer opens this one-handed with players waiting, so every control is a tap and
|
||||||
|
// the answer updates as they type rather than behind a "calculate" button.
|
||||||
|
const QuickSplit = {
|
||||||
|
WAY_CHOICES: [2, 3, 4, 5],
|
||||||
|
CHIP_CHOICES: [
|
||||||
|
{ value: 0.01, label: "1¢" },
|
||||||
|
{ value: 0.25, label: "25¢" },
|
||||||
|
{ value: 1, label: "$1" },
|
||||||
|
{ value: 5, label: "$5" },
|
||||||
|
{ value: 25, label: "$25" },
|
||||||
|
],
|
||||||
|
MAX_WAYS: 12,
|
||||||
|
|
||||||
|
ways: 2,
|
||||||
|
chip: 1,
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
this.potInput = document.getElementById("potInput");
|
||||||
|
this.clearPotBtn = document.getElementById("clearPotBtn");
|
||||||
|
|
||||||
|
this.renderWayChoices();
|
||||||
|
this.renderChipChoices();
|
||||||
|
|
||||||
|
this.potInput.addEventListener("input", () => {
|
||||||
|
this.sanitizePotInput();
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clearPotBtn.addEventListener("click", () => {
|
||||||
|
this.potInput.value = "";
|
||||||
|
this.potInput.focus();
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("resetSplitBtn").addEventListener("click", () => {
|
||||||
|
this.potInput.value = "";
|
||||||
|
this.ways = 2;
|
||||||
|
this.chip = 1;
|
||||||
|
this.renderWayChoices();
|
||||||
|
this.renderChipChoices();
|
||||||
|
this.potInput.focus();
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.update();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Keeps the field to something parseable without fighting the typist: digits and a
|
||||||
|
// single decimal point, at most two places.
|
||||||
|
sanitizePotInput() {
|
||||||
|
const cleaned = this.potInput.value
|
||||||
|
.replace(/[^0-9.]/g, "")
|
||||||
|
.replace(/(\..*)\./g, "$1")
|
||||||
|
.replace(/(\.\d\d)\d+/, "$1");
|
||||||
|
if (cleaned !== this.potInput.value) this.potInput.value = cleaned;
|
||||||
|
},
|
||||||
|
|
||||||
|
makeChoiceButton(label, isSelected, onPick) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "choiceChip";
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.setAttribute("aria-pressed", String(isSelected));
|
||||||
|
btn.classList.toggle("selected", isSelected);
|
||||||
|
btn.addEventListener("click", onPick);
|
||||||
|
return btn;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderWayChoices() {
|
||||||
|
const row = document.getElementById("waysChoices");
|
||||||
|
row.innerHTML = "";
|
||||||
|
|
||||||
|
this.WAY_CHOICES.forEach(count => {
|
||||||
|
row.appendChild(
|
||||||
|
this.makeChoiceButton(String(count), this.ways === count, () => {
|
||||||
|
this.ways = count;
|
||||||
|
this.renderWayChoices();
|
||||||
|
this.update();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Beyond a five-way chop it's rare enough to hide behind a stepper rather than
|
||||||
|
// spend a row of buttons on it.
|
||||||
|
const more = document.createElement("div");
|
||||||
|
more.className = "waysStepper";
|
||||||
|
|
||||||
|
const minus = this.makeStepButton("−", () => this.stepWays(-1));
|
||||||
|
const plus = this.makeStepButton("+", () => this.stepWays(1));
|
||||||
|
const readout = document.createElement("span");
|
||||||
|
readout.className = "waysReadout";
|
||||||
|
readout.textContent = `${this.ways} ways`;
|
||||||
|
|
||||||
|
more.appendChild(minus);
|
||||||
|
more.appendChild(readout);
|
||||||
|
more.appendChild(plus);
|
||||||
|
row.appendChild(more);
|
||||||
|
},
|
||||||
|
|
||||||
|
makeStepButton(label, onPick) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "stepBtn";
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.addEventListener("click", onPick);
|
||||||
|
return btn;
|
||||||
|
},
|
||||||
|
|
||||||
|
stepWays(delta) {
|
||||||
|
this.ways = Math.max(2, Math.min(this.MAX_WAYS, this.ways + delta));
|
||||||
|
this.renderWayChoices();
|
||||||
|
this.update();
|
||||||
|
},
|
||||||
|
|
||||||
|
renderChipChoices() {
|
||||||
|
const row = document.getElementById("chipChoices");
|
||||||
|
row.innerHTML = "";
|
||||||
|
this.CHIP_CHOICES.forEach(choice => {
|
||||||
|
row.appendChild(
|
||||||
|
this.makeChoiceButton(choice.label, this.chip === choice.value, () => {
|
||||||
|
this.chip = choice.value;
|
||||||
|
this.renderChipChoices();
|
||||||
|
this.update();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
update() {
|
||||||
|
const potValue = Number(this.potInput.value);
|
||||||
|
const pot = Number.isNaN(potValue) ? 0 : potValue;
|
||||||
|
this.clearPotBtn.hidden = this.potInput.value === "";
|
||||||
|
|
||||||
|
const result = SplitCalculator.split(pot, this.ways, this.chip);
|
||||||
|
|
||||||
|
document.getElementById("splitResultLabel").textContent =
|
||||||
|
`Each of ${result.players} players gets`;
|
||||||
|
document.getElementById("splitResultAmount").textContent = Money.format(result.each);
|
||||||
|
|
||||||
|
const oddNote = document.getElementById("splitOddNote");
|
||||||
|
if (result.valid && result.oddChips > 0) {
|
||||||
|
oddNote.textContent = result.oddChips === 1
|
||||||
|
? `plus one odd ${Money.format(result.chip)} chip to the first player left of the button`
|
||||||
|
: `plus ${result.oddChips} odd ${Money.format(result.chip)} chips — one each to the ` +
|
||||||
|
`first ${result.oddChips} players left of the button`;
|
||||||
|
oddNote.hidden = false;
|
||||||
|
} else {
|
||||||
|
oddNote.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dustNote = document.getElementById("splitDustNote");
|
||||||
|
if (result.valid && result.dust > 0) {
|
||||||
|
dustNote.textContent =
|
||||||
|
`${Money.format(result.dust)} doesn't divide into ${Money.format(result.chip)} chips — ` +
|
||||||
|
`pick a smaller denomination.`;
|
||||||
|
dustNote.hidden = false;
|
||||||
|
} else {
|
||||||
|
dustNote.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("splitResult").classList.toggle("hasAmount", result.valid && result.each > 0);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => QuickSplit.mount());
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
// 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();
|
||||||
|
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
|
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
|
||||||
|
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
|
||||||
|
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
|
||||||
|
},
|
||||||
|
|
||||||
|
renderTableHint() {
|
||||||
|
const hint = document.getElementById("tableHint");
|
||||||
|
// Gestures are invisible until someone tells you about them, and this is the
|
||||||
|
// only place on the table with room to say so.
|
||||||
|
hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats.";
|
||||||
|
},
|
||||||
|
|
||||||
|
renderNotes() {
|
||||||
|
const notesEl = document.getElementById("tableNotes");
|
||||||
|
if (document.activeElement !== notesEl) notesEl.value = this.state.notes || "";
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePotBadge() {
|
||||||
|
// Chips still sitting in front of players on the current street are part of
|
||||||
|
// the pot as far as anyone at the table is concerned, so count them — waiting
|
||||||
|
// until the street closes would show a stale number for most of every hand.
|
||||||
|
const swept = HoldemState.totalPot(this.state);
|
||||||
|
const onStreet = this.state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
|
||||||
|
document.getElementById("potBadgeAmount").textContent = Money.format(swept + onStreet);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- 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);
|
||||||
|
|
||||||
|
this.wireSeatGestures(node, index);
|
||||||
|
this.wireDragAndDrop(node, index);
|
||||||
|
|
||||||
|
layer.appendChild(node);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
isRedSuit(card) {
|
||||||
|
return /[HD]$/i.test(card || "");
|
||||||
|
},
|
||||||
|
|
||||||
|
SUIT_SYMBOLS: { S: "♠", H: "♥", D: "♦", C: "♣" },
|
||||||
|
|
||||||
|
// "KD" -> "K♦". Stored cards stay two plain characters so every existing reader
|
||||||
|
// keeps working; the suit only becomes a symbol on its way to a human.
|
||||||
|
formatCard(card) {
|
||||||
|
const value = (card || "").toUpperCase();
|
||||||
|
if (!value) return "";
|
||||||
|
return `${value[0]}${this.SUIT_SYMBOLS[value[1]] || value[1] || ""}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// An empty slot has to look like something you can tap, so it gets a placeholder
|
||||||
|
// rather than going blank.
|
||||||
|
paintCardSlot(slot, card) {
|
||||||
|
const value = (card || "").toUpperCase();
|
||||||
|
slot.classList.toggle("cardRed", this.isRedSuit(value));
|
||||||
|
slot.classList.toggle("cardSlotEmpty", !value);
|
||||||
|
slot.textContent = value ? this.formatCard(value) : "+";
|
||||||
|
},
|
||||||
|
|
||||||
|
// A chair nobody has claimed: no name typed, not dealt in, nothing in the pot.
|
||||||
|
// Worth calling out, because at a short-handed table the open seats are what
|
||||||
|
// the dealer is looking for.
|
||||||
|
isEmptySeat(player) {
|
||||||
|
const named = !!(player.name && player.name.trim());
|
||||||
|
const contributing =
|
||||||
|
HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount) > 0;
|
||||||
|
return !named && !player.inHand && !contributing;
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePuckContent(node, player, index) {
|
||||||
|
const empty = this.isEmptySeat(player);
|
||||||
|
const nameEl = node.querySelector(".seatPuckName");
|
||||||
|
nameEl.textContent = empty ? "Empty" : (player.name || "").trim();
|
||||||
|
nameEl.classList.toggle("seatEmptyLabel", empty);
|
||||||
|
|
||||||
|
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 = this.formatCard(card);
|
||||||
|
cardsEl.appendChild(span);
|
||||||
|
});
|
||||||
|
// What's in front of them this street reads as the live number during betting;
|
||||||
|
// between hands it falls back to their total for the hand.
|
||||||
|
const streetBet = Betting.committed(player);
|
||||||
|
const total = streetBet > 0
|
||||||
|
? streetBet
|
||||||
|
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
||||||
|
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
|
||||||
|
|
||||||
|
const stackEl = node.querySelector(".seatPuckStack");
|
||||||
|
const stack = Betting.stackOf(this.state, player);
|
||||||
|
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
|
||||||
|
|
||||||
|
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
||||||
|
node.classList.toggle("seatEmpty", empty);
|
||||||
|
node.classList.toggle("seatInactive", !player.inHand && !empty);
|
||||||
|
node.classList.toggle("seatFolded", player.folded);
|
||||||
|
node.classList.toggle("seatLive", player.inHand && !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);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Seat gestures ----
|
||||||
|
//
|
||||||
|
// One puck, three intents: tap to edit, double-tap to fold, press and hold for
|
||||||
|
// the quick menu. The single tap waits out the double-tap window before it
|
||||||
|
// fires — a fifth of a second of latency on opening a form, in exchange for a
|
||||||
|
// fold that doesn't also fling a sheet up over the table.
|
||||||
|
|
||||||
|
LONG_PRESS_MS: 500,
|
||||||
|
DOUBLE_TAP_MS: 220,
|
||||||
|
DRAG_SLOP_PX: 8,
|
||||||
|
pendingTap: null,
|
||||||
|
|
||||||
|
wireSeatGestures(node, index) {
|
||||||
|
let longPressTimer = null;
|
||||||
|
let longPressFired = false;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
|
||||||
|
const cancelLongPress = () => {
|
||||||
|
if (longPressTimer) {
|
||||||
|
clearTimeout(longPressTimer);
|
||||||
|
longPressTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
node.addEventListener("pointerdown", e => {
|
||||||
|
longPressFired = false;
|
||||||
|
startX = e.clientX;
|
||||||
|
startY = e.clientY;
|
||||||
|
cancelLongPress();
|
||||||
|
longPressTimer = setTimeout(() => {
|
||||||
|
longPressTimer = null;
|
||||||
|
longPressFired = true;
|
||||||
|
this.cancelPendingTap();
|
||||||
|
node.classList.remove("seatPressing");
|
||||||
|
this.hooks.onSeatLongPress(index);
|
||||||
|
}, this.LONG_PRESS_MS);
|
||||||
|
node.classList.add("seatPressing");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Moving means they're dragging the seat, not holding it.
|
||||||
|
node.addEventListener("pointermove", e => {
|
||||||
|
if (Math.abs(e.clientX - startX) > this.DRAG_SLOP_PX || Math.abs(e.clientY - startY) > this.DRAG_SLOP_PX) {
|
||||||
|
cancelLongPress();
|
||||||
|
node.classList.remove("seatPressing");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
["pointerup", "pointercancel", "pointerleave"].forEach(type => {
|
||||||
|
node.addEventListener(type, () => {
|
||||||
|
cancelLongPress();
|
||||||
|
node.classList.remove("seatPressing");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
node.addEventListener("dragstart", () => {
|
||||||
|
cancelLongPress();
|
||||||
|
this.cancelPendingTap();
|
||||||
|
node.classList.remove("seatPressing");
|
||||||
|
});
|
||||||
|
|
||||||
|
node.addEventListener("click", e => {
|
||||||
|
// The long press already did something; don't also treat the release as a tap.
|
||||||
|
if (longPressFired) {
|
||||||
|
longPressFired = false;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.handleSeatTap(index);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelPendingTap() {
|
||||||
|
if (this.pendingTap) {
|
||||||
|
clearTimeout(this.pendingTap.timer);
|
||||||
|
this.pendingTap = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSeatTap(index) {
|
||||||
|
if (this.pendingTap && this.pendingTap.index === index) {
|
||||||
|
this.cancelPendingTap();
|
||||||
|
this.hooks.onSeatDoubleTap(index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cancelPendingTap();
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pendingTap = null;
|
||||||
|
this.toggleSeatEditor(index);
|
||||||
|
}, this.DOUBLE_TAP_MS);
|
||||||
|
this.pendingTap = { index, timer };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tapping the open seat again closes it — on a phone the editor is a sheet over
|
||||||
|
// the table, and the seat you just tapped is the closest thing to hand for
|
||||||
|
// dismissing it.
|
||||||
|
toggleSeatEditor(index) {
|
||||||
|
this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
|
||||||
|
this.renderSeats();
|
||||||
|
this.renderSeatEditor();
|
||||||
|
},
|
||||||
|
|
||||||
|
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) ----
|
||||||
|
|
||||||
|
closeSeatEditor() {
|
||||||
|
this.selectedSeatIndex = null;
|
||||||
|
this.renderSeats();
|
||||||
|
this.renderSeatEditor();
|
||||||
|
},
|
||||||
|
|
||||||
|
renderSeatEditor() {
|
||||||
|
const title = document.getElementById("seatEditorTitle");
|
||||||
|
const body = document.getElementById("seatEditorBody");
|
||||||
|
|
||||||
|
// Drives the mobile bottom sheet: the panel only slides up over the table
|
||||||
|
// while a seat is actually being edited.
|
||||||
|
document.body.classList.toggle("seatSheetOpen", this.selectedSeatIndex !== null);
|
||||||
|
|
||||||
|
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 slots = [form.querySelector(".card1Slot"), form.querySelector(".card2Slot")];
|
||||||
|
slots.forEach((slot, cardIdx) => {
|
||||||
|
this.paintCardSlot(slot, player.cards[cardIdx]);
|
||||||
|
slot.addEventListener("click", () => {
|
||||||
|
CardPicker.open(this.state, {
|
||||||
|
onPick: (seatIndex, pickedIdx, card) => {
|
||||||
|
this.paintCardSlot(slots[pickedIdx], card);
|
||||||
|
this.commitSeatChange(form, player, seatIndex);
|
||||||
|
},
|
||||||
|
}, index, cardIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const stackInput = form.querySelector(".stackInput");
|
||||||
|
stackInput.value = player.stack ?? "";
|
||||||
|
stackInput.addEventListener("input", () => {
|
||||||
|
player.stack = stackInput.value === "" ? null : Number(stackInput.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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = { Render };
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// The long-press menu on a seat. Everything a dealer does to one player mid-hand,
|
||||||
|
// one press and one tap away, without opening the full editor and without hunting
|
||||||
|
// for whose turn it is in the action bar.
|
||||||
|
const SeatMenu = {
|
||||||
|
state: null,
|
||||||
|
hooks: null,
|
||||||
|
seatIndex: null,
|
||||||
|
|
||||||
|
open(state, hooks, seatIndex) {
|
||||||
|
this.state = state;
|
||||||
|
this.hooks = hooks;
|
||||||
|
this.seatIndex = seatIndex;
|
||||||
|
this.render();
|
||||||
|
document.getElementById("seatMenuOverlay").hidden = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
close() {
|
||||||
|
document.getElementById("seatMenuOverlay").hidden = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Only the actions that make sense for this seat right now — a folded player has
|
||||||
|
// nothing to go all-in with, and an empty chair has nothing to fold.
|
||||||
|
actionsFor(player, index) {
|
||||||
|
const actions = [];
|
||||||
|
const dealtIn = player.inHand;
|
||||||
|
const live = dealtIn && !player.folded;
|
||||||
|
|
||||||
|
if (live) {
|
||||||
|
actions.push({ label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
|
||||||
|
actions.push({ label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
|
||||||
|
} else if (dealtIn && player.folded) {
|
||||||
|
actions.push({ label: "Un-fold", run: () => this.hooks.unfold(index) });
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push({
|
||||||
|
label: dealtIn ? "Sit out" : "Deal in",
|
||||||
|
run: () => this.hooks.toggleDealtIn(index),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
|
||||||
|
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
||||||
|
return actions;
|
||||||
|
},
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const player = this.state.seats[this.seatIndex];
|
||||||
|
document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, this.seatIndex);
|
||||||
|
|
||||||
|
const stack = Betting.stackOf(this.state, player);
|
||||||
|
const owed = Betting.toCall(this.state, player);
|
||||||
|
const bits = [];
|
||||||
|
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
|
||||||
|
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
|
||||||
|
document.getElementById("seatMenuSubtitle").textContent = bits.join(" · ");
|
||||||
|
|
||||||
|
const list = document.getElementById("seatMenuActions");
|
||||||
|
list.innerHTML = "";
|
||||||
|
this.actionsFor(player, this.seatIndex).forEach(action => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = `seatMenuBtn ${action.className || ""}`;
|
||||||
|
btn.textContent = action.label;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
this.close();
|
||||||
|
action.run();
|
||||||
|
});
|
||||||
|
list.appendChild(btn);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = SeatMenu;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Pure math for chopping one pot between tied players. No DOM.
|
||||||
|
//
|
||||||
|
// A dealer can't push half a chip across the felt, so this works in whole chips of
|
||||||
|
// whatever the smallest denomination in play is: everyone gets the same stack, and
|
||||||
|
// whatever won't divide is called out separately as odd chips. House rule for those
|
||||||
|
// is that they go to the first player left of the button, one at a time.
|
||||||
|
const SplitCalculator = {
|
||||||
|
// Everything is done in cents to keep 0.1 + 0.2 out of a payout.
|
||||||
|
toCents(amount) {
|
||||||
|
return Math.round((Number(amount) || 0) * 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
split(potAmount, ways, chipAmount) {
|
||||||
|
const potCents = this.toCents(potAmount);
|
||||||
|
const chipCents = Math.max(1, this.toCents(chipAmount));
|
||||||
|
const players = Math.max(1, Math.floor(Number(ways) || 0));
|
||||||
|
|
||||||
|
if (potCents <= 0) {
|
||||||
|
return { valid: false, each: 0, oddChips: 0, chip: chipCents / 100, dust: 0, players };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wholeChips = Math.floor(potCents / chipCents);
|
||||||
|
// Left over when the pot itself isn't a whole number of the smallest chip —
|
||||||
|
// usually means the dealer picked the wrong denomination, so it's surfaced
|
||||||
|
// rather than silently rolled into someone's stack.
|
||||||
|
const dustCents = potCents - wholeChips * chipCents;
|
||||||
|
|
||||||
|
const chipsEach = Math.floor(wholeChips / players);
|
||||||
|
const oddChips = wholeChips - chipsEach * players;
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
players,
|
||||||
|
each: (chipsEach * chipCents) / 100,
|
||||||
|
oddChips,
|
||||||
|
chip: chipCents / 100,
|
||||||
|
dust: dustCents / 100,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sanity check for the UI: what the split actually hands out has to equal the pot.
|
||||||
|
accountedFor(result) {
|
||||||
|
return (
|
||||||
|
Math.round(result.each * 100) * result.players +
|
||||||
|
Math.round(result.chip * 100) * result.oddChips +
|
||||||
|
Math.round(result.dust * 100)
|
||||||
|
) / 100;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = SplitCalculator;
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// 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: [],
|
||||||
|
// Live-betting fields: chips in front of the player, chips they've put in
|
||||||
|
// on the current street, and whether they've answered the current bet.
|
||||||
|
stack: null,
|
||||||
|
committed: 0,
|
||||||
|
hasActed: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
smallBlind: 1,
|
||||||
|
bigBlind: 2,
|
||||||
|
defaultStack: null,
|
||||||
|
street: "preflop",
|
||||||
|
currentBet: 0,
|
||||||
|
lastRaiseSize: 0,
|
||||||
|
actingSeatIndex: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
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 = [];
|
||||||
|
|
||||||
|
// Tables saved before live betting existed are missing its fields; fill
|
||||||
|
// them in rather than making the dealer reset a table mid-session.
|
||||||
|
const defaults = this.createDefault();
|
||||||
|
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize", "actingSeatIndex"]
|
||||||
|
.forEach(key => {
|
||||||
|
if (parsed[key] === undefined) parsed[key] = defaults[key];
|
||||||
|
});
|
||||||
|
parsed.seats.forEach(player => {
|
||||||
|
if (player.stack === undefined) player.stack = null;
|
||||||
|
if (player.committed === undefined) player.committed = 0;
|
||||||
|
if (player.hasActed === undefined) player.hasActed = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
p.committed = 0;
|
||||||
|
p.hasActed = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -103,9 +103,13 @@ CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle trainer);
|
|||||||
/* Record one played position (post-move FEN) into the accumulator. */
|
/* Record one played position (post-move FEN) into the accumulator. */
|
||||||
CHESS_API void CHESS_CALL trainer_record(TrainerHandle trainer, const char* fen);
|
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
|
/* Apply a finished game's outcome to the global weights and save. Each touched cell (a piece
|
||||||
* occupied squares / features and punishes the loser's, scaled by `weight` (e.g. 0.5 for a
|
* on a square, or a feature) tracks a presence-weighted running average of the game's
|
||||||
* material-imbalance draw). winner: 0 = white, 1 = black. */
|
* "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);
|
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle trainer, int winner, double weight);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
|
#include <iomanip>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <new>
|
#include <new>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -115,11 +116,25 @@ struct ChessEngine {
|
|||||||
/* The process-global learned weights: the single source of truth, loaded from disk once and
|
/* 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
|
* 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
|
* 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 {
|
struct LearnedWeights {
|
||||||
int mg[chess::PIECE_TYPE_NB][64] = {};
|
int mg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
int eg[chess::PIECE_TYPE_NB][64] = {};
|
int eg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
int featW[FEATURE_NB] = {};
|
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;
|
static LearnedWeights g_weights;
|
||||||
@@ -127,13 +142,15 @@ static std::mutex g_weightsMutex;
|
|||||||
static std::string g_weightsPath;
|
static std::string g_weightsPath;
|
||||||
|
|
||||||
/* Per-game training accumulator (one per learned CPU-vs-CPU game). Records, per ply, where
|
/* 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
|
* each side's pieces sat (split into midgame/endgame by phase), each side's feature
|
||||||
* activations; trainer_apply turns the totals into weight nudges. Squares are white-relative
|
* 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. */
|
* (black indexes sq ^ 56), so a side's tally lines up with the shared white-relative table. */
|
||||||
struct Trainer {
|
struct Trainer {
|
||||||
double mgOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
|
double mgOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
|
||||||
double egOcc[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 featAcc[chess::COLOR_NB][FEATURE_NB] = {};
|
||||||
|
double matDiffAcc = 0.0; /* sum over plies of (white material - black material), cp */
|
||||||
int plies = 0;
|
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
|
/* 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
|
* FEATURE_NB feature ints, then the backing visits/score doubles for each (mgVisits, mgScore,
|
||||||
* rest neutral (0), so an absent weights file just means "train from a blank slate".
|
* egVisits, egScore, featVisits, featScore) in the same shapes, all whitespace-separated. A
|
||||||
* Caller holds g_weightsMutex. */
|
* 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) {
|
static void load_global_weights(const char* path) {
|
||||||
g_weights = LearnedWeights{}; /* reset to neutral before loading */
|
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;
|
if (!(f >> g_weights.eg[pt][sq])) return;
|
||||||
for (int i = 0; i < FEATURE_NB; ++i)
|
for (int i = 0; i < FEATURE_NB; ++i)
|
||||||
if (!(f >> g_weights.featW[i])) return;
|
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() {
|
static void save_global_weights() {
|
||||||
if (g_weightsPath.empty()) return;
|
if (g_weightsPath.empty()) return;
|
||||||
std::ofstream f(g_weightsPath);
|
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 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 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' : ' ');
|
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
|
/* 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
|
* 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. */
|
* 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
|
/* Per-game-outcome learning constants. Squares and features both learn a presence/activation-
|
||||||
* square, summed); features accumulate normalized per-ply activation (averaged, divided by a
|
* weighted AVERAGE advantage in [-1,1] (see trainer_apply) — OUT_SCALE just converts that
|
||||||
* nominal scale so high-magnitude mobility doesn't dwarf the small pawn-structure terms). */
|
* average into a centipawn-ish int for the eval/table to add directly. FEAT_SCALE normalizes
|
||||||
static constexpr double SQUARE_LR = 0.5;
|
* each feature's raw per-ply activation to a comparable magnitude before it's used as a weight
|
||||||
static constexpr int SQ_CLAMP = 250;
|
* (so high-count mobility doesn't dwarf the small pawn-structure terms), same as before.
|
||||||
static constexpr double FEAT_LR = 2.0;
|
* MATERIAL_ELO_SCALE is the classic centipawn-to-win-probability curve
|
||||||
static constexpr int FEAT_CLAMP = 500;
|
* (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 };
|
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
|
/* 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);
|
chess::Position pos = chess::Position::from_fen(fen);
|
||||||
double phase = game_phase(pos);
|
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();
|
chess::Bitboard occ = pos.pieces();
|
||||||
while (occ) {
|
while (occ) {
|
||||||
chess::Square s = chess::pop_lsb(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);
|
int relSq = (c == chess::WHITE) ? int(s) : (int(s) ^ 56);
|
||||||
t->mgOcc[c][pt][relSq] += (1.0 - phase);
|
t->mgOcc[c][pt][relSq] += (1.0 - phase);
|
||||||
t->egOcc[c][pt][relSq] += phase;
|
t->egOcc[c][pt][relSq] += phase;
|
||||||
|
t->matDiffAcc += (c == chess::WHITE ? 1 : -1) * piece_value(pt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Per-side feature activations. */
|
/* Per-side feature activations. */
|
||||||
@@ -847,34 +907,66 @@ CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
|
|||||||
t->plies++;
|
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) {
|
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);
|
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) {
|
for (int pass = 0; pass < 2; ++pass) {
|
||||||
chess::Color side = chess::Color((pass == 0 ? winner : (winner ^ 1)) & 1);
|
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 pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
for (int sq = 0; sq < 64; ++sq) {
|
for (int sq = 0; sq < 64; ++sq) {
|
||||||
if (t->mgOcc[side][pt][sq] != 0.0) {
|
double mgFrac = t->mgOcc[side][pt][sq] / t->plies; /* fraction of the game spent here, [0,1] */
|
||||||
int d = sign * int(std::lround(SQUARE_LR * t->mgOcc[side][pt][sq] * weight));
|
if (mgFrac != 0.0) {
|
||||||
g_weights.mg[pt][sq] = std::clamp(g_weights.mg[pt][sq] + d, -SQ_CLAMP, SQ_CLAMP);
|
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) {
|
double egFrac = t->egOcc[side][pt][sq] / t->plies;
|
||||||
int d = sign * int(std::lround(SQUARE_LR * t->egOcc[side][pt][sq] * weight));
|
if (egFrac != 0.0) {
|
||||||
g_weights.eg[pt][sq] = std::clamp(g_weights.eg[pt][sq] + d, -SQ_CLAMP, SQ_CLAMP);
|
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) {
|
||||||
for (int i = 0; i < FEATURE_NB; ++i) {
|
double activation = (t->featAcc[side][i] / t->plies) / FEAT_SCALE[i]; /* per-ply average, normalized */
|
||||||
double avg = t->featAcc[side][i] / t->plies; /* per-ply average, normalized */
|
if (activation == 0.0) continue;
|
||||||
int d = sign * int(std::lround(FEAT_LR * (avg / FEAT_SCALE[i]) * weight));
|
g_weights.featVisits[i] += std::abs(activation);
|
||||||
g_weights.featW[i] = std::clamp(g_weights.featW[i] + d, -FEAT_CLAMP, FEAT_CLAMP);
|
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();
|
save_global_weights();
|
||||||
|
|||||||
Reference in New Issue
Block a user