Compare commits
173
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e2821257f | ||
|
|
c19575ab10 | ||
|
|
8aa90e90a4 | ||
|
|
dc5db16ffd | ||
|
|
b9b2237dbe | ||
|
|
fc866597eb | ||
|
|
b97453fba1 | ||
|
|
419852a1f4 | ||
|
|
8a2fc3e540 | ||
|
|
b64da7a787 | ||
|
|
3be93cdadf | ||
|
|
40fa16920b | ||
|
|
09eac536b5 | ||
|
|
8efc14f61f | ||
|
|
c70cb150db | ||
|
|
e9bc9afcdb | ||
|
|
9f3d0dce4b | ||
|
|
01f5139740 | ||
|
|
c76ebf4d18 | ||
|
|
cc060b29b8 | ||
|
|
3149b48c14 | ||
|
|
cb057b5159 | ||
|
|
ab92259e3e | ||
|
|
65f201cdfd | ||
|
|
d297c1a2c9 | ||
|
|
f0cfd24647 | ||
|
|
95fd46ee99 | ||
|
|
4cc8faf8cb | ||
|
|
cfb83a2ba6 | ||
|
|
4071be5329 | ||
|
|
8a7d7b09bf | ||
|
|
ebf22e3153 | ||
|
|
f0994a8aed | ||
|
|
ddb3907ace | ||
|
|
3fe16fa7f2 | ||
|
|
f199148046 | ||
|
|
38536a5c0c | ||
|
|
286a924fe4 | ||
|
|
3e9e2e899d | ||
|
|
96cd7684f0 | ||
|
|
1af82dfddf | ||
|
|
2cc2c66976 | ||
|
|
a92a389245 | ||
|
|
3d1ce1f45c | ||
|
|
29188d6049 | ||
|
|
4c1ac222a8 | ||
|
|
784315f2ea | ||
|
|
0c6cd55951 | ||
|
|
e01d8e7c49 | ||
|
|
c77161c3e9 | ||
|
|
843ef9cd42 | ||
|
|
e5d5b515ba | ||
|
|
a14087fa7d | ||
|
|
76f1952d61 | ||
|
|
f464e6f2e2 | ||
|
|
f5108de288 | ||
|
|
ee37cf7bd9 | ||
|
|
0d1218fa53 | ||
|
|
a72f199139 | ||
|
|
e0eb0d2079 | ||
|
|
d66abda05a | ||
|
|
6fa4532781 | ||
|
|
2f6d63400a | ||
|
|
0d6e7e0b5a | ||
|
|
aeb02d023d | ||
|
|
60d192a9ec | ||
|
|
cc6a1902bc | ||
|
|
a488bbf832 | ||
|
|
9ee4816fa4 | ||
|
|
db208a1a86 | ||
|
|
3d63ba50ee | ||
|
|
073ba9a252 | ||
|
|
c1a484b97a | ||
|
|
6117886e70 | ||
|
|
ae8744672f | ||
|
|
328ef56af5 | ||
|
|
7714e16547 | ||
|
|
cc31cd033b | ||
|
|
f324793732 | ||
|
|
56d9d740b5 | ||
|
|
e5b02b00e4 | ||
|
|
34cd7dbc59 | ||
|
|
4f72dda6fa | ||
|
|
cc1b5011a1 | ||
|
|
18bb23ad6d | ||
|
|
ae591e4212 | ||
|
|
89d79a0f7d | ||
|
|
d0f75d0c14 | ||
|
|
6b3a7dc09d | ||
|
|
38488f2978 | ||
|
|
7c64749267 | ||
|
|
e8d6098d39 | ||
|
|
5d1a6b7399 | ||
|
|
a816b32426 | ||
|
|
5271d5fbad | ||
|
|
552ec1a542 | ||
|
|
5c807d1581 | ||
|
|
a5b9c26902 | ||
|
|
9a5b974fda | ||
|
|
b563a56563 | ||
|
|
72ee2fb2b7 | ||
|
|
ae927983e7 | ||
|
|
4ceeed71a2 | ||
|
|
95c523738c | ||
|
|
1fcc058dd8 | ||
|
|
ee25e2a4fa | ||
|
|
dabf1100c9 | ||
|
|
0759d89d13 | ||
|
|
01a1728971 | ||
|
|
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 | ||
|
|
c048529654 | ||
|
|
e8eb90a085 | ||
|
|
fc167ae2b0 | ||
|
|
088f05e0d2 | ||
|
|
04dcae7993 | ||
|
|
f956f4bffa | ||
|
|
a53b0c2e43 | ||
|
|
edc4d70678 | ||
|
|
3bfcbaa190 | ||
|
|
59b318b75c | ||
|
|
e5e0c9cc48 | ||
|
|
3947bd0daf | ||
|
|
b99525ae51 | ||
|
|
9da7b1986b | ||
|
|
a0b8c2602a | ||
|
|
7604798e68 | ||
|
|
02163a4fc4 | ||
|
|
1680d13b05 | ||
|
|
a1e2ad9c2d | ||
|
|
26b4c0e59b | ||
|
|
aeef320cb4 | ||
|
|
d71e485611 | ||
|
|
d09a067377 | ||
|
|
acbee436bb | ||
|
|
d452ee414b | ||
|
|
f09ca60315 | ||
|
|
743c395e11 | ||
|
|
62e146648c | ||
|
|
6402ef268c | ||
|
|
c92c46e6d4 | ||
|
|
e8a3bfe432 | ||
|
|
8b78fd589c | ||
|
|
7bfbae7301 | ||
|
|
9b69489c4e |
@@ -0,0 +1,97 @@
|
|||||||
|
# .gitea/workflows/deploy.yml
|
||||||
|
name: Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "master" ]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-deploy:
|
||||||
|
# Gitea's runner images are lean, so the toolchain is installed below rather
|
||||||
|
# than assumed. The glibc that libchess_engine.so links against comes from
|
||||||
|
# this image, not from the runner host — keep it matched to the prod server.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install toolchain
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
cmake build-essential rsync openssh-client
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj
|
||||||
|
|
||||||
|
- name: Build native chess engine (libchess_engine.so)
|
||||||
|
run: |
|
||||||
|
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build native/chess_engine/build
|
||||||
|
cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/
|
||||||
|
|
||||||
|
# Publishing the project rather than the solution keeps chess_engine.vcxproj
|
||||||
|
# (Windows-only MSBuild C++ targets) and the UiTests assemblies out of it.
|
||||||
|
- name: Publish
|
||||||
|
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
|
||||||
|
|
||||||
|
- name: Verify payload
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -f publish/Resources/libchess_engine.so \
|
||||||
|
|| { echo "libchess_engine.so missing from publish output"; exit 1; }
|
||||||
|
test -x publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt \
|
||||||
|
|| { echo "stockfish is not executable"; exit 1; }
|
||||||
|
|
||||||
|
- name: Prepare SSH
|
||||||
|
env:
|
||||||
|
SSH_KEY: ${{ secrets.SSH_KEY }}
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PORT="${SSH_PORT:-22}"
|
||||||
|
install -m 700 -d ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
ssh-keyscan -p "$PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
# --exclude chess-data: never let --delete remove the learned-engine training
|
||||||
|
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
|
||||||
|
- name: Rsync to server
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
TARGET_DIR: ${{ secrets.TARGET_DIR }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PORT="${SSH_PORT:-22}"
|
||||||
|
rsync -az --delete --exclude 'chess-data' \
|
||||||
|
-e "ssh -p $PORT -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes" \
|
||||||
|
publish/ "$SSH_USER@$SSH_HOST:$TARGET_DIR/"
|
||||||
|
|
||||||
|
- name: Reload and restart service
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SERVICE_NAME: ${{ secrets.SERVICE_NAME }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PORT="${SSH_PORT:-22}"
|
||||||
|
ssh -p "$PORT" -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
|
||||||
|
"sudo systemctl daemon-reload \
|
||||||
|
&& sudo systemctl restart '$SERVICE_NAME' \
|
||||||
|
&& systemctl --no-pager status '$SERVICE_NAME' --lines=0"
|
||||||
@@ -54,6 +54,12 @@ jobs:
|
|||||||
name: site-publish
|
name: site-publish
|
||||||
path: publish
|
path: publish
|
||||||
|
|
||||||
|
# GitHub artifacts don't preserve the Unix executable bit, so Stockfish (the only file
|
||||||
|
# the app spawns as a subprocess) arrives non-executable. Restore 755 here; rsync -a then
|
||||||
|
# carries it to the server, where the service user can run it regardless of file owner.
|
||||||
|
- name: Restore Stockfish executable bit
|
||||||
|
run: chmod 755 publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt
|
||||||
|
|
||||||
- name: Prepare SSH
|
- name: Prepare SSH
|
||||||
run: |
|
run: |
|
||||||
install -m 700 -d ~/.ssh
|
install -m 700 -d ~/.ssh
|
||||||
@@ -63,7 +69,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Rsync to server
|
- name: Rsync to server
|
||||||
run: |
|
run: |
|
||||||
rsync -az --delete -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
|
# --exclude chess-data: never let --delete remove the learned-engine training
|
||||||
|
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
|
||||||
|
rsync -az --delete --exclude 'chess-data' -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
|
||||||
publish/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.TARGET_DIR }}/
|
publish/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.TARGET_DIR }}/
|
||||||
|
|
||||||
- name: Reload and restart service
|
- name: Reload and restart service
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Unit tests for the Texas Hold'em dealer's money logic.
|
||||||
|
#
|
||||||
|
# The betting engine, the side-pot calculator and the table state are plain script-tag
|
||||||
|
# modules with no build step, so they run directly under Node's built-in test runner —
|
||||||
|
# no package.json, no install step, nothing to keep up to date.
|
||||||
|
|
||||||
|
name: Hold'em tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ "master" ]
|
||||||
|
push:
|
||||||
|
branches: [ "master" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: Run Hold'em unit tests
|
||||||
|
run: node --test tests/holdem/*.test.js
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
##
|
##
|
||||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||||
|
|
||||||
|
# Learned chess engine weights (runtime training output, not source)
|
||||||
|
chess-data/
|
||||||
|
|
||||||
# User-specific files
|
# User-specific files
|
||||||
*.rsuser
|
*.rsuser
|
||||||
*.suo
|
*.suo
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
using JoshHeaps.Net.Hubs;
|
using JoshHeaps.Net.Hubs;
|
||||||
using JoshHeaps.Net.Models;
|
using JoshHeaps.Net.Models;
|
||||||
using JoshHeaps.Net.Services.Implementations;
|
using JoshHeaps.Net.Services.Implementations;
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Controllers;
|
namespace JoshHeaps.Net.Controllers;
|
||||||
|
|
||||||
@@ -15,58 +14,60 @@ public class ChessController(
|
|||||||
IBackgroundTaskQueue queue,
|
IBackgroundTaskQueue queue,
|
||||||
IChessEngineFactory engineFactory,
|
IChessEngineFactory engineFactory,
|
||||||
IComputerMoveOrchestrator orchestrator,
|
IComputerMoveOrchestrator orchestrator,
|
||||||
|
ILearnedWeightsStore weightsStore,
|
||||||
|
IGameStore gameStore,
|
||||||
|
ISelfPlayCoordinator selfPlay,
|
||||||
|
AutoTrainingSettings autoTraining,
|
||||||
IHubContext<ChessHub> chessHub) : ControllerBase
|
IHubContext<ChessHub> chessHub) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Store of ongoing games.
|
|
||||||
/// </summary>
|
|
||||||
private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
|
||||||
private static readonly ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
|
|
||||||
private static readonly ConcurrentDictionary<Guid, CancellationTokenSource> _gameRemovalCancellationTokens = [];
|
|
||||||
|
|
||||||
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||||
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
|
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
|
||||||
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
|
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
|
||||||
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
|
|
||||||
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new chess game and store it in-memory.
|
/// Create a new chess game and store it in-memory.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("new")]
|
[HttpGet("new")]
|
||||||
[HttpGet("new/{difficulty}")]
|
[HttpGet("new/{difficulty}")]
|
||||||
public ActionResult CreateGame(int difficulty = 20)
|
public ActionResult CreateGame(int difficulty = 20, string color = "random")
|
||||||
{
|
{
|
||||||
var gameState = chessService.CreateNewGame();
|
var gameState = chessService.CreateNewGame();
|
||||||
_games[gameState.GameId] = gameState;
|
gameStore.Add(gameState);
|
||||||
|
|
||||||
gameState.IsVsComputer = true;
|
gameState.IsVsComputer = true;
|
||||||
gameState.WhiteJoined = true;
|
gameState.WhiteJoined = true;
|
||||||
gameState.BlackJoined = true;
|
gameState.BlackJoined = true;
|
||||||
Guid playerId = Guid.NewGuid();
|
Guid playerId = Guid.NewGuid();
|
||||||
Guid computerId = Guid.NewGuid();
|
Guid computerId = Guid.NewGuid();
|
||||||
var isWhite = Random.Shared.Next(2) == 0;
|
var isWhite = color.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"white" => true,
|
||||||
|
"black" => false,
|
||||||
|
_ => Random.Shared.Next(2) == 0,
|
||||||
|
};
|
||||||
|
|
||||||
gameState.Computer = engineFactory.Create(difficulty);
|
var computer = engineFactory.Create(difficulty);
|
||||||
|
|
||||||
if (isWhite)
|
if (isWhite)
|
||||||
{
|
{
|
||||||
gameState.WhitePlayerId = playerId;
|
gameState.WhitePlayerId = playerId;
|
||||||
gameState.BlackPlayerId = computerId;
|
gameState.BlackPlayerId = computerId;
|
||||||
|
gameState.BlackComputer = computer;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
gameState.WhitePlayerId = computerId;
|
gameState.WhitePlayerId = computerId;
|
||||||
gameState.BlackPlayerId = playerId;
|
gameState.BlackPlayerId = playerId;
|
||||||
|
gameState.WhiteComputer = computer;
|
||||||
queue.Queue(async () =>
|
queue.Queue(async () =>
|
||||||
{
|
{
|
||||||
// Give user's browser time to connect to signalR and such.
|
// Give user's browser time to connect to signalR and such.
|
||||||
await Task.Delay(TimeSpan.FromSeconds(1));
|
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||||
await orchestrator.PlayAsync(gameState, gameState.Computer!);
|
await orchestrator.PlayAsync(gameState);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -77,28 +78,52 @@ public class ChessController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a game the computer plays against itself and auto-play it move by move,
|
/// Create a computer-vs-computer game and auto-play it move by move, broadcasting each
|
||||||
/// broadcasting each move so it can be watched on the spectator page.
|
/// move so it can be watched on the spectator page. Each side's engine and skill can be
|
||||||
|
/// chosen independently; when the learned engine plays, the game also trains it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("watch/cpu")]
|
[HttpGet("watch/cpu")]
|
||||||
[HttpGet("watch/cpu/{difficulty}")]
|
[HttpGet("watch/cpu/{difficulty}")]
|
||||||
public ActionResult CreateSelfPlayGame(int difficulty = 4)
|
public ActionResult CreateSelfPlayGame(
|
||||||
|
int difficulty = 4,
|
||||||
|
string whiteEngine = "custom",
|
||||||
|
string blackEngine = "custom",
|
||||||
|
int? whiteSkill = null,
|
||||||
|
int? blackSkill = null)
|
||||||
{
|
{
|
||||||
var gameState = chessService.CreateNewGame();
|
var config = new SelfPlayConfig(
|
||||||
_games[gameState.GameId] = gameState;
|
ParseEngineKind(whiteEngine), whiteSkill ?? difficulty,
|
||||||
|
ParseEngineKind(blackEngine), blackSkill ?? difficulty);
|
||||||
|
|
||||||
gameState.IsVsComputer = true;
|
var (gameId, _) = selfPlay.StartGame(config);
|
||||||
gameState.IsComputerVsComputer = true;
|
|
||||||
gameState.WhiteJoined = true;
|
|
||||||
gameState.BlackJoined = true;
|
|
||||||
gameState.WhitePlayerId = Guid.NewGuid();
|
|
||||||
gameState.BlackPlayerId = Guid.NewGuid();
|
|
||||||
gameState.Computer = engineFactory.Create(difficulty);
|
|
||||||
|
|
||||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
return Ok(new { GameId = gameId });
|
||||||
StartSelfPlay(gameState);
|
}
|
||||||
|
|
||||||
return Ok(new { gameState.GameId });
|
private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"stockfish" => ChessEngineKind.Stockfish,
|
||||||
|
"customlearned" or "learned" => ChessEngineKind.CustomLearned,
|
||||||
|
_ => ChessEngineKind.Custom
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current number of background auto-training games (and the allowed maximum). Auto-training
|
||||||
|
/// itself runs only outside Development; this reflects the target the service is keeping.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("autotrain")]
|
||||||
|
public ActionResult GetAutoTrain() =>
|
||||||
|
Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set how many auto-training games run concurrently (clamped to 0..max; 0 pauses training).
|
||||||
|
/// Takes effect live — the background service tops up or drains toward the new count.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("autotrain")]
|
||||||
|
public ActionResult SetAutoTrain([FromQuery] int count)
|
||||||
|
{
|
||||||
|
autoTraining.GameCount = count; // clamped inside the setter
|
||||||
|
return Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -110,12 +135,12 @@ public class ChessController(
|
|||||||
public ActionResult JoinGame()
|
public ActionResult JoinGame()
|
||||||
{
|
{
|
||||||
Console.WriteLine("joining game");
|
Console.WriteLine("joining game");
|
||||||
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen);
|
GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen);
|
||||||
|
|
||||||
if (gameState == null)
|
if (gameState == null)
|
||||||
{
|
{
|
||||||
gameState = chessService.CreateNewGame();
|
gameState = chessService.CreateNewGame();
|
||||||
_games[gameState.GameId] = gameState;
|
gameStore.Add(gameState);
|
||||||
}
|
}
|
||||||
|
|
||||||
Guid playerId = Guid.NewGuid();
|
Guid playerId = Guid.NewGuid();
|
||||||
@@ -133,7 +158,7 @@ public class ChessController(
|
|||||||
isWhite = false;
|
isWhite = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -149,7 +174,7 @@ public class ChessController(
|
|||||||
[HttpGet("active")]
|
[HttpGet("active")]
|
||||||
public ActionResult GetActiveGames()
|
public ActionResult GetActiveGames()
|
||||||
{
|
{
|
||||||
var activeGames = _games.Values
|
var activeGames = gameStore.All
|
||||||
// In-progress games, plus finished computer-vs-computer games still in their result window.
|
// In-progress games, plus finished computer-vs-computer games still in their result window.
|
||||||
.Where(g => g.WhiteJoined && g.BlackJoined
|
.Where(g => g.WhiteJoined && g.BlackJoined
|
||||||
&& ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer))
|
&& ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer))
|
||||||
@@ -158,6 +183,8 @@ public class ChessController(
|
|||||||
g.GameId,
|
g.GameId,
|
||||||
g.IsVsComputer,
|
g.IsVsComputer,
|
||||||
g.IsComputerVsComputer,
|
g.IsComputerVsComputer,
|
||||||
|
WhiteEngine = g.WhiteEngineKind.ToString(),
|
||||||
|
BlackEngine = g.BlackEngineKind.ToString(),
|
||||||
CurrentPlayer = g.CurrentPlayer.ToString(),
|
CurrentPlayer = g.CurrentPlayer.ToString(),
|
||||||
MoveCount = g.MoveHistory.Count,
|
MoveCount = g.MoveHistory.Count,
|
||||||
g.IsCheck
|
g.IsCheck
|
||||||
@@ -167,52 +194,46 @@ public class ChessController(
|
|||||||
return Ok(activeGames);
|
return Ok(activeGames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The learned engine's piece-square bonus table, for the weights-visualization page:
|
||||||
|
/// one 64-entry array per piece type (Pawn..King), white-relative (A1=0 .. H8=63).
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("weights")]
|
||||||
|
public ActionResult GetLearnedWeights()
|
||||||
|
{
|
||||||
|
var names = new[] { "Pawn", "Knight", "Bishop", "Rook", "Queen", "King" };
|
||||||
|
var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Isolated", "Doubled", "King safety" };
|
||||||
|
|
||||||
|
var snapshot = weightsStore.Snapshot();
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
mg = snapshot.Mg.Select((squares, i) => new { name = names[i], squares }),
|
||||||
|
eg = snapshot.Eg.Select((squares, i) => new { name = names[i], squares }),
|
||||||
|
features = snapshot.Features.Select((value, i) => new { name = featureNames[i], value })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get the state of an existing game by ID.
|
/// Get the state of an existing game by ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("{gameId}")]
|
[HttpGet("{gameId}")]
|
||||||
public ActionResult GetGameState(Guid gameId)
|
public ActionResult GetGameState(Guid gameId)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(gameId, out var gameState))
|
if (!gameStore.TryGet(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
var response = new
|
return Ok(gameState.ToDto());
|
||||||
{
|
|
||||||
gameState.GameId,
|
|
||||||
CurrentPlayer = gameState.CurrentPlayer.ToString(),
|
|
||||||
gameState.IsCheck,
|
|
||||||
gameState.IsCheckmate,
|
|
||||||
gameState.IsStalemate,
|
|
||||||
gameState.IsThreefoldRepetition,
|
|
||||||
EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null,
|
|
||||||
gameState.WhiteCanCastleKingside,
|
|
||||||
gameState.WhiteCanCastleQueenside,
|
|
||||||
gameState.BlackCanCastleKingside,
|
|
||||||
gameState.BlackCanCastleQueenside,
|
|
||||||
Pieces = gameState.Pieces
|
|
||||||
.Where(p => p.Position.Row >= 0)
|
|
||||||
.Select(p => new {
|
|
||||||
p.Id,
|
|
||||||
p.Type,
|
|
||||||
p.Color,
|
|
||||||
p.Position.Row,
|
|
||||||
p.Position.Col,
|
|
||||||
p.HasMoved
|
|
||||||
}),
|
|
||||||
gameState.MoveHistory
|
|
||||||
};
|
|
||||||
|
|
||||||
return Ok(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Make a move in the specified game.
|
/// Make a move in the specified game.
|
||||||
/// The test passes a JSON body with a MoveDto.
|
/// The test passes a JSON body with a MoveDto.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("move")]
|
[HttpPost("move")]
|
||||||
public ActionResult MakeMove([FromBody] MoveDto moveDto)
|
public async Task<ActionResult> MakeMove([FromBody] MoveDto moveDto)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(moveDto.GameId, out var gameState))
|
if (!gameStore.TryGet(moveDto.GameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
// Check if player is authorized to move
|
// Check if player is authorized to move
|
||||||
@@ -239,16 +260,28 @@ public class ChessController(
|
|||||||
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
|
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
|
||||||
|
|
||||||
if (isGameOver)
|
if (isGameOver)
|
||||||
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
|
||||||
else if (gameState.IsVsComputer)
|
else if (gameState.IsVsComputer)
|
||||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||||
else
|
else
|
||||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||||
|
|
||||||
if (!isGameOver && gameState.IsVsComputer && gameState.Computer is not null)
|
var state = gameState.ToDto();
|
||||||
queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!));
|
|
||||||
|
|
||||||
return Ok(result);
|
// Broadcast the move (with the full resulting state) to everyone watching this
|
||||||
|
// game. The mover also receives this echo but drops it via the version guard,
|
||||||
|
// since it already rendered the same state from this response.
|
||||||
|
await chessHub.Clients.Group(gameState.GameId.ToString())
|
||||||
|
.SendAsync("ReceiveMoveUpdate", gameState.GameId.ToString(), moveDto, result, state);
|
||||||
|
|
||||||
|
var sideToMoveEngine = gameState.CurrentPlayer == PieceColor.White
|
||||||
|
? gameState.WhiteComputer
|
||||||
|
: gameState.BlackComputer;
|
||||||
|
|
||||||
|
if (!isGameOver && gameState.IsVsComputer && sideToMoveEngine is not null)
|
||||||
|
queue.Queue(() => orchestrator.PlayAsync(gameState));
|
||||||
|
|
||||||
|
return Ok(new { result, state });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -258,7 +291,7 @@ public class ChessController(
|
|||||||
[HttpPost("forfeit")]
|
[HttpPost("forfeit")]
|
||||||
public async Task<ActionResult> Forfeit([FromBody] ForfeitDto forfeit)
|
public async Task<ActionResult> Forfeit([FromBody] ForfeitDto forfeit)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(forfeit.GameId, out var gameState))
|
if (!gameStore.TryGet(forfeit.GameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
|
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
|
||||||
@@ -276,7 +309,7 @@ public class ChessController(
|
|||||||
await chessHub.Clients.Group(gameState.GameId.ToString())
|
await chessHub.Clients.Group(gameState.GameId.ToString())
|
||||||
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
|
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
|
||||||
|
|
||||||
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
@@ -287,7 +320,7 @@ public class ChessController(
|
|||||||
[HttpGet("{gameId}/pgn")]
|
[HttpGet("{gameId}/pgn")]
|
||||||
public ActionResult GetPgn(Guid gameId)
|
public ActionResult GetPgn(Guid gameId)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(gameId, out var gameState))
|
if (!gameStore.TryGet(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
return Content(gameState.ToPgn(), "application/x-chess-pgn");
|
return Content(gameState.ToPgn(), "application/x-chess-pgn");
|
||||||
@@ -299,7 +332,7 @@ public class ChessController(
|
|||||||
[HttpGet("{gameId}/legalMoves/{pieceId}")]
|
[HttpGet("{gameId}/legalMoves/{pieceId}")]
|
||||||
public ActionResult GetLegalMoves(Guid gameId, string pieceId)
|
public ActionResult GetLegalMoves(Guid gameId, string pieceId)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(gameId, out var gameState))
|
if (!gameStore.TryGet(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
|
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
|
||||||
@@ -313,7 +346,7 @@ public class ChessController(
|
|||||||
[HttpGet("{gameId}/legalMoves")]
|
[HttpGet("{gameId}/legalMoves")]
|
||||||
public ActionResult GetAllLegalMoves(Guid gameId)
|
public ActionResult GetAllLegalMoves(Guid gameId)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(gameId, out var gameState))
|
if (!gameStore.TryGet(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
var allMoves = chessService.GetAllLegalMoves(gameState)
|
var allMoves = chessService.GetAllLegalMoves(gameState)
|
||||||
@@ -325,75 +358,4 @@ public class ChessController(
|
|||||||
|
|
||||||
return Ok(allMoves);
|
return Ok(allMoves);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Drives a computer-vs-computer game: keeps asking the engine for the side-to-move's
|
|
||||||
/// move (which applies and broadcasts it) until the game ends or is removed.
|
|
||||||
/// </summary>
|
|
||||||
private void StartSelfPlay(GameState gameState)
|
|
||||||
{
|
|
||||||
queue.Queue(async () =>
|
|
||||||
{
|
|
||||||
// Give spectators a moment to join the SignalR group before the first move.
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(1));
|
|
||||||
|
|
||||||
while (_games.ContainsKey(gameState.GameId)
|
|
||||||
&& !gameState.IsCheckmate
|
|
||||||
&& !gameState.IsStalemate
|
|
||||||
&& !gameState.IsThreefoldRepetition
|
|
||||||
&& !gameState.IsForfeited)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await orchestrator.PlayAsync(gameState, gameState.Computer!);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(_selfPlayMoveDelay);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leave the finished game in place briefly so spectators can see the result.
|
|
||||||
if (_games.ContainsKey(gameState.GameId))
|
|
||||||
ScheduleRemoveGame(gameState.GameId, _selfPlayResultTimeout);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
|
|
||||||
{
|
|
||||||
if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts))
|
|
||||||
{
|
|
||||||
oldCts.Cancel();
|
|
||||||
oldCts.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
var cts = new CancellationTokenSource();
|
|
||||||
_gameRemovalCancellationTokens[id] = cts;
|
|
||||||
|
|
||||||
_gameRemovalTasks[id] = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.Delay(delay, cts.Token);
|
|
||||||
|
|
||||||
if (_games.TryGetValue(id, out var game) && game.Computer is not null)
|
|
||||||
await game.Computer.DisposeAsync();
|
|
||||||
|
|
||||||
_games.Remove(id, out _);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) { }
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (_gameRemovalCancellationTokens.TryGetValue(id, out var currentCts) && currentCts == cts)
|
|
||||||
{
|
|
||||||
_gameRemovalCancellationTokens.TryRemove(id, out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
cts.Dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using JoshHeaps.Net.Models;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Hubs;
|
namespace JoshHeaps.Net.Hubs;
|
||||||
|
|
||||||
@@ -11,11 +10,6 @@ public class ChessHub : Hub
|
|||||||
await Groups.AddToGroupAsync(Context.ConnectionId, gameId);
|
await Groups.AddToGroupAsync(Context.ConnectionId, gameId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MoveMade(string gameId, MoveDto moveDto, MoveResultDto moveResult)
|
|
||||||
{
|
|
||||||
await Clients.OthersInGroup(gameId).SendAsync("ReceiveMoveUpdate", gameId, moveDto, moveResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task LeaveWebsocketGroup(string gameId)
|
public async Task LeaveWebsocketGroup(string gameId)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"❌ Leaving group {gameId}");
|
Console.WriteLine($"❌ Leaving group {gameId}");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using JoshHeaps.Net.Services.Interfaces;
|
using JoshHeaps.Net.Services.Implementations;
|
||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Models;
|
namespace JoshHeaps.Net.Models;
|
||||||
|
|
||||||
@@ -59,7 +60,20 @@ public class GameState
|
|||||||
public bool IsVsComputer { get; set; } = false;
|
public bool IsVsComputer { get; set; } = false;
|
||||||
public bool IsComputerVsComputer { get; set; } = false;
|
public bool IsComputerVsComputer { get; set; } = false;
|
||||||
|
|
||||||
public IChessEngine? Computer { get; set; }
|
// The engine playing each side (null for a human). In a human-vs-computer game only the
|
||||||
|
// computer's side is set; the orchestrator picks the engine for whoever is to move.
|
||||||
|
public IChessEngine? WhiteComputer { get; set; }
|
||||||
|
public IChessEngine? BlackComputer { get; set; }
|
||||||
|
|
||||||
|
// Which engine implementation each side uses — lets game-over handling know which side(s)
|
||||||
|
// are the learning engine, and lets spectators see who is playing.
|
||||||
|
public ChessEngineKind WhiteEngineKind { get; set; }
|
||||||
|
public ChessEngineKind BlackEngineKind { get; set; }
|
||||||
|
|
||||||
|
// Native per-game training accumulator (nint.Zero when this game isn't training the
|
||||||
|
// learned engine). The engine records each played position into it and applies the
|
||||||
|
// result on game over.
|
||||||
|
public nint Trainer { get; set; }
|
||||||
|
|
||||||
// optional: convenience
|
// optional: convenience
|
||||||
public bool IsOpen => !WhiteJoined || !BlackJoined;
|
public bool IsOpen => !WhiteJoined || !BlackJoined;
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single piece as sent to the client. Captured pieces keep their original
|
||||||
|
/// type and color; their position is meaningless and omitted.
|
||||||
|
/// </summary>
|
||||||
|
public record ChessPieceDto(string Id, PieceType Type, PieceColor Color, int Row, int Col, bool HasMoved);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The full board state pushed to clients. The same shape is returned by the
|
||||||
|
/// state endpoint, the move endpoint, and every SignalR move broadcast, so the
|
||||||
|
/// client always renders from one authoritative payload instead of re-fetching.
|
||||||
|
/// </summary>
|
||||||
|
public record GameStateDto(
|
||||||
|
Guid GameId,
|
||||||
|
string CurrentPlayer,
|
||||||
|
bool IsCheck,
|
||||||
|
bool IsCheckmate,
|
||||||
|
bool IsStalemate,
|
||||||
|
bool IsThreefoldRepetition,
|
||||||
|
string? EnPassantTarget,
|
||||||
|
bool WhiteCanCastleKingside,
|
||||||
|
bool WhiteCanCastleQueenside,
|
||||||
|
bool BlackCanCastleKingside,
|
||||||
|
bool BlackCanCastleQueenside,
|
||||||
|
IReadOnlyList<ChessPieceDto> Pieces,
|
||||||
|
IReadOnlyList<ChessPieceDto> CapturedPieces,
|
||||||
|
IReadOnlyList<string> MoveHistory,
|
||||||
|
// Moves in standard algebraic notation, for the move-list panel.
|
||||||
|
IReadOnlyList<string> SanHistory,
|
||||||
|
// Monotonic ply counter the client uses to drop stale or echoed updates.
|
||||||
|
int Version);
|
||||||
|
|
||||||
|
public static class GameStateMapper
|
||||||
|
{
|
||||||
|
public static GameStateDto ToDto(this GameState gameState) => new(
|
||||||
|
gameState.GameId,
|
||||||
|
gameState.CurrentPlayer.ToString(),
|
||||||
|
gameState.IsCheck,
|
||||||
|
gameState.IsCheckmate,
|
||||||
|
gameState.IsStalemate,
|
||||||
|
gameState.IsThreefoldRepetition,
|
||||||
|
gameState.EnPassantTarget?.ToString(),
|
||||||
|
gameState.WhiteCanCastleKingside,
|
||||||
|
gameState.WhiteCanCastleQueenside,
|
||||||
|
gameState.BlackCanCastleKingside,
|
||||||
|
gameState.BlackCanCastleQueenside,
|
||||||
|
gameState.Pieces
|
||||||
|
.Where(p => p.Position.Row >= 0)
|
||||||
|
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
|
||||||
|
.ToList(),
|
||||||
|
gameState.Pieces
|
||||||
|
.Where(p => p.Position.Row < 0)
|
||||||
|
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
|
||||||
|
.ToList(),
|
||||||
|
gameState.MoveHistory,
|
||||||
|
gameState.SanHistory,
|
||||||
|
gameState.MoveHistory.Count);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace JoshHeaps.Net.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A copy of the learned engine's weights for display: midgame and endgame piece-square
|
||||||
|
/// tables (one 64-entry array per piece, in canonical Pawn..King order, white-relative
|
||||||
|
/// A1=0..H8=63) plus the feature weights (mobility N/B/R/Q, passed, isolated, doubled,
|
||||||
|
/// king safety).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record LearnedWeightsSnapshot(int[][] Mg, int[][] Eg, int[] Features);
|
||||||
@@ -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,600 @@
|
|||||||
|
@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="potBadge">
|
||||||
|
<span id="potBadgeLabel">Pot</span>
|
||||||
|
<span id="potBadgeAmount">$0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="betsLayer" aria-hidden="true"></div>
|
||||||
|
<div id="seatsLayer"></div>
|
||||||
|
</div>
|
||||||
|
<p id="tableHint" class="tableHint"></p>
|
||||||
|
<p class="tableHintActions">
|
||||||
|
<button type="button" id="openRulingsBtn" class="linkBtn">Common rulings</button>
|
||||||
|
</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. Seats without their own stack start here.</p>
|
||||||
|
|
||||||
|
<details class="subPanel">
|
||||||
|
<summary>House take</summary>
|
||||||
|
<div class="subPanelBody">
|
||||||
|
<div class="blindsRow">
|
||||||
|
<label class="fieldRow">
|
||||||
|
<span>Rake (%)</span>
|
||||||
|
<input type="number" id="rakePercentInput" min="0" max="100" step="0.5" placeholder="0" />
|
||||||
|
</label>
|
||||||
|
<label class="fieldRow">
|
||||||
|
<span>Cap ($)</span>
|
||||||
|
<input type="number" id="rakeCapInput" min="0" step="1" placeholder="none" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="fieldRow">
|
||||||
|
<span>No flop, no drop</span>
|
||||||
|
<input type="checkbox" id="rakeNoFlopInput" />
|
||||||
|
</label>
|
||||||
|
<p class="fieldHint">
|
||||||
|
Taken from the pot when it's awarded, and shown before anything is pushed.
|
||||||
|
Rounded down to a chip the table has, so the players keep what the house
|
||||||
|
can't make. Leave the percentage at zero for a game that doesn't rake.
|
||||||
|
</p>
|
||||||
|
<p class="fieldHint" id="rakeTakenHint" hidden></p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="subPanel">
|
||||||
|
<summary>Without live betting</summary>
|
||||||
|
<div class="subPanelBody">
|
||||||
|
<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">For tracking a hand by hand instead of tapping the action: everyone is assumed to have put in this much, unless their seat says otherwise.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="sidebarPanel">
|
||||||
|
<summary>Session</summary>
|
||||||
|
<div class="panelBody">
|
||||||
|
<p id="storageWarning" class="storageWarning" hidden></p>
|
||||||
|
<h3 class="panelSubhead">Standings</h3>
|
||||||
|
<div class="standingsTableWrap">
|
||||||
|
<div id="standingsTable">
|
||||||
|
<div class="standingsRow standingsHeader">
|
||||||
|
<span>Player</span>
|
||||||
|
<span class="standingsInHead">In</span>
|
||||||
|
<span class="standingsWonHead">Won</span>
|
||||||
|
<span class="standingsNetHead">Net</span>
|
||||||
|
<span class="standingsChipsHead" title="What they are holding — the cash to count out if they leave now">Chips</span>
|
||||||
|
<span title="Those chips, less the cash they handed over">Up/down</span>
|
||||||
|
</div>
|
||||||
|
<div id="standingsList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
|
||||||
|
|
||||||
|
<div id="bankReconcile" class="bankReconcile" hidden>
|
||||||
|
<div class="bankReconcileRow">
|
||||||
|
<span>Cash taken</span>
|
||||||
|
<span id="bankCashIn"></span>
|
||||||
|
</div>
|
||||||
|
<div class="bankReconcileRow">
|
||||||
|
<span>Chips in play</span>
|
||||||
|
<span id="bankChipsInPlay"></span>
|
||||||
|
</div>
|
||||||
|
<div class="bankReconcileRow" id="bankDiscardedRow" hidden>
|
||||||
|
<span>Taken off the table</span>
|
||||||
|
<span id="bankDiscarded"></span>
|
||||||
|
</div>
|
||||||
|
<div class="bankReconcileRow" id="bankRakedRow" hidden>
|
||||||
|
<span>House take</span>
|
||||||
|
<span id="bankRaked"></span>
|
||||||
|
</div>
|
||||||
|
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settleUp" hidden>
|
||||||
|
<h3 class="panelSubhead">Settle up</h3>
|
||||||
|
<ul id="settleUpList" class="settleUpList"></ul>
|
||||||
|
<p id="settleUpNote" class="fieldHint"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="panelSubhead">Hands</h3>
|
||||||
|
<div class="handHistoryFilterRow">
|
||||||
|
<input id="handHistoryFilter" type="search" class="handHistoryFilter"
|
||||||
|
placeholder="Find a hand — name, amount or #" aria-label="Filter the hand log by player, amount or hand number" />
|
||||||
|
<span id="handHistoryCount" class="handHistoryCount" hidden></span>
|
||||||
|
</div>
|
||||||
|
<div id="handHistoryList"></div>
|
||||||
|
<p id="handHistoryNoMatch" class="fieldHint" hidden></p>
|
||||||
|
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, 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>
|
||||||
|
|
||||||
|
<h3 class="panelSubhead">The table itself</h3>
|
||||||
|
<p class="fieldHint">
|
||||||
|
The night lives in this browser and nowhere else. Save it to a file to carry it
|
||||||
|
to another device, or to have something to come back to if this one dies.
|
||||||
|
</p>
|
||||||
|
<div class="handHistoryActions">
|
||||||
|
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
|
||||||
|
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
|
||||||
|
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
|
||||||
|
</div>
|
||||||
|
</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 value, then count chips on a seat instead of typing a total.</p>
|
||||||
|
|
||||||
|
<div id="colourUp" hidden>
|
||||||
|
<h3 class="panelSubhead">Stacks in chips</h3>
|
||||||
|
<dl id="colourUpList" class="colourUpList"></dl>
|
||||||
|
<p class="fieldHint">The fewest chips that make each stack — for colouring up, or counting one out.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="sidebarPanel" id="keyboardPanel">
|
||||||
|
<summary>Keyboard</summary>
|
||||||
|
<div class="panelBody">
|
||||||
|
<dl id="shortcutLegend" class="shortcutLegend"></dl>
|
||||||
|
<p class="fieldHint">Nothing fires while you're typing in a field, or while a sheet is open over the table.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="sidebarPanel">
|
||||||
|
<summary>Table Notes</summary>
|
||||||
|
<div class="panelBody">
|
||||||
|
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template id="seatTemplate">
|
||||||
|
<div class="seat" draggable="true">
|
||||||
|
<div class="seatChairNumber"></div>
|
||||||
|
<button class="dealerChip" type="button" title="Make this seat the dealer button">D</button>
|
||||||
|
<span class="blindBadge sbBadge">SB</span>
|
||||||
|
<span class="blindBadge bbBadge">BB</span>
|
||||||
|
<span class="blindBadge strBadge" title="Straddled — the action comes back to this seat">STR</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="seatBuyIns" hidden></div>
|
||||||
|
<div class="seatContribution">
|
||||||
|
<div class="chipCounts"></div>
|
||||||
|
<label class="fieldRow amountOverrideRow">
|
||||||
|
<span>$ amount</span>
|
||||||
|
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
|
||||||
|
</label>
|
||||||
|
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="customFields"></div>
|
||||||
|
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="chipLegendRowTemplate">
|
||||||
|
<div class="chipLegendRow">
|
||||||
|
<input type="color" class="chipColorInput" aria-label="Chip colour" />
|
||||||
|
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" aria-label="Chip 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>
|
||||||
|
<span class="standingsChips"></span>
|
||||||
|
<span class="standingsCashOut"></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="addChipsOverlay" hidden>
|
||||||
|
<div id="addChipsSheet" role="dialog" aria-labelledby="addChipsTitle">
|
||||||
|
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||||
|
<div class="addChipsHeader">
|
||||||
|
<h2 id="addChipsTitle"></h2>
|
||||||
|
<span id="addChipsSubtitle"></span>
|
||||||
|
</div>
|
||||||
|
<div id="addChipsQuick"></div>
|
||||||
|
<div class="addChipsEntry">
|
||||||
|
<span class="addChipsCurrency">$</span>
|
||||||
|
<input type="text" id="addChipsInput" inputmode="decimal" autocomplete="off" aria-label="Amount to add" />
|
||||||
|
</div>
|
||||||
|
<div class="addChipsActions">
|
||||||
|
<button id="addChipsConfirmBtn" class="holdemBtn holdemBtnAccent" type="button">Add to stack</button>
|
||||||
|
<button id="addChipsCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="rulingsOverlay" hidden>
|
||||||
|
<div id="rulingsSheet" role="dialog" aria-labelledby="rulingsTitle">
|
||||||
|
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||||
|
<div class="rulingsHeader">
|
||||||
|
<h2 id="rulingsTitle">Common rulings</h2>
|
||||||
|
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="rulingsIntro">
|
||||||
|
The conventions most rooms follow, for the calls that come up mid-hand.
|
||||||
|
Your house rules always win — and settle a dispute before the next hand,
|
||||||
|
not during it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<nav id="rulingsIndex" class="rulingsIndex" aria-label="Jump to a ruling"></nav>
|
||||||
|
|
||||||
|
<section class="ruling" data-short="Misdeal">
|
||||||
|
<h3>Redeal it — misdeal</h3>
|
||||||
|
<ul>
|
||||||
|
<li>The first card goes to the wrong seat.</li>
|
||||||
|
<li>A seat entitled to a hand gets dealt out, or a seat that isn't entitled gets dealt in.</li>
|
||||||
|
<li>Two or more boxed cards turn up on the initial deal.</li>
|
||||||
|
<li>Either of the first two cards is exposed by dealer error.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ruling" data-short="Board card early">
|
||||||
|
<h3>A board card turned too early</h3>
|
||||||
|
<p>
|
||||||
|
If the fourth board card gets turned before the betting round is finished,
|
||||||
|
that card is out of play for the round. Finish the betting, then burn and
|
||||||
|
turn what would have been the fifth card in the fourth card's place.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ruling" data-short="Out of turn">
|
||||||
|
<h3>Acting out of turn</h3>
|
||||||
|
<p>
|
||||||
|
A one-off is usually just backed up if no one has acted behind them.
|
||||||
|
Doing it repeatedly is what earns a penalty.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ruling" data-short="Clock">
|
||||||
|
<h3>Calling the clock on a player</h3>
|
||||||
|
<p>
|
||||||
|
Any player may ask for a clock once a decision is taking unreasonably
|
||||||
|
long; it is the dealer, not the table, who grants it. The player then
|
||||||
|
gets a set time and a countdown out loud — thirty seconds and ten is the
|
||||||
|
usual form.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If the count reaches zero with no action, the hand is <em>dead</em> if
|
||||||
|
they were facing a bet, and <em>checked</em> if they were not: nobody is
|
||||||
|
folded for slowness when checking was free. The action bar runs the
|
||||||
|
count once you tap <em>Call the clock</em>, and offers the ruling as one
|
||||||
|
button when it ends.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ruling" data-short="Straddle">
|
||||||
|
<h3>A straddle is a blind raise</h3>
|
||||||
|
<p>
|
||||||
|
The player left of the big blind may put out twice it before the cards,
|
||||||
|
without looking. It is a blind bet, not a raise made in turn, so it does
|
||||||
|
three things at once: everyone else acts first, the minimum raise becomes
|
||||||
|
twice the straddle, and the straddler still has the option when the
|
||||||
|
action comes back — they may raise even if nobody raised into them.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Re-straddles carry on to the left, each doubling again, and stop before
|
||||||
|
the small blind. Tap <em>Straddle</em> on the deal prompt and the tool
|
||||||
|
posts it, puts the right player on the clock and prices the raise;
|
||||||
|
the seat wears an <em>STR</em> badge for the hand.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ruling" data-short="Short all-in">
|
||||||
|
<h3>A short all-in doesn't reopen the betting</h3>
|
||||||
|
<p>
|
||||||
|
Somebody shoving in less than a full raise — say $3 more when the last
|
||||||
|
raise was $8 — does not give the players behind them another go. Anyone
|
||||||
|
who has already acted owes the difference or folds; they don't get to
|
||||||
|
raise again because a short stack happened to land between them.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
A player who hadn't acted yet keeps full rights and may still raise, and
|
||||||
|
the minimum raise stays measured off the last <em>full</em> raise, not
|
||||||
|
off the short one. The action bar follows this for you.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ruling" data-short="Uncalled bet">
|
||||||
|
<h3>Chips nobody called come back</h3>
|
||||||
|
<p>
|
||||||
|
Bet more than anyone was able to call and the excess is returned to you
|
||||||
|
before the showdown — it was never contested, so it isn't part of the
|
||||||
|
pot. The pot breakdown shows it as "uncalled" rather than pretending it's
|
||||||
|
a side pot you could win.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ruling" data-short="Oversized chip">
|
||||||
|
<h3>One big chip, nothing said</h3>
|
||||||
|
<p>
|
||||||
|
The most common argument at a home table. A player facing a bet who
|
||||||
|
pushes out a single oversized chip without saying anything has
|
||||||
|
<em>called</em> — not raised, however big the chip is. To raise with one
|
||||||
|
chip you have to say "raise" before it lands; say it without an amount
|
||||||
|
and the raise is the most that chip allows.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Facing no bet, the same silent chip is a bet of the whole chip. And a
|
||||||
|
clear verbal declaration in turn is binding either way — if words and
|
||||||
|
chips disagree, whichever came first is the action.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ruling" data-short="Minimum raise">
|
||||||
|
<h3>A raise has to be a full raise</h3>
|
||||||
|
<p>
|
||||||
|
At least as much again as the last raise, or the big blind if there
|
||||||
|
hasn't been one. Anything between a call and that minimum isn't a raise
|
||||||
|
a player can make — it's a call, unless they're putting their last chips
|
||||||
|
in. The raise pad says so if you type one, and won't stop you: your
|
||||||
|
ruling beats the arithmetic.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ruling" data-short="Who shows first">
|
||||||
|
<h3>Who turns their hand over first</h3>
|
||||||
|
<p>
|
||||||
|
If there was a bet on the river, the last player to bet or raise shows
|
||||||
|
first. If the river was checked all the way round, it's the first live
|
||||||
|
player left of the button. Nobody has to be asked twice — a player may
|
||||||
|
always turn their hand over early if they want to.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When everyone is all-in before the river there was no final round to
|
||||||
|
lead, so it falls to whoever last put money in, on whichever street that
|
||||||
|
was. The action bar names the seat and the reason at every showdown, so
|
||||||
|
the call is already made before anyone asks.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ruling" data-short="Odd chip">
|
||||||
|
<h3>The odd chip in a split pot</h3>
|
||||||
|
<p>
|
||||||
|
When a chopped pot won't divide evenly, the odd chip goes to the first
|
||||||
|
player left of the button, not to whoever the dealer happened to tick
|
||||||
|
first. Pot payouts here order the winners that way, so the leftover lands
|
||||||
|
on the right seat every time.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ruling" data-short="String bet">
|
||||||
|
<h3>Betting in one motion — the string bet</h3>
|
||||||
|
<p>
|
||||||
|
A bet is whatever goes in on the first trip to the stack, unless the
|
||||||
|
amount was said out loud first. Reaching back for more chips without
|
||||||
|
having said "raise" is a string bet, and only the first motion stands.
|
||||||
|
Saying the number is always enough on its own: "raise to sixty" binds
|
||||||
|
whatever order the chips arrive in.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
It's the rule home games invoke most and enforce least, and both are
|
||||||
|
fine — the point is that a player can't watch the table react and then
|
||||||
|
decide how much to bet. Say it, or push it all at once.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="rulingsSource">
|
||||||
|
Follows the Tournament Directors Association rules, which most rooms and
|
||||||
|
home games take their lead from.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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" data-modal-close 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" data-modal-close 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" data-modal-close 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" role="dialog" aria-labelledby="potWizardTitle">
|
||||||
|
<div class="potWizardHeader">
|
||||||
|
<h2 id="potWizardTitle">Resolve the Pot</h2>
|
||||||
|
<button id="closePotWizardBtn" data-modal-close 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/settleUp.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/addChips.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/shortcuts.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/modal.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,95 @@
|
|||||||
|
@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="modeToggle" class="chipRow" role="group" aria-label="How the money went in">
|
||||||
|
<button type="button" id="modeEvenBtn" class="choiceChip selected">Everyone matched</button>
|
||||||
|
<button type="button" id="modeUnevenBtn" class="choiceChip">Someone's all-in</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div id="unevenResult" hidden aria-live="polite">
|
||||||
|
<div id="unevenPots"></div>
|
||||||
|
<p id="unevenEmpty" class="unevenEmpty">Enter what each player put in.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="splitField unevenOnly" hidden>
|
||||||
|
<span class="splitLabel">What each player put in</span>
|
||||||
|
<div id="unevenRows"></div>
|
||||||
|
<button type="button" id="addPlayerBtn" class="splitBtn splitBtnGhost">+ Add player</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="splitField evenOnly">
|
||||||
|
<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 evenOnly">
|
||||||
|
<span class="splitLabel" id="waysLabel">Split between</span>
|
||||||
|
<div id="waysChoices" class="chipRow" role="group" aria-labelledby="waysLabel"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="splitField evenOnly">
|
||||||
|
<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 evenOnly">
|
||||||
|
If somebody was all-in for less than the others, switch to
|
||||||
|
<strong>Someone's all-in</strong> above.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="splitFootnote unevenOnly" hidden>
|
||||||
|
Pots are listed with who can win each. Award them by hand strength, best hand
|
||||||
|
first. Nothing here is saved — the <a href="/casino/texasholdem">full table</a>
|
||||||
|
keeps a log.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template id="unevenRowTemplate">
|
||||||
|
<div class="unevenRow">
|
||||||
|
<span class="unevenRowLabel"></span>
|
||||||
|
<div class="unevenAmountWrap">
|
||||||
|
<span class="unevenCurrency">$</span>
|
||||||
|
<input type="text" class="unevenAmount" inputmode="decimal" autocomplete="off" placeholder="0" />
|
||||||
|
</div>
|
||||||
|
<button type="button" class="unevenFoldBtn" aria-pressed="false">Folded</button>
|
||||||
|
<button type="button" class="unevenRemoveBtn" aria-label="Remove player">✕</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
@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/potCalculator.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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div id="chessContainer">
|
<div id="chessContainer">
|
||||||
<div id="boardContainer">
|
<section id="boardArea">
|
||||||
|
<div class="playerBar" id="topPlayerBar">
|
||||||
|
<span class="playerDot black"></span>
|
||||||
|
<span class="playerName">Opponent</span>
|
||||||
|
<div class="capturedTray" id="captured-top"></div>
|
||||||
|
<span class="advantage" id="advantage-top"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="chessBoard">
|
<div id="chessBoard">
|
||||||
<!-- Placeholder squares -->
|
<!-- Placeholder squares -->
|
||||||
@for (int i = 0; i < 64; i++)
|
@for (int i = 0; i < 64; i++)
|
||||||
@@ -14,18 +21,41 @@
|
|||||||
<div id="square-@i" class="chessSquare @( (i + i / 8) % 2 == 0 ? "light" : "dark" )"></div>
|
<div id="square-@i" class="chessSquare @( (i + i / 8) % 2 == 0 ? "light" : "dark" )"></div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="textContainer" class="sideContent">
|
<div class="playerBar" id="bottomPlayerBar">
|
||||||
<h1>Chess</h1>
|
<span class="playerDot white"></span>
|
||||||
<p>Click a button to start a game :)</p>
|
<span class="playerName">You</span>
|
||||||
</div>
|
<div class="capturedTray" id="captured-bottom"></div>
|
||||||
|
<span class="advantage" id="advantage-bottom"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div id="buttonContainer" class="sideContent">
|
<aside id="gamePanel">
|
||||||
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
|
<header class="panelHeader">
|
||||||
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
|
<span class="panelLogo">♞</span>
|
||||||
<button id="copyPgnBtn" onclick="copyPgn()" style="display: none">Copy PGN</button>
|
<h1>Chess</h1>
|
||||||
<a id="watchLink" href="/watch">Watch other games →</a>
|
<button id="menuClose" class="iconBtn" aria-label="Close menu" onclick="closeMenu()">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="moveList">
|
||||||
|
<p class="movePlaceholder">Moves will appear here once a game begins.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="statusLine">Start a game to play.</div>
|
||||||
|
|
||||||
|
<div id="panelButtons">
|
||||||
|
<button id="startGameBtn" class="btn btn-primary" onclick="startNewGame()">New Game</button>
|
||||||
|
<button id="startCPUGame" class="btn btn-secondary" onclick="startCPUGame()">Play vs CPU</button>
|
||||||
|
<button id="copyPgnBtn" class="btn btn-ghost" onclick="copyPgn()" style="display: none">Copy PGN</button>
|
||||||
|
<a id="watchLink" href="/watch">Watch other games →</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div id="menuBackdrop" onclick="closeMenu()"></div>
|
||||||
|
|
||||||
|
<div id="mobileBar">
|
||||||
|
<span id="mobileStatus">Start a game to play.</span>
|
||||||
|
<button id="menuToggle" class="btn btn-primary" onclick="toggleMenu()">Menu</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -59,19 +89,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="colorModal" style="display: none;">
|
||||||
|
<p>Play as:</p>
|
||||||
|
<div id="colorButtonContainer">
|
||||||
|
<button onclick="selectColor('white')">White</button>
|
||||||
|
<button onclick="selectColor('black')">Black</button>
|
||||||
|
<button onclick="selectColor('random')">Random</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script src="~/js/signalr/signalr.min.js"></script>
|
<script src="~/js/signalr/signalr.min.js"></script>
|
||||||
<script src="~/js/ChessScripts/GameState.js"></script>
|
<script src="~/js/ChessScripts/GameState.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/ChessScripts/ChessUtils.js"></script>
|
<script src="~/js/ChessScripts/ChessUtils.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/ChessScripts/ChessAPI.js"></script>
|
<script src="~/js/ChessScripts/ChessAPI.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/ChessScripts/ChessSignalR.js"></script>
|
<script src="~/js/ChessScripts/ChessSignalR.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/ChessScripts/ChessInteractions.js"></script>
|
<script src="~/js/ChessScripts/ChessInteractions.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/ChessScripts/ChessBoard.js"></script>
|
<script src="~/js/ChessScripts/ChessBoard.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/ChessScripts/ChessModals.js"></script>
|
<script src="~/js/ChessScripts/ChessModals.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/ChessScripts/chessMain.js"></script>
|
<script src="~/js/ChessScripts/chessMain.js?v=@ViewData["cssVersion"]"></script>
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Styles {
|
@section Styles {
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
|
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
|
||||||
<link rel="stylesheet" href="~/css/chess/site.css?v=@ViewData["cssVersion"]" />
|
<link rel="stylesheet" href="~/css/chess/site.css?v=@ViewData["cssVersion"]" />
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -9,16 +9,43 @@
|
|||||||
<h1>Live Chess</h1>
|
<h1>Live Chess</h1>
|
||||||
<p id="watchStatus">Loading games…</p>
|
<p id="watchStatus">Loading games…</p>
|
||||||
<div id="watchControls">
|
<div id="watchControls">
|
||||||
<label for="cpuDifficulty">Difficulty</label>
|
<fieldset class="enginePicker">
|
||||||
<select id="cpuDifficulty">
|
<legend>White</legend>
|
||||||
@for (int i = 1; i <= 20; i++)
|
<select id="whiteEngine">
|
||||||
{
|
<option value="custom">Custom (v1)</option>
|
||||||
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
<option value="customLearned" selected>Custom (Learned)</option>
|
||||||
}
|
<option value="stockfish">Stockfish</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select id="whiteSkill">
|
||||||
|
@for (int i = 1; i <= 20; i++)
|
||||||
|
{
|
||||||
|
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="enginePicker">
|
||||||
|
<legend>Black</legend>
|
||||||
|
<select id="blackEngine">
|
||||||
|
<option value="custom" selected>Custom (v1)</option>
|
||||||
|
<option value="customLearned">Custom (Learned)</option>
|
||||||
|
<option value="stockfish">Stockfish</option>
|
||||||
|
</select>
|
||||||
|
<select id="blackSkill">
|
||||||
|
@for (int i = 1; i <= 20; i++)
|
||||||
|
{
|
||||||
|
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
|
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
|
||||||
|
<fieldset class="enginePicker">
|
||||||
|
<legend>Auto-train games</legend>
|
||||||
|
<input id="autoTrainCount" type="number" min="0" max="16" step="1" />
|
||||||
|
<button id="applyAutoTrain" onclick="Spectate.setAutoTrainCount()">Apply</button>
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<a id="backToPlay" href="/chess">← Play a game</a>
|
<a id="backToPlay" href="/chess">← Play a game</a>
|
||||||
|
<a id="viewWeights" href="/weights">View learned weights →</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="gamesFeed"></div>
|
<div id="gamesFeed"></div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
@page
|
||||||
|
@model JoshHeaps.Net.Pages.WeightsModel
|
||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
ViewData["Title"] = "Learned Weights";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div id="weightsHeader">
|
||||||
|
<h1>Learned Piece-Square Weights</h1>
|
||||||
|
<p id="weightsStatus">Where the learned engine thinks each piece belongs.</p>
|
||||||
|
<div id="weightsControls">
|
||||||
|
<div class="heatLegend">
|
||||||
|
<span>low</span>
|
||||||
|
<span class="legendBar"></span>
|
||||||
|
<span>high</span>
|
||||||
|
</div>
|
||||||
|
<button id="refreshWeights" onclick="Weights.refresh()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<a id="backToWatch" href="/watch">← Watch / train</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="weightsSection">
|
||||||
|
<h2>Feature weights</h2>
|
||||||
|
<p class="sectionHint">Learned value of each contextual feature (per normalized unit). Mobility is per piece type; passed pawns are endgame-weighted, king safety midgame-weighted.</p>
|
||||||
|
<div id="featureWeights" class="featurePanel"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="weightsSection">
|
||||||
|
<h2>Midgame tables</h2>
|
||||||
|
<div id="weightsGridMg" class="weightsGrid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="weightsSection">
|
||||||
|
<h2>Endgame tables</h2>
|
||||||
|
<div id="weightsGridEg" class="weightsGrid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="~/js/ChessScripts/Weights.js"></script>
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<link rel="stylesheet" href="~/css/chess/weights.css?v=@ViewData["cssVersion"]" />
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Pages
|
||||||
|
{
|
||||||
|
public class WeightsModel : PageModel
|
||||||
|
{
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@{
|
@{
|
||||||
Layout = null;
|
Layout = null;
|
||||||
ViewData["cssVersion"] = "1.0.5"; // <--- change this once to bust cache
|
ViewData["cssVersion"] = "1.0.8"; // <--- change this once to bust cache
|
||||||
}
|
}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|||||||
@@ -23,12 +23,23 @@ builder.Services.AddSingleton<IChessService, ChessService>();
|
|||||||
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
|
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
|
||||||
|
|
||||||
builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName));
|
builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName));
|
||||||
|
builder.Services.AddSingleton<ILearnedWeightsStore, LearnedWeightsStore>();
|
||||||
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
|
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
|
||||||
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
|
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
|
||||||
|
builder.Services.AddSingleton<IGameStore, GameStore>();
|
||||||
|
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
|
||||||
|
builder.Services.AddSingleton<AutoTrainingSettings>();
|
||||||
|
|
||||||
if (!builder.Environment.IsDevelopment())
|
if (!builder.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
builder.Services.AddHostedService<AutoIpUpdateService>();
|
builder.Services.AddHostedService<AutoIpUpdateService>();
|
||||||
|
|
||||||
|
// Continuously train the learned engine against Stockfish in the background. Toggle off
|
||||||
|
// via ChessEngine:AutoTrain (env ChessEngine__AutoTrain=false) without a redeploy.
|
||||||
|
if (configuration.GetValue($"{ChessEngineOptions.SectionName}:{nameof(ChessEngineOptions.AutoTrain)}", true))
|
||||||
|
builder.Services.AddHostedService<AutoTrainingService>();
|
||||||
|
}
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,73 @@
|
|||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Continuously trains the learned engine in the background by keeping a configurable number of
|
||||||
|
/// self-play games running — the learned engine (skill 6) against Stockfish (skill 20), alternating
|
||||||
|
/// which color Stockfish takes so the model trains on both. The target count is read live from
|
||||||
|
/// <see cref="AutoTrainingSettings"/> (adjustable from the website): when a game finishes another
|
||||||
|
/// starts to refill the pool, raising the count starts more, and lowering it lets the surplus drain
|
||||||
|
/// as games finish (0 pauses training). Registered only outside Development and gated by the
|
||||||
|
/// ChessEngine:AutoTrain config flag.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AutoTrainingService(
|
||||||
|
ISelfPlayCoordinator coordinator,
|
||||||
|
AutoTrainingSettings settings,
|
||||||
|
ILogger<AutoTrainingService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private const int LearnedSkill = 6;
|
||||||
|
private const int StockfishSkill = 20;
|
||||||
|
private static readonly TimeSpan _restartBackoff = TimeSpan.FromSeconds(5);
|
||||||
|
private static readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var running = new List<Task>();
|
||||||
|
int started = 0;
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
running.RemoveAll(t => t.IsCompleted);
|
||||||
|
|
||||||
|
int desired = settings.GameCount;
|
||||||
|
bool startFailed = false;
|
||||||
|
|
||||||
|
while (running.Count < desired && !stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Alternate Stockfish's color so the learned engine trains as both white and black.
|
||||||
|
var config = started++ % 2 == 0
|
||||||
|
? new SelfPlayConfig(ChessEngineKind.CustomLearned, LearnedSkill, ChessEngineKind.Stockfish, StockfishSkill)
|
||||||
|
: new SelfPlayConfig(ChessEngineKind.Stockfish, StockfishSkill, ChessEngineKind.CustomLearned, LearnedSkill);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (_, completion) = coordinator.StartGame(config, stoppingToken);
|
||||||
|
running.Add(completion);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Most likely an engine failing to start (e.g. Stockfish). Back off so a
|
||||||
|
// persistent failure doesn't spin a tight loop, then try again.
|
||||||
|
logger.LogError(ex, "Failed to start an auto-training game; retrying after backoff.");
|
||||||
|
startFailed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (startFailed)
|
||||||
|
await Task.Delay(_restartBackoff, stoppingToken);
|
||||||
|
else if (running.Count > 0)
|
||||||
|
// Wake when any game finishes (to refill) or after a short poll (to pick up a
|
||||||
|
// count increase promptly).
|
||||||
|
await Task.WhenAny(Task.WhenAny(running), Task.Delay(_pollInterval, stoppingToken));
|
||||||
|
else
|
||||||
|
// Pool is empty (count is 0) — just poll for the count to change.
|
||||||
|
await Task.Delay(_pollInterval, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runtime-adjustable auto-training settings. Singleton so the value set from the website (via the
|
||||||
|
/// chess controller) is seen live by the background <see cref="AutoTrainingService"/>. Seeded from
|
||||||
|
/// <see cref="ChessEngineOptions.AutoTrainGameCount"/> and clamped to a sane range.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AutoTrainingSettings
|
||||||
|
{
|
||||||
|
/// <summary>Upper bound on concurrent auto-training games (each spawns a Stockfish + a learned engine).</summary>
|
||||||
|
public const int MaxGames = 16;
|
||||||
|
|
||||||
|
private int _gameCount;
|
||||||
|
|
||||||
|
public AutoTrainingSettings(IOptions<ChessEngineOptions> options)
|
||||||
|
=> _gameCount = Clamp(options.Value.AutoTrainGameCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of auto-training games to keep running concurrently. 0 pauses auto-training.
|
||||||
|
/// Reads/writes are atomic; the background service reads this every cycle.
|
||||||
|
/// </summary>
|
||||||
|
public int GameCount
|
||||||
|
{
|
||||||
|
get => Volatile.Read(ref _gameCount);
|
||||||
|
set => Volatile.Write(ref _gameCount, Clamp(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Clamp(int n) => Math.Clamp(n, 0, MaxGames);
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ namespace JoshHeaps.Net.Services.Implementations;
|
|||||||
public enum ChessEngineKind
|
public enum ChessEngineKind
|
||||||
{
|
{
|
||||||
Stockfish,
|
Stockfish,
|
||||||
Custom
|
Custom,
|
||||||
|
|
||||||
|
/// <summary>The custom engine with the reinforcement-learned piece-square evaluation.</summary>
|
||||||
|
CustomLearned
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Configuration selecting which <see cref="IChessEngine"/> to use.</summary>
|
/// <summary>Configuration selecting which <see cref="IChessEngine"/> to use.</summary>
|
||||||
@@ -16,17 +19,46 @@ public sealed class ChessEngineOptions
|
|||||||
public const string SectionName = "ChessEngine";
|
public const string SectionName = "ChessEngine";
|
||||||
|
|
||||||
public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish;
|
public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Absolute path to the learned-weights file. Leave null to default to
|
||||||
|
/// <c>{ContentRoot}/chess-data/learned-weights.txt</c> (fine for local dev). In
|
||||||
|
/// production set this to a stable, service-writable location OUTSIDE the deploy
|
||||||
|
/// directory (e.g. <c>/var/lib/joshheaps/chess-data/learned-weights.txt</c>) so the
|
||||||
|
/// trained weights survive deploys and avoid deploy-user vs service-user permission
|
||||||
|
/// clashes. Override via the <c>ChessEngine__WeightsPath</c> environment variable.
|
||||||
|
/// </summary>
|
||||||
|
public string? WeightsPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true (and outside Development), a background service continuously plays the learned
|
||||||
|
/// engine against Stockfish to train it. Set to false to stop auto-training without a
|
||||||
|
/// redeploy. Override via the <c>ChessEngine__AutoTrain</c> environment variable.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoTrain { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many auto-training games run concurrently (when <see cref="AutoTrain"/> is on). This is
|
||||||
|
/// the starting value; it can be changed at runtime from the website. Override the default via
|
||||||
|
/// the <c>ChessEngine__AutoTrainGameCount</c> environment variable.
|
||||||
|
/// </summary>
|
||||||
|
public int AutoTrainGameCount { get; set; } = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
|
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
|
||||||
public sealed class ChessEngineFactory(IOptions<ChessEngineOptions> options) : IChessEngineFactory
|
public sealed class ChessEngineFactory(
|
||||||
|
IOptions<ChessEngineOptions> options,
|
||||||
|
ILearnedWeightsStore weightsStore) : IChessEngineFactory
|
||||||
{
|
{
|
||||||
private readonly ChessEngineKind _kind = options.Value.Engine;
|
private readonly ChessEngineKind _kind = options.Value.Engine;
|
||||||
|
|
||||||
public IChessEngine Create(int skill) => _kind switch
|
public IChessEngine Create(int skill) => Create(skill, _kind);
|
||||||
|
|
||||||
|
public IChessEngine Create(int skill, ChessEngineKind kind) => kind switch
|
||||||
{
|
{
|
||||||
ChessEngineKind.Custom => new CustomChessEngine(skill),
|
ChessEngineKind.Custom => new CustomChessEngine(skill),
|
||||||
|
ChessEngineKind.CustomLearned => new CustomChessEngine(skill, EngineVariant.Learned, weightsStore.WeightsFilePath),
|
||||||
ChessEngineKind.Stockfish => new Stockfish(skill),
|
ChessEngineKind.Stockfish => new Stockfish(skill),
|
||||||
_ => throw new InvalidOperationException($"Unknown chess engine '{_kind}'.")
|
_ => throw new InvalidOperationException($"Unknown chess engine '{kind}'.")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,23 +11,60 @@ namespace JoshHeaps.Net.Services.Implementations;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ComputerMoveOrchestrator(
|
public sealed class ComputerMoveOrchestrator(
|
||||||
IHubContext<ChessHub> chessHub,
|
IHubContext<ChessHub> chessHub,
|
||||||
IChessService chessService) : IComputerMoveOrchestrator
|
IChessService chessService,
|
||||||
|
ILearnedWeightsStore weightsStore) : IComputerMoveOrchestrator
|
||||||
{
|
{
|
||||||
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine)
|
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state)
|
||||||
{
|
{
|
||||||
|
var engine = (state.CurrentPlayer == PieceColor.White ? state.WhiteComputer : state.BlackComputer)
|
||||||
|
?? throw new InvalidOperationException($"No engine is set for {state.CurrentPlayer} in game {state.GameId}.");
|
||||||
|
|
||||||
var uci = await engine.GetBestMoveAsync(state.ToFen(), state.RepetitionHistory());
|
var uci = await engine.GetBestMoveAsync(state.ToFen(), state.RepetitionHistory());
|
||||||
|
var move = uci.ToMoveDto(state, CurrentPlayerId(state));
|
||||||
|
|
||||||
var move = uci.ToMoveDto(
|
return await ApplyAndBroadcastAsync(state, move);
|
||||||
state,
|
}
|
||||||
state.CurrentPlayer == PieceColor.White
|
|
||||||
? state.WhitePlayerId
|
|
||||||
: state.BlackPlayerId);
|
|
||||||
|
|
||||||
|
public async Task PlayRandomMoveAsync(GameState state)
|
||||||
|
{
|
||||||
|
var options = chessService.GetAllLegalMoves(state);
|
||||||
|
|
||||||
|
// No legal moves means the game is already over; let the caller's loop detect it.
|
||||||
|
if (options.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var (piece, moves) = options[Random.Shared.Next(options.Count)];
|
||||||
|
var target = moves[Random.Shared.Next(moves.Count)];
|
||||||
|
|
||||||
|
var move = new MoveDto
|
||||||
|
{
|
||||||
|
GameId = state.GameId,
|
||||||
|
PlayerId = CurrentPlayerId(state),
|
||||||
|
PieceId = piece.Id,
|
||||||
|
SourceRow = piece.Position.Row,
|
||||||
|
SourceCol = piece.Position.Col,
|
||||||
|
TargetRow = target.Row,
|
||||||
|
TargetCol = target.Col,
|
||||||
|
PromotionChoice = null // a pawn cannot reach the last rank within the opening plies
|
||||||
|
};
|
||||||
|
|
||||||
|
await ApplyAndBroadcastAsync(state, move);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(MoveDto move, MoveResultDto result)> ApplyAndBroadcastAsync(GameState state, MoveDto move)
|
||||||
|
{
|
||||||
var result = chessService.MakeMove(state, move);
|
var result = chessService.MakeMove(state, move);
|
||||||
|
|
||||||
|
// Record the played position for training (no-op for non-training games).
|
||||||
|
if (state.Trainer != nint.Zero)
|
||||||
|
weightsStore.Record(state.Trainer, state.ToFen());
|
||||||
|
|
||||||
await chessHub.Clients.Group(state.GameId.ToString())
|
await chessHub.Clients.Group(state.GameId.ToString())
|
||||||
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result);
|
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, state.ToDto());
|
||||||
|
|
||||||
return (move, result);
|
return (move, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Guid CurrentPlayerId(GameState state) =>
|
||||||
|
state.CurrentPlayer == PieceColor.White ? state.WhitePlayerId : state.BlackPlayerId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ using System.Runtime.InteropServices;
|
|||||||
|
|
||||||
namespace JoshHeaps.Net.Services.Implementations;
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>Which evaluation the native engine uses.</summary>
|
||||||
|
public enum EngineVariant
|
||||||
|
{
|
||||||
|
/// <summary>The hand-crafted evaluation.</summary>
|
||||||
|
Classic,
|
||||||
|
|
||||||
|
/// <summary>Material plus a learned per-square bonus table loaded from a weights file.</summary>
|
||||||
|
Learned
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Middleman wrapper over the native custom chess engine (chess_engine.dll / libchess_engine.so).
|
/// Middleman wrapper over the native custom chess engine (chess_engine.dll / libchess_engine.so).
|
||||||
/// Shares <see cref="IChessEngine"/> with <see cref="Stockfish"/> so the two are swappable.
|
/// Shares <see cref="IChessEngine"/> with <see cref="Stockfish"/> so the two are swappable.
|
||||||
@@ -16,11 +26,17 @@ public sealed partial class CustomChessEngine : IChessEngine
|
|||||||
|
|
||||||
public int Skill { get; }
|
public int Skill { get; }
|
||||||
|
|
||||||
public CustomChessEngine(int skill = 20)
|
public CustomChessEngine(int skill = 20, EngineVariant variant = EngineVariant.Classic, string? weightsPath = null)
|
||||||
{
|
{
|
||||||
Skill = skill;
|
Skill = skill;
|
||||||
|
|
||||||
var handle = NativeMethods.engine_create($"skill={skill}");
|
// weights= must come last: the native side reads the path as the rest of the
|
||||||
|
// string, which lets it contain ';' and spaces.
|
||||||
|
var options = variant == EngineVariant.Learned
|
||||||
|
? $"skill={skill};variant=learned;weights={weightsPath}"
|
||||||
|
: $"skill={skill}";
|
||||||
|
|
||||||
|
var handle = NativeMethods.engine_create(options);
|
||||||
|
|
||||||
if (handle == IntPtr.Zero)
|
if (handle == IntPtr.Zero)
|
||||||
throw new InvalidOperationException("Native chess engine failed to initialize (engine_create returned null).");
|
throw new InvalidOperationException("Native chess engine failed to initialize (engine_create returned null).");
|
||||||
@@ -79,8 +95,9 @@ public sealed partial class CustomChessEngine : IChessEngine
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// P/Invoke surface for chess_engine.(dll|so). The resolver maps the logical name
|
/// P/Invoke surface for chess_engine.(dll|so). The resolver maps the logical name
|
||||||
/// "chess_engine" to the platform binary in the Resources folder (mirrors Stockfish).
|
/// "chess_engine" to the platform binary in the Resources folder (mirrors Stockfish).
|
||||||
|
/// Internal so <see cref="LearnedWeightsStore"/> can share the single import resolver.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static partial class NativeMethods
|
internal static partial class NativeMethods
|
||||||
{
|
{
|
||||||
private const string LibName = "chess_engine";
|
private const string LibName = "chess_engine";
|
||||||
|
|
||||||
@@ -115,5 +132,31 @@ public sealed partial class CustomChessEngine : IChessEngine
|
|||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||||
internal static partial void engine_destroy(IntPtr engine);
|
internal static partial void engine_destroy(IntPtr engine);
|
||||||
|
|
||||||
|
// ---- Learned-weights / training ABI (see chess_engine.h) ----
|
||||||
|
|
||||||
|
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||||
|
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||||
|
internal static partial void learned_load(string path);
|
||||||
|
|
||||||
|
[LibraryImport(LibName)]
|
||||||
|
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||||
|
internal static unsafe partial int weights_snapshot(int* outBuf, int outLen);
|
||||||
|
|
||||||
|
[LibraryImport(LibName)]
|
||||||
|
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||||
|
internal static partial IntPtr trainer_create();
|
||||||
|
|
||||||
|
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||||
|
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||||
|
internal static partial void trainer_record(IntPtr trainer, string fen);
|
||||||
|
|
||||||
|
[LibraryImport(LibName)]
|
||||||
|
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||||
|
internal static partial void trainer_apply(IntPtr trainer, int winner, double weight);
|
||||||
|
|
||||||
|
[LibraryImport(LibName)]
|
||||||
|
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||||
|
internal static partial void trainer_destroy(IntPtr trainer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory game registry with a delayed-removal lifecycle. Singleton: the game state is
|
||||||
|
/// process-wide, not per-request, so it lives in a service rather than static controller fields.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GameStore(ILearnedWeightsStore weightsStore) : IGameStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
||||||
|
private readonly ConcurrentDictionary<Guid, Task> _removalTasks = [];
|
||||||
|
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> _removalCts = [];
|
||||||
|
|
||||||
|
public void Add(GameState game) => _games[game.GameId] = game;
|
||||||
|
|
||||||
|
public bool TryGet(Guid id, out GameState game) => _games.TryGetValue(id, out game!);
|
||||||
|
|
||||||
|
public bool Contains(Guid id) => _games.ContainsKey(id);
|
||||||
|
|
||||||
|
public IReadOnlyCollection<GameState> All => [.. _games.Values];
|
||||||
|
|
||||||
|
public void ScheduleRemove(Guid id, TimeSpan delay)
|
||||||
|
{
|
||||||
|
if (_removalCts.TryRemove(id, out var oldCts))
|
||||||
|
{
|
||||||
|
oldCts.Cancel();
|
||||||
|
oldCts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
_removalCts[id] = cts;
|
||||||
|
|
||||||
|
_removalTasks[id] = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(delay, cts.Token);
|
||||||
|
|
||||||
|
if (_games.TryGetValue(id, out var game))
|
||||||
|
{
|
||||||
|
if (game.WhiteComputer is not null)
|
||||||
|
await game.WhiteComputer.DisposeAsync();
|
||||||
|
if (game.BlackComputer is not null)
|
||||||
|
await game.BlackComputer.DisposeAsync();
|
||||||
|
|
||||||
|
// Free the trainer if the game never reached ApplyLearning (e.g. timed out).
|
||||||
|
if (game.Trainer != nint.Zero)
|
||||||
|
{
|
||||||
|
weightsStore.DestroyTrainer(game.Trainer);
|
||||||
|
game.Trainer = nint.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_games.Remove(id, out _);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (_removalCts.TryGetValue(id, out var currentCts) && currentCts == cts)
|
||||||
|
_removalCts.TryRemove(id, out _);
|
||||||
|
|
||||||
|
cts.Dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Managed facade over the native learned-weights model (see <see cref="ILearnedWeightsStore"/>).
|
||||||
|
/// On construction it points the native engine at the weights file; everything else delegates
|
||||||
|
/// to the shared native ABI in <see cref="CustomChessEngine"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LearnedWeightsStore : ILearnedWeightsStore
|
||||||
|
{
|
||||||
|
private const int Pieces = 6; // Pawn..King
|
||||||
|
private const int Squares = 64;
|
||||||
|
private const int Features = 8; // mobility N/B/R/Q, passed, isolated, doubled, king safety
|
||||||
|
|
||||||
|
public string WeightsFilePath { get; }
|
||||||
|
|
||||||
|
public LearnedWeightsStore(IHostEnvironment env, IOptions<ChessEngineOptions> options, ILogger<LearnedWeightsStore> logger)
|
||||||
|
{
|
||||||
|
// Prefer the configured path (production points this outside the deploy dir); fall
|
||||||
|
// back to the content root for local dev.
|
||||||
|
var configured = options.Value.WeightsPath;
|
||||||
|
WeightsFilePath = string.IsNullOrWhiteSpace(configured)
|
||||||
|
? Path.Combine(env.ContentRootPath, "chess-data", "learned-weights.txt")
|
||||||
|
: configured;
|
||||||
|
|
||||||
|
// A missing/unwritable/misconfigured path must not take down the whole app — the
|
||||||
|
// learned engine just plays from a neutral table and can't persist training.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(WeightsFilePath)!);
|
||||||
|
CustomChessEngine.NativeMethods.learned_load(WeightsFilePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex,
|
||||||
|
"Could not initialize the learned-weights store at {Path}. The learned engine will " +
|
||||||
|
"play from a neutral table and training will not persist. In production set " +
|
||||||
|
"ChessEngine:WeightsPath (env ChessEngine__WeightsPath) to a service-writable directory.",
|
||||||
|
WeightsFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public nint CreateTrainer() => CustomChessEngine.NativeMethods.trainer_create();
|
||||||
|
|
||||||
|
public void Record(nint trainer, string fen) =>
|
||||||
|
CustomChessEngine.NativeMethods.trainer_record(trainer, fen);
|
||||||
|
|
||||||
|
public void ApplyResult(nint trainer, PieceColor winner, double weight) =>
|
||||||
|
CustomChessEngine.NativeMethods.trainer_apply(trainer, winner == PieceColor.White ? 0 : 1, weight);
|
||||||
|
|
||||||
|
public void DestroyTrainer(nint trainer) =>
|
||||||
|
CustomChessEngine.NativeMethods.trainer_destroy(trainer);
|
||||||
|
|
||||||
|
public LearnedWeightsSnapshot Snapshot()
|
||||||
|
{
|
||||||
|
const int total = Pieces * Squares * 2 + Features;
|
||||||
|
var buffer = new int[total];
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (int* p = buffer)
|
||||||
|
CustomChessEngine.NativeMethods.weights_snapshot(p, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
var mg = new int[Pieces][];
|
||||||
|
var eg = new int[Pieces][];
|
||||||
|
|
||||||
|
for (int piece = 0; piece < Pieces; piece++)
|
||||||
|
{
|
||||||
|
mg[piece] = new int[Squares];
|
||||||
|
eg[piece] = new int[Squares];
|
||||||
|
Array.Copy(buffer, piece * Squares, mg[piece], 0, Squares);
|
||||||
|
Array.Copy(buffer, Pieces * Squares + piece * Squares, eg[piece], 0, Squares);
|
||||||
|
}
|
||||||
|
|
||||||
|
var features = new int[Features];
|
||||||
|
Array.Copy(buffer, Pieces * Squares * 2, features, 0, Features);
|
||||||
|
|
||||||
|
return new LearnedWeightsSnapshot(mg, eg, features);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs CPU-vs-CPU games: builds the game and engines, plays a randomized opening (for training
|
||||||
|
/// variety), drives the move loop to completion, then trains the learned engine from the result.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SelfPlayCoordinator(
|
||||||
|
IChessService chessService,
|
||||||
|
IChessEngineFactory engineFactory,
|
||||||
|
IComputerMoveOrchestrator orchestrator,
|
||||||
|
ILearnedWeightsStore weightsStore,
|
||||||
|
IGameStore gameStore) : ISelfPlayCoordinator
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||||
|
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
|
||||||
|
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
// Abandon a game that goes this long without a move being played — i.e. an engine (usually
|
||||||
|
// Stockfish) that crashed or froze. The game is killed with no result recorded; if it was an
|
||||||
|
// auto-training game the trainer schedules a replacement once this one's task completes.
|
||||||
|
private static readonly TimeSpan _idleTimeout = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
// Plies of random legal moves at the start of a training game, so self-play and
|
||||||
|
// engine-vs-engine games explore different lines instead of replaying one game.
|
||||||
|
private const int _openingRandomPlies = 4;
|
||||||
|
|
||||||
|
public (Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var whiteComputer = engineFactory.Create(config.WhiteSkill, config.WhiteKind);
|
||||||
|
IChessEngine blackComputer;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
blackComputer = engineFactory.Create(config.BlackSkill, config.BlackKind);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Don't leak the first engine if the second fails to start (e.g. Stockfish process).
|
||||||
|
whiteComputer.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var gameState = chessService.CreateNewGame();
|
||||||
|
gameState.IsVsComputer = true;
|
||||||
|
gameState.IsComputerVsComputer = true;
|
||||||
|
gameState.WhiteJoined = true;
|
||||||
|
gameState.BlackJoined = true;
|
||||||
|
gameState.WhitePlayerId = Guid.NewGuid();
|
||||||
|
gameState.BlackPlayerId = Guid.NewGuid();
|
||||||
|
gameState.WhiteEngineKind = config.WhiteKind;
|
||||||
|
gameState.BlackEngineKind = config.BlackKind;
|
||||||
|
gameState.WhiteComputer = whiteComputer;
|
||||||
|
gameState.BlackComputer = blackComputer;
|
||||||
|
|
||||||
|
// When the learned engine is playing, attach a trainer so the outcome can train it.
|
||||||
|
if (config.WhiteKind == ChessEngineKind.CustomLearned || config.BlackKind == ChessEngineKind.CustomLearned)
|
||||||
|
gameState.Trainer = weightsStore.CreateTrainer();
|
||||||
|
|
||||||
|
gameStore.Add(gameState);
|
||||||
|
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||||
|
|
||||||
|
var completion = Task.Run(() => RunAsync(gameState, cancellationToken));
|
||||||
|
return (gameState.GameId, completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the game to completion, then trains from it. Never throws — a failure (or a frozen
|
||||||
|
/// engine) just ends the game and the trainer is always freed, so callers can await or ignore.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RunAsync(GameState gameState, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
bool aborted = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Give spectators a moment to join the SignalR group before the first move.
|
||||||
|
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||||
|
|
||||||
|
// Training games open with random moves so they don't replay the same line.
|
||||||
|
if (gameState.Trainer != nint.Zero)
|
||||||
|
for (int i = 0; i < _openingRandomPlies && IsLive(gameState, cancellationToken); i++)
|
||||||
|
{
|
||||||
|
await orchestrator.PlayRandomMoveAsync(gameState);
|
||||||
|
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastMoveCount = gameState.MoveHistory.Count;
|
||||||
|
var lastProgress = DateTime.UtcNow;
|
||||||
|
|
||||||
|
while (IsLive(gameState, cancellationToken))
|
||||||
|
{
|
||||||
|
await PlayMoveWithTimeoutAsync(gameState, cancellationToken);
|
||||||
|
|
||||||
|
// Watchdog: abandon the game if it stops making moves (a crashed or frozen engine
|
||||||
|
// can leave PlayAsync returning without progressing). Reset the clock on a real
|
||||||
|
// move; otherwise bail once nothing has happened for the idle timeout.
|
||||||
|
if (gameState.MoveHistory.Count != lastMoveCount)
|
||||||
|
{
|
||||||
|
lastMoveCount = gameState.MoveHistory.Count;
|
||||||
|
lastProgress = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
else if (DateTime.UtcNow - lastProgress > _idleTimeout)
|
||||||
|
throw new TimeoutException("no move played within the idle timeout");
|
||||||
|
|
||||||
|
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { aborted = true; /* service shutting down */ }
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
aborted = true;
|
||||||
|
Console.WriteLine($"Self-play game {gameState.GameId} abandoned: no move for over " +
|
||||||
|
$"{_idleTimeout.TotalSeconds:n0}s (likely a crashed or frozen engine).");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
aborted = true;
|
||||||
|
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A clean finish trains the learned engine; an abandoned game (cancelled, crashed, or
|
||||||
|
// idle past the timeout) records nothing and just frees its trainer.
|
||||||
|
if (aborted)
|
||||||
|
DiscardTraining(gameState);
|
||||||
|
else
|
||||||
|
ApplyLearning(gameState);
|
||||||
|
|
||||||
|
// A clean finish lingers briefly so spectators see the result; an abandoned game is torn
|
||||||
|
// down immediately so its engines (and any crashed/frozen Stockfish process) are released.
|
||||||
|
if (gameStore.Contains(gameState.GameId))
|
||||||
|
gameStore.ScheduleRemove(gameState.GameId, aborted ? TimeSpan.Zero : _selfPlayResultTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plays one engine move, abandoning it if it exceeds <see cref="_idleTimeout"/> (throwing
|
||||||
|
/// <see cref="TimeoutException"/>) so a single hung move can't block the loop forever. The
|
||||||
|
/// abandoned move's eventual fault — it errors once the game's engines are disposed — is
|
||||||
|
/// observed so it isn't an unobserved task exception.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PlayMoveWithTimeoutAsync(GameState gameState, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var play = orchestrator.PlayAsync(gameState);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await play.WaitAsync(_idleTimeout, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
_ = play.ContinueWith(static t => { _ = t.Exception; }, TaskScheduler.Default);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsLive(GameState gameState, CancellationToken cancellationToken) =>
|
||||||
|
!cancellationToken.IsCancellationRequested
|
||||||
|
&& gameStore.Contains(gameState.GameId)
|
||||||
|
&& !IsGameOver(gameState);
|
||||||
|
|
||||||
|
private static bool IsGameOver(GameState gameState) =>
|
||||||
|
gameState.IsCheckmate || gameState.IsStalemate || gameState.IsThreefoldRepetition || gameState.IsForfeited;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feeds a finished training game's result into the learned weights, then frees the trainer.
|
||||||
|
/// A checkmate is a full-strength result; a material-imbalance draw is a half-strength win
|
||||||
|
/// for the lower-material side (holding a draw while down material is a success; only drawing
|
||||||
|
/// while up is a failure). A balanced draw, forfeit, or unfinished game teaches nothing.
|
||||||
|
/// </summary>
|
||||||
|
private void ApplyLearning(GameState gameState)
|
||||||
|
{
|
||||||
|
if (gameState.Trainer == nint.Zero)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (TryDetermineOutcome(gameState, out var winner, out var weight))
|
||||||
|
weightsStore.ApplyResult(gameState.Trainer, winner, weight);
|
||||||
|
|
||||||
|
weightsStore.DestroyTrainer(gameState.Trainer);
|
||||||
|
gameState.Trainer = nint.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frees an abandoned game's trainer without recording any result — a cancelled, crashed, or
|
||||||
|
/// idle-timed-out game teaches the model nothing.
|
||||||
|
/// </summary>
|
||||||
|
private void DiscardTraining(GameState gameState)
|
||||||
|
{
|
||||||
|
if (gameState.Trainer == nint.Zero)
|
||||||
|
return;
|
||||||
|
|
||||||
|
weightsStore.DestroyTrainer(gameState.Trainer);
|
||||||
|
gameState.Trainer = nint.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines the trainable outcome of a finished game: the winning color and the reward
|
||||||
|
/// weight. Returns false when the game teaches nothing (balanced draw, forfeit, unfinished).
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryDetermineOutcome(GameState gameState, out PieceColor winner, out double weight)
|
||||||
|
{
|
||||||
|
winner = PieceColor.White;
|
||||||
|
weight = 1.0;
|
||||||
|
|
||||||
|
if (gameState.IsCheckmate)
|
||||||
|
{
|
||||||
|
// The side to move is the mated one, so the winner is the other color.
|
||||||
|
winner = gameState.CurrentPlayer == PieceColor.White ? PieceColor.Black : PieceColor.White;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.IsStalemate || gameState.IsThreefoldRepetition)
|
||||||
|
{
|
||||||
|
var (white, black) = MaterialCounts(gameState);
|
||||||
|
|
||||||
|
if (white == black)
|
||||||
|
return false; // a balanced draw carries no signal
|
||||||
|
|
||||||
|
winner = white < black ? PieceColor.White : PieceColor.Black;
|
||||||
|
weight = 0.5;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // forfeit / unfinished
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Total non-king material per side (P=1, N=B=3, R=5, Q=9), for draw adjudication.</summary>
|
||||||
|
private static (int white, int black) MaterialCounts(GameState gameState)
|
||||||
|
{
|
||||||
|
int white = 0, black = 0;
|
||||||
|
|
||||||
|
for (int row = 0; row < 8; row++)
|
||||||
|
for (int col = 0; col < 8; col++)
|
||||||
|
{
|
||||||
|
var piece = gameState.Board[row, col];
|
||||||
|
|
||||||
|
if (piece is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int value = piece.Type switch
|
||||||
|
{
|
||||||
|
PieceType.Pawn => 1,
|
||||||
|
PieceType.Knight => 3,
|
||||||
|
PieceType.Bishop => 3,
|
||||||
|
PieceType.Rook => 5,
|
||||||
|
PieceType.Queen => 9,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
|
||||||
|
if (piece.Color == PieceColor.White)
|
||||||
|
white += value;
|
||||||
|
else
|
||||||
|
black += value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (white, black);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Services.Interfaces;
|
namespace JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -6,9 +8,20 @@ namespace JoshHeaps.Net.Services.Interfaces;
|
|||||||
public interface IChessEngineFactory
|
public interface IChessEngineFactory
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new engine instance for a single game. The caller owns and disposes it.
|
/// Creates a new engine instance for a single game using the configured default engine.
|
||||||
|
/// The caller owns and disposes it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="skill">The desired playing strength / search depth.</param>
|
/// <param name="skill">The desired playing strength / search depth.</param>
|
||||||
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
|
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
|
||||||
IChessEngine Create(int skill);
|
IChessEngine Create(int skill);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new engine instance for a single game using an explicitly chosen engine
|
||||||
|
/// (e.g. for picking a different engine per side in a CPU-vs-CPU game). The caller owns
|
||||||
|
/// and disposes it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="skill">The desired playing strength / search depth.</param>
|
||||||
|
/// <param name="kind">The engine implementation to create.</param>
|
||||||
|
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
|
||||||
|
IChessEngine Create(int skill, ChessEngineKind kind);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,24 @@ using JoshHeaps.Net.Models;
|
|||||||
namespace JoshHeaps.Net.Services.Interfaces;
|
namespace JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Drives a computer move: asks the engine for a move, applies it through the rules
|
/// Drives a computer move: asks the side-to-move's engine for a move, applies it through
|
||||||
/// service, and broadcasts the result to the game's clients.
|
/// the rules service, and broadcasts the result to the game's clients.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IComputerMoveOrchestrator
|
public interface IComputerMoveOrchestrator
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Has the engine pick a move for the current position, applies it, and broadcasts it.
|
/// Has the side-to-move's engine pick a move for the current position, applies it, and
|
||||||
|
/// broadcasts it. The engine is taken from the game's per-side computer assignments.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="state">The game to play a move in.</param>
|
/// <param name="state">The game to play a move in.</param>
|
||||||
/// <param name="engine">The engine that selects the move.</param>
|
|
||||||
/// <returns>The applied move and its result.</returns>
|
/// <returns>The applied move and its result.</returns>
|
||||||
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine);
|
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plays a uniformly-random legal move for the side to move (no engine), applying and
|
||||||
|
/// broadcasting it. Used to randomize the opening of training games so self-play and
|
||||||
|
/// engine-vs-engine games don't replay the same line every time.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="state">The game to play a random move in.</param>
|
||||||
|
Task PlayRandomMoveAsync(GameState state);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Process-wide registry of in-memory games and their cleanup lifecycle. Shared by the HTTP
|
||||||
|
/// controller (human and single-computer games) and the self-play coordinator (CPU-vs-CPU and
|
||||||
|
/// auto-training games), so every game is reachable from one place for lookup and spectating.
|
||||||
|
/// </summary>
|
||||||
|
public interface IGameStore
|
||||||
|
{
|
||||||
|
/// <summary>Add (or replace) a game in the registry.</summary>
|
||||||
|
void Add(GameState game);
|
||||||
|
|
||||||
|
/// <summary>Look a game up by id.</summary>
|
||||||
|
bool TryGet(Guid id, out GameState game);
|
||||||
|
|
||||||
|
/// <summary>Whether a game with this id is still in the registry.</summary>
|
||||||
|
bool Contains(Guid id);
|
||||||
|
|
||||||
|
/// <summary>Snapshot of all games currently in the registry.</summary>
|
||||||
|
IReadOnlyCollection<GameState> All { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schedule removal of a game after <paramref name="delay"/>, cancelling any prior schedule
|
||||||
|
/// for it. On removal the game's engines are disposed and any training accumulator freed.
|
||||||
|
/// </summary>
|
||||||
|
void ScheduleRemove(Guid id, TimeSpan delay);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thin managed facade over the native learned-weights model. The weights, all feature
|
||||||
|
/// computation, per-game accumulation, the update rule, and persistence live in the native
|
||||||
|
/// engine; this just points it at the weights file, hands out per-game trainers, and reads
|
||||||
|
/// the table back for visualization.
|
||||||
|
/// </summary>
|
||||||
|
public interface ILearnedWeightsStore
|
||||||
|
{
|
||||||
|
/// <summary>Absolute path to the weights file the native engine loads and saves.</summary>
|
||||||
|
string WeightsFilePath { get; }
|
||||||
|
|
||||||
|
/// <summary>A copy of the current weights (midgame/endgame tables + feature weights).</summary>
|
||||||
|
LearnedWeightsSnapshot Snapshot();
|
||||||
|
|
||||||
|
/// <summary>Creates a per-game training accumulator. The caller owns it (see <see cref="DestroyTrainer"/>).</summary>
|
||||||
|
nint CreateTrainer();
|
||||||
|
|
||||||
|
/// <summary>Records one played position (post-move FEN) into a trainer.</summary>
|
||||||
|
void Record(nint trainer, string fen);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a finished game's outcome to the global weights (and saves): rewards the
|
||||||
|
/// winner's squares/features, punishes the loser's, scaled by <paramref name="weight"/>.
|
||||||
|
/// </summary>
|
||||||
|
void ApplyResult(nint trainer, PieceColor winner, double weight);
|
||||||
|
|
||||||
|
/// <summary>Frees a trainer. Safe to call with <see cref="nint.Zero"/>.</summary>
|
||||||
|
void DestroyTrainer(nint trainer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Per-side engine and strength for a CPU-vs-CPU game.</summary>
|
||||||
|
public sealed record SelfPlayConfig(
|
||||||
|
ChessEngineKind WhiteKind, int WhiteSkill,
|
||||||
|
ChessEngineKind BlackKind, int BlackSkill);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates and runs CPU-vs-CPU games to completion: randomized opening, move loop, and (when
|
||||||
|
/// the learned engine plays) feeding the result back into the learned weights. Used by the
|
||||||
|
/// spectator "watch" endpoint and by the auto-trainer.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISelfPlayCoordinator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Create, register, and start running a self-play game. Returns immediately with the new
|
||||||
|
/// game's id and a task that completes when the game finishes (or is cancelled). Callers
|
||||||
|
/// that only need the id can ignore the task; the auto-trainer awaits it to start the next.
|
||||||
|
/// </summary>
|
||||||
|
(Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
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,628 @@
|
|||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same touch handling as the dealer table — see the note there. This page is the one
|
||||||
|
a dealer opens mid-hand to chop a pot, so the ~300ms the browser spends deciding
|
||||||
|
whether a tap was a double-tap-to-zoom is the last thing it needs. */
|
||||||
|
button,
|
||||||
|
[role="button"],
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
a {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
[role="button"],
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
a {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Not on the fields — a dealer typing a pot amount still needs to select it. */
|
||||||
|
button,
|
||||||
|
[role="button"] {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearPotBtn:active:not(:disabled),
|
||||||
|
.choiceChip:active:not(:disabled),
|
||||||
|
.stepBtn:active:not(:disabled),
|
||||||
|
.splitBtn:active:not(:disabled),
|
||||||
|
.splitBtnGhost:active:not(:disabled),
|
||||||
|
.unevenFoldBtn:active:not(:disabled),
|
||||||
|
.unevenRemoveBtn:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
filter: brightness(1.25);
|
||||||
|
transition-duration: 0.04s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.clearPotBtn:active:not(:disabled),
|
||||||
|
.choiceChip:active:not(:disabled),
|
||||||
|
.stepBtn:active:not(:disabled),
|
||||||
|
.splitBtn:active:not(:disabled),
|
||||||
|
.splitBtnGhost:active:not(:disabled),
|
||||||
|
.unevenFoldBtn:active:not(:disabled),
|
||||||
|
.unevenRemoveBtn:active:not(:disabled) {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Mode toggle ---------- */
|
||||||
|
|
||||||
|
#modeToggle {
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Uneven amounts ---------- */
|
||||||
|
|
||||||
|
#unevenRows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenRowLabel {
|
||||||
|
flex: none;
|
||||||
|
width: 2rem;
|
||||||
|
color: #8f8f8f;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenAmountWrap {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenAmountWrap:focus-within {
|
||||||
|
border-color: #d8b46a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenCurrency {
|
||||||
|
/* Was #777 — 4.11:1 on this background, under the 4.5:1 small text needs. It sits
|
||||||
|
against every amount a dealer types, so it is read constantly. */
|
||||||
|
color: #8f8f8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 37px tall, on the field a dealer types every amount into. The touch rule has been
|
||||||
|
44px everywhere else since the round that set it; this page never got measured for
|
||||||
|
it, because the audit only ran the target check when asked and nothing asked. */
|
||||||
|
.unevenAmount {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 46px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: #f2f2f2;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenFoldBtn {
|
||||||
|
flex: none;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 0.6rem;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
background: #232323;
|
||||||
|
color: #8f8f8f;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenFoldBtn.selected {
|
||||||
|
background: linear-gradient(180deg, #3d1f1f, #2c1515);
|
||||||
|
border-color: #5b2626;
|
||||||
|
color: #ffd6d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 30px across, and it deletes a player's entry — the one control on the row where a
|
||||||
|
near miss costs something. The amount field beside it gives up the 16px. */
|
||||||
|
.unevenRemoveBtn {
|
||||||
|
flex: none;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
/* Was #6a6a6a — 3.66:1, the worst on the page, on a control that deletes a
|
||||||
|
player's entry. A destructive action should not be the faintest thing in view. */
|
||||||
|
color: #9a9a9a;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenRemoveBtn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#unevenResult {
|
||||||
|
background: linear-gradient(160deg, #1f2a21, #161c17);
|
||||||
|
border: 1px solid #2c3a2f;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#unevenResult[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenPot {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
/* Wraps so a chop opens underneath its own pot rather than squeezing in beside the
|
||||||
|
eligible-players list and pushing it off the row. */
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopToggle {
|
||||||
|
flex: none;
|
||||||
|
order: 3;
|
||||||
|
/* 44px on touch: it moves money, like everything else that does. */
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border: 1px solid rgba(216, 180, 106, 0.45);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(216, 180, 106, 0.1);
|
||||||
|
color: #e3c887;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopToggle:hover {
|
||||||
|
background: rgba(216, 180, 106, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopToggle:focus-visible {
|
||||||
|
outline: 2px solid #d8b46a;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopDetail {
|
||||||
|
order: 4;
|
||||||
|
flex: 0 0 100%;
|
||||||
|
/* Or the padding is added on top of the 100% basis and the panel sits wider than
|
||||||
|
the row it belongs to — measured 328px inside a 309px row on a phone. */
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0.15rem 0 0.2rem;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopWays {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopWaysLabel {
|
||||||
|
color: #cfcfcf;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopEach {
|
||||||
|
margin: 0;
|
||||||
|
color: #f2f2f2;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopNote {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: #9a9a9a;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenChopDust {
|
||||||
|
color: #e0b169;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px), (pointer: coarse) {
|
||||||
|
.unevenChopToggle {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenPot + .unevenPot {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenPotLabel {
|
||||||
|
flex: none;
|
||||||
|
color: #d8b46a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenPotAmount {
|
||||||
|
flex: none;
|
||||||
|
color: #f2f2f2;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenPotWho {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: right;
|
||||||
|
color: #9a9a9a;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenPotReturn .unevenPotLabel,
|
||||||
|
.unevenPotReturn .unevenPotAmount {
|
||||||
|
color: #9a9a9a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenPotTotal {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenPotTotal .unevenPotLabel {
|
||||||
|
color: #8f9d92;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unevenEmpty {
|
||||||
|
margin: 0;
|
||||||
|
color: #8f9d92;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A `display` declaration outranks the browser's own [hidden] rule, so anything
|
||||||
|
given one has to opt back out explicitly or toggling `hidden` does nothing. */
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.splitBtn:hover {
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitBtnGhost {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitFootnote {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
/* Was #6f6f6f — 3.94:1. It carries the instruction for what to do with the pot
|
||||||
|
list, which is not a decorative line. */
|
||||||
|
color: #8d8d8d;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* This footnote names a control the dealer is being told to reach for, so the name
|
||||||
|
itself has to be readable — it was 3.39:1 at its worst, inheriting the muted grey
|
||||||
|
the surrounding sentence is deliberately set in. Emphasised text carrying the
|
||||||
|
instruction gets its own colour rather than the aside's. */
|
||||||
|
.splitFootnote strong {
|
||||||
|
color: #a8a8a8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,4 @@
|
|||||||
#boardContainer {
|
#chessBoard {
|
||||||
position: relative;
|
|
||||||
width: fit-content;
|
|
||||||
margin: 2vw auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#chessBoard {
|
|
||||||
width: 60vw;
|
|
||||||
height: 60vw;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(8, 1fr);
|
grid-template-columns: repeat(8, 1fr);
|
||||||
grid-template-rows: repeat(8, 1fr);
|
grid-template-rows: repeat(8, 1fr);
|
||||||
@@ -165,6 +157,47 @@
|
|||||||
pointer-events: none; /* ensures img doesn't steal the click */
|
pointer-events: none; /* ensures img doesn't steal the click */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#colorModal {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 20px rgba(0,0,0,0.6);
|
||||||
|
color: white;
|
||||||
|
z-index: 1000;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#colorModal p {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#colorButtonContainer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#colorButtonContainer button {
|
||||||
|
background-color: #2c2c2c;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#colorButtonContainer button:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
.chessSquare .coordinate-label {
|
.chessSquare .coordinate-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-size: 1.2vw;
|
font-size: 1.2vw;
|
||||||
|
|||||||
@@ -1,159 +1,429 @@
|
|||||||
html, body {
|
:root {
|
||||||
background-color: #2b2c30;
|
--bg: #2b2c30;
|
||||||
color: #d6d6d6;
|
--panel: #21232a;
|
||||||
cursor: default;
|
--panel-row: rgba(255, 255, 255, 0.035);
|
||||||
|
--bar: #1e2026;
|
||||||
|
--accent: #8cd5ed;
|
||||||
|
--accent-ink: #10222a;
|
||||||
|
--text: #d6d6d6;
|
||||||
|
--muted: #8b8f99;
|
||||||
|
--line: #34373f;
|
||||||
|
--bar-h: clamp(34px, 5.5vh, 50px);
|
||||||
|
--radius: 12px;
|
||||||
|
--font: 'Outfit', system-ui, -apple-system, Segoe UI, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
cursor: default;
|
||||||
|
/* The chess page is a fixed, single-screen app: never scroll. */
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#startGameBtn {
|
main {
|
||||||
background-color: #8cd5ed;
|
height: 100%;
|
||||||
color: #262626;
|
|
||||||
border-radius: 30px;
|
|
||||||
border: 0px;
|
|
||||||
cursor: pointer;
|
|
||||||
order: 1;
|
|
||||||
padding: 2vh;
|
|
||||||
margin: 2vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#startCPUGame {
|
/* ---- Layout ------------------------------------------------------------ */
|
||||||
background-color: #8cd5ed;
|
|
||||||
color: #262626;
|
|
||||||
border-radius: 30px;
|
|
||||||
border: 0px;
|
|
||||||
cursor: pointer;
|
|
||||||
order: 1;
|
|
||||||
padding: 2vh;
|
|
||||||
margin: 2vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
#copyPgnBtn {
|
|
||||||
background-color: #8cd5ed;
|
|
||||||
color: #262626;
|
|
||||||
border-radius: 30px;
|
|
||||||
border: 0px;
|
|
||||||
cursor: pointer;
|
|
||||||
order: 1;
|
|
||||||
padding: 2vh;
|
|
||||||
margin: 2vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
#watchLink {
|
|
||||||
color: #8cd5ed;
|
|
||||||
text-decoration: none;
|
|
||||||
margin: 2vh;
|
|
||||||
order: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#watchLink:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* Portrait / narrow: board on top, panel stacked below. */
|
||||||
#chessContainer {
|
#chessContainer {
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-evenly;
|
gap: clamp(8px, 1.6vh, 16px);
|
||||||
height: 100vh;
|
padding: clamp(10px, 2vh, 20px);
|
||||||
box-sizing: border-box;
|
overflow: hidden;
|
||||||
padding: 5vw;
|
}
|
||||||
|
|
||||||
|
#boardArea {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: min(94vw, 56vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
#chessBoard {
|
#chessBoard {
|
||||||
aspect-ratio: 1 / 1;
|
width: 100%;
|
||||||
width: 90vw; /* use the smaller of width or height */
|
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 90vw;
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
#boardContainer {
|
#gamePanel {
|
||||||
align-content: center;
|
width: min(94vw, 56vh);
|
||||||
flex-grow: 1;
|
flex: 1 1 auto;
|
||||||
}
|
min-height: 0;
|
||||||
|
|
||||||
.sideContent {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-content: center;
|
background: var(--panel);
|
||||||
justify-content: center;
|
border-radius: var(--radius);
|
||||||
text-align: center;
|
overflow: hidden;
|
||||||
|
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
#textContainer {
|
/* Landscape / wide: board left, game panel right (chess.com style). */
|
||||||
flex-direction: column;
|
|
||||||
order: -1;
|
|
||||||
flex-shrink: 1;
|
|
||||||
font-size: large;
|
|
||||||
margin: 5vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
#buttonContainer {
|
|
||||||
flex-grow: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Put UI to left/right when screen is short */
|
|
||||||
@media (min-aspect-ratio: 1/1) {
|
@media (min-aspect-ratio: 1/1) {
|
||||||
#chessContainer {
|
#chessContainer {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto, auto;
|
|
||||||
grid-template-rows: auto, auto;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
gap: clamp(16px, 3vw, 48px);
|
||||||
height: 100vh;
|
padding: clamp(12px, 3vh, 28px);
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sideContent {
|
#boardArea {
|
||||||
display: flex;
|
width: auto;
|
||||||
flex-direction: column;
|
height: 100%;
|
||||||
flex-grow: 1;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: min(5vw, 5vh);
|
|
||||||
}
|
|
||||||
|
|
||||||
#boardContainer {
|
|
||||||
grid-row: 1 / span 2;
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#textContainer {
|
|
||||||
grid-row: 1;
|
|
||||||
grid-column: 2;
|
|
||||||
text-align: center;
|
|
||||||
font-size: x-large;
|
|
||||||
}
|
|
||||||
|
|
||||||
#buttonContainer {
|
|
||||||
grid-row: 2;
|
|
||||||
grid-column: 2;
|
|
||||||
text-align: left;
|
|
||||||
align-self: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
#startGameBtn {
|
|
||||||
padding: 1vw;
|
|
||||||
font-size: large;
|
|
||||||
}
|
|
||||||
|
|
||||||
#startCPUGame {
|
|
||||||
padding: 1vw;
|
|
||||||
margin: 1vw;
|
|
||||||
font-size: large;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#chessBoard {
|
#chessBoard {
|
||||||
aspect-ratio: 1 / 1;
|
width: min(72vh, 54vw);
|
||||||
flex-shrink: 1;
|
height: min(72vh, 54vw);
|
||||||
width: min(90vh, 90vw);
|
|
||||||
max-height: 90vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#difficultyButtonContainer {
|
.playerBar {
|
||||||
grid-template-columns: repeat(10, 1fr);
|
width: min(72vh, 54vw);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
#gamePanel {
|
||||||
|
width: clamp(300px, 26vw, 380px);
|
||||||
|
height: min(86vh, 100%);
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Player bars ------------------------------------------------------- */
|
||||||
|
|
||||||
|
.playerBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: var(--bar-h);
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bar);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerDot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerDot.white {
|
||||||
|
background: #ededed;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerDot.black {
|
||||||
|
background: #2c2c2c;
|
||||||
|
box-shadow: 0 0 0 1px #565656 inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerName {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capturedTray {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capturedPiece {
|
||||||
|
height: clamp(15px, calc(var(--bar-h) * 0.58), 26px);
|
||||||
|
width: auto;
|
||||||
|
margin-right: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advantage {
|
||||||
|
margin-left: auto;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Game panel -------------------------------------------------------- */
|
||||||
|
|
||||||
|
.panelHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelLogo {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#moveList {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movePlaceholder {
|
||||||
|
margin: 0;
|
||||||
|
padding: 18px 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2.4em 1fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 14px;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveRow:nth-child(odd) {
|
||||||
|
background: var(--panel-row);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveNum {
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveSan {
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveSan.latest {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusLine {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusLine.alert {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panelButtons {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: clamp(0.92rem, 1vw, 1.05rem);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: clamp(10px, 1.4vh, 14px) 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.08s ease, filter 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #363a44;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #424752;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: #2a2e37;
|
||||||
|
}
|
||||||
|
|
||||||
|
#watchLink {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#watchLink:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Mobile chrome (hidden on desktop) --------------------------------- */
|
||||||
|
|
||||||
|
.iconBtn {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#menuClose {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default (tablet/desktop): no mobile bar, menu button, or sheet chrome. */
|
||||||
|
#mobileBar,
|
||||||
|
#menuBackdrop,
|
||||||
|
#menuClose {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Phone layout ------------------------------------------------------ */
|
||||||
|
/* The full panel doesn't fit alongside a usable board on a phone, so we show
|
||||||
|
the board + a slim status/menu bar, and tuck the move list and secondary
|
||||||
|
actions into a slide-up sheet. The page itself still never scrolls. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
#chessContainer {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#boardArea {
|
||||||
|
width: min(94vw, 60vh);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerBar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right rail becomes a hidden bottom sheet, revealed by the Menu button. */
|
||||||
|
#gamePanel {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
top: auto;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 85dvh;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
z-index: 60;
|
||||||
|
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.menu-open #gamePanel {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The slim bar already shows status, so hide the sheet's inline copy. */
|
||||||
|
#gamePanel #statusLine {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menuClose {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menuBackdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
z-index: 55;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.menu-open #menuBackdrop {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobileBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: min(94vw, 60vh);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobileStatus {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobileStatus.alert {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobileBar #menuToggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,11 +21,27 @@ html, body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cpuDifficulty {
|
.enginePicker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.3rem 0.6rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enginePicker legend {
|
||||||
|
color: #9a9a9a;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#watchControls select {
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
color: #d6d6d6;
|
color: #d6d6d6;
|
||||||
border: 1px solid #444;
|
border: 1px solid #444;
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: #141414;
|
||||||
|
color: #d6d6d6;
|
||||||
|
font-family: "Segoe UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weightsHeader {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem 1rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weightsHeader h1 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weightsStatus {
|
||||||
|
color: #9a9a9a;
|
||||||
|
margin: 0.25rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weightsControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatLegend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9a9a9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendBar {
|
||||||
|
width: 120px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: linear-gradient(to right, rgba(232, 74, 74, 1), #2a2a2a, rgba(74, 134, 232, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#refreshWeights {
|
||||||
|
background-color: #8cd5ed;
|
||||||
|
color: #262626;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#refreshWeights:hover {
|
||||||
|
background-color: #a5e0f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#backToWatch {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: #8cd5ed;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weightsSection {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 1200px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weightsSection h2 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin: 1rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHint {
|
||||||
|
text-align: center;
|
||||||
|
color: #9a9a9a;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weightsGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featurePanel {
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureLabel {
|
||||||
|
flex: 0 0 6.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #c8c8c8;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureTrack {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 14px;
|
||||||
|
background-color: #232323;
|
||||||
|
border-radius: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureBar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 7px;
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureValue {
|
||||||
|
flex: 0 0 3rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #e8e8e8;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weightBoard {
|
||||||
|
background-color: #1c1c1c;
|
||||||
|
border: 1px solid #2e2e2e;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weightBoardHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weightBoardIcon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weightRange {
|
||||||
|
margin-left: auto;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniHeat {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1rem repeat(8, 34px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatSquare {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: #f0f0f0;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2px 0;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ const ChessAPI = {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
async createCPUGame(difficulty) {
|
async createCPUGame(difficulty, color = "random") {
|
||||||
const response = await fetch(`/api/chess/new/${difficulty}`);
|
const response = await fetch(`/api/chess/new/${difficulty}?color=${color}`);
|
||||||
return await response.json();
|
return await response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -46,13 +46,15 @@ const ChessAPI = {
|
|||||||
throw new Error(message || "Invalid move or not your turn.");
|
throw new Error(message || "Invalid move or not your turn.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
// The move endpoint returns both the move result and the full resulting
|
||||||
|
// board state, so the caller can render without a follow-up fetch.
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
if (!result.success) {
|
if (!data.result.success) {
|
||||||
throw new Error(result.message || "Invalid move or not your turn.");
|
throw new Error(data.result.message || "Invalid move or not your turn.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleMove(targetRow, targetCol) {
|
async handleMove(targetRow, targetCol) {
|
||||||
@@ -85,16 +87,14 @@ const ChessAPI = {
|
|||||||
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
|
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const moveResult = await this.makeMove(moveDto);
|
const { result, state } = await this.makeMove(moveDto);
|
||||||
|
|
||||||
const updatedGame = await this.getGameState(GameState.currentGameId);
|
ChessBoard.renderState(state);
|
||||||
ChessBoard.renderPieces(updatedGame.pieces);
|
|
||||||
|
|
||||||
GameState.clearSelection();
|
GameState.clearSelection();
|
||||||
ChessInteractions.clearHighlights();
|
ChessInteractions.clearHighlights();
|
||||||
|
|
||||||
await ChessSignalR.notifyMoveMade(moveDto, moveResult);
|
this.alertGameStatusChange(result);
|
||||||
this.alertGameStatusChange(moveResult);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("❌ " + error.message);
|
alert("❌ " + error.message);
|
||||||
|
|||||||
@@ -1,4 +1,91 @@
|
|||||||
|
// Material value per piece type, indexed by PieceType (0=Pawn .. 5=King).
|
||||||
|
const PIECE_VALUES = [1, 5, 3, 3, 9, 0];
|
||||||
|
|
||||||
const ChessBoard = {
|
const ChessBoard = {
|
||||||
|
// Render from a full game-state payload (the shape returned by the move/state
|
||||||
|
// endpoints and pushed over SignalR). Ignores state older than what's already
|
||||||
|
// shown, so a slow initial fetch can't clobber a move that arrived first.
|
||||||
|
renderState(state) {
|
||||||
|
if (!state || !GameState.shouldApply(state.version)) return;
|
||||||
|
this.renderPieces(state.pieces);
|
||||||
|
this.renderCapturedTrays(state.pieces, state.capturedPieces);
|
||||||
|
this.renderMoveList(state.sanHistory);
|
||||||
|
this.renderStatus(state);
|
||||||
|
GameState.setVersion(state.version);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Two-column numbered move list (white move, black move), latest highlighted.
|
||||||
|
renderMoveList(sanHistory) {
|
||||||
|
const list = document.getElementById("moveList");
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
const san = sanHistory ?? [];
|
||||||
|
|
||||||
|
if (san.length === 0) {
|
||||||
|
list.innerHTML = '<p class="movePlaceholder">Moves will appear here once a game begins.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < san.length; i += 2) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "moveRow";
|
||||||
|
|
||||||
|
const num = document.createElement("span");
|
||||||
|
num.className = "moveNum";
|
||||||
|
num.textContent = `${i / 2 + 1}.`;
|
||||||
|
row.appendChild(num);
|
||||||
|
|
||||||
|
row.appendChild(this.moveCell(san[i], i === san.length - 1));
|
||||||
|
|
||||||
|
if (i + 1 < san.length)
|
||||||
|
row.appendChild(this.moveCell(san[i + 1], i + 1 === san.length - 1));
|
||||||
|
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.scrollTop = list.scrollHeight;
|
||||||
|
},
|
||||||
|
|
||||||
|
moveCell(san, isLatest) {
|
||||||
|
const cell = document.createElement("span");
|
||||||
|
cell.className = isLatest ? "moveSan latest" : "moveSan";
|
||||||
|
cell.textContent = san;
|
||||||
|
return cell;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderStatus(state) {
|
||||||
|
let text;
|
||||||
|
let alert = false;
|
||||||
|
|
||||||
|
if (state.isCheckmate) {
|
||||||
|
const winner = state.currentPlayer === "White" ? "Black" : "White";
|
||||||
|
text = `Checkmate — ${winner} wins`;
|
||||||
|
alert = true;
|
||||||
|
} else if (state.isStalemate) {
|
||||||
|
text = "Draw — stalemate";
|
||||||
|
alert = true;
|
||||||
|
} else if (state.isThreefoldRepetition) {
|
||||||
|
text = "Draw — threefold repetition";
|
||||||
|
alert = true;
|
||||||
|
} else {
|
||||||
|
text = `${state.currentPlayer} to move`;
|
||||||
|
if (state.isCheck) {
|
||||||
|
text += " — check";
|
||||||
|
alert = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror to both the desktop panel status and the mobile bar status.
|
||||||
|
["statusLine", "mobileStatus"].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text;
|
||||||
|
el.classList.toggle("alert", alert);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
renderPieces(pieces) {
|
renderPieces(pieces) {
|
||||||
this.clearAllSquares();
|
this.clearAllSquares();
|
||||||
this.renderCoordinateLabels();
|
this.renderCoordinateLabels();
|
||||||
@@ -7,6 +94,48 @@ const ChessBoard = {
|
|||||||
this.highlightPreviousMove();
|
this.highlightPreviousMove();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Show each side's captured pieces and the leading side's material advantage,
|
||||||
|
// arranged so the current player's tray sits below the board.
|
||||||
|
renderCapturedTrays(activePieces, capturedPieces) {
|
||||||
|
const captured = capturedPieces ?? [];
|
||||||
|
|
||||||
|
// A captured piece's color is the side that lost it, so White's haul is the
|
||||||
|
// captured Black pieces, and vice versa.
|
||||||
|
const whiteCaptured = captured.filter(p => p.color === 1);
|
||||||
|
const blackCaptured = captured.filter(p => p.color === 0);
|
||||||
|
|
||||||
|
// Net material from pieces still on the board, so promotions count correctly.
|
||||||
|
const advantage = (activePieces ?? []).reduce(
|
||||||
|
(sum, p) => sum + (p.color === 0 ? PIECE_VALUES[p.type] : -PIECE_VALUES[p.type]), 0);
|
||||||
|
|
||||||
|
const white = { captured: whiteCaptured, advantage: Math.max(advantage, 0) };
|
||||||
|
const black = { captured: blackCaptured, advantage: Math.max(-advantage, 0) };
|
||||||
|
|
||||||
|
const isWhite = GameState.currentPlayerIsWhite !== false;
|
||||||
|
this.fillCapturedTray("bottom", isWhite ? white : black);
|
||||||
|
this.fillCapturedTray("top", isWhite ? black : white);
|
||||||
|
},
|
||||||
|
|
||||||
|
fillCapturedTray(position, side) {
|
||||||
|
const tray = document.getElementById(`captured-${position}`);
|
||||||
|
const badge = document.getElementById(`advantage-${position}`);
|
||||||
|
if (!tray || !badge) return;
|
||||||
|
|
||||||
|
tray.innerHTML = "";
|
||||||
|
[...side.captured]
|
||||||
|
.sort((a, b) => PIECE_VALUES[a.type] - PIECE_VALUES[b.type])
|
||||||
|
.forEach(piece => {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = ChessUtils.getPieceImageUrl(piece);
|
||||||
|
img.alt = piece.type;
|
||||||
|
img.className = "capturedPiece";
|
||||||
|
img.draggable = false;
|
||||||
|
tray.appendChild(img);
|
||||||
|
});
|
||||||
|
|
||||||
|
badge.textContent = side.advantage > 0 ? `+${side.advantage}` : "";
|
||||||
|
},
|
||||||
|
|
||||||
clearAllSquares() {
|
clearAllSquares() {
|
||||||
for (let i = 0; i < 64; i++) {
|
for (let i = 0; i < 64; i++) {
|
||||||
const square = document.getElementById(`square-${i}`);
|
const square = document.getElementById(`square-${i}`);
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ const ChessModals = {
|
|||||||
resolve(difficulty);
|
resolve(difficulty);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
promptColor() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
document.getElementById("colorModal").style.display = "block";
|
||||||
|
window.selectColor = (color) => {
|
||||||
|
document.getElementById("colorModal").style.display = "none";
|
||||||
|
resolve(color);
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const ChessSignalR = {
|
|||||||
console.error("❌ SignalR connection closed:", err?.message);
|
console.error("❌ SignalR connection closed:", err?.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto) => {
|
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto, state) => {
|
||||||
await this.handleMoveUpdate(gameId, moveDto, moveResultDto);
|
await this.handleMoveUpdate(gameId, moveDto, moveResultDto, state);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => {
|
this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => {
|
||||||
@@ -28,12 +28,14 @@ const ChessSignalR = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleMoveUpdate(gameId, moveDto, moveResultDto) {
|
async handleMoveUpdate(gameId, moveDto, moveResultDto, state) {
|
||||||
if (gameId !== GameState.currentGameId) return;
|
if (gameId !== GameState.currentGameId) return;
|
||||||
|
|
||||||
const gameState = await ChessAPI.getGameState(gameId);
|
// Drop the echo of our own move and any out-of-order delivery.
|
||||||
|
if (!GameState.shouldApply(state?.version)) return;
|
||||||
|
|
||||||
GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]);
|
GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]);
|
||||||
ChessBoard.renderPieces(gameState.pieces);
|
ChessBoard.renderState(state);
|
||||||
|
|
||||||
ChessAPI.alertGameStatusChange(moveResultDto);
|
ChessAPI.alertGameStatusChange(moveResultDto);
|
||||||
},
|
},
|
||||||
@@ -50,12 +52,6 @@ const ChessSignalR = {
|
|||||||
await this.leaveGame();
|
await this.leaveGame();
|
||||||
},
|
},
|
||||||
|
|
||||||
async notifyMoveMade(moveDto, moveResult) {
|
|
||||||
if (this.connection) {
|
|
||||||
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async leaveGame() {
|
async leaveGame() {
|
||||||
if (this.connection) {
|
if (this.connection) {
|
||||||
await this.connection.invoke("LeaveWebsocketGroup", GameState.currentGameId);
|
await this.connection.invoke("LeaveWebsocketGroup", GameState.currentGameId);
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ const GameState = {
|
|||||||
legalMoves: [],
|
legalMoves: [],
|
||||||
previousMoveStart: null,
|
previousMoveStart: null,
|
||||||
previousMoveEnd: null,
|
previousMoveEnd: null,
|
||||||
|
// Highest ply (move count) already rendered. Lets us ignore stale or
|
||||||
|
// already-applied updates that arrive out of order, including the echo
|
||||||
|
// of our own move.
|
||||||
|
lastVersion: -1,
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.currentGameId = null;
|
this.currentGameId = null;
|
||||||
@@ -15,12 +19,22 @@ const GameState = {
|
|||||||
this.legalMoves = [];
|
this.legalMoves = [];
|
||||||
this.previousMoveStart = null;
|
this.previousMoveStart = null;
|
||||||
this.previousMoveEnd = null;
|
this.previousMoveEnd = null;
|
||||||
|
this.lastVersion = -1;
|
||||||
|
},
|
||||||
|
|
||||||
|
shouldApply(version) {
|
||||||
|
return typeof version !== "number" || version > this.lastVersion;
|
||||||
|
},
|
||||||
|
|
||||||
|
setVersion(version) {
|
||||||
|
if (typeof version === "number") this.lastVersion = version;
|
||||||
},
|
},
|
||||||
|
|
||||||
setGameInfo(gameId, playerId, isWhite) {
|
setGameInfo(gameId, playerId, isWhite) {
|
||||||
this.currentGameId = gameId;
|
this.currentGameId = gameId;
|
||||||
this.currentPlayerId = playerId;
|
this.currentPlayerId = playerId;
|
||||||
this.currentPlayerIsWhite = isWhite;
|
this.currentPlayerIsWhite = isWhite;
|
||||||
|
this.lastVersion = -1;
|
||||||
},
|
},
|
||||||
|
|
||||||
setSelectedPiece(piece) {
|
setSelectedPiece(piece) {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const Spectate = {
|
|||||||
.configureLogging(signalR.LogLevel.Warning)
|
.configureLogging(signalR.LogLevel.Warning)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto) =>
|
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto, _moveResult, state) =>
|
||||||
this.handleMoveUpdate(gameId, moveDto));
|
this.handleMoveUpdate(gameId, moveDto, state));
|
||||||
|
|
||||||
this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId));
|
this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId));
|
||||||
|
|
||||||
@@ -23,14 +23,50 @@ const Spectate = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.refreshGames();
|
await this.refreshGames();
|
||||||
|
await this.loadAutoTrainCount();
|
||||||
setInterval(() => this.refreshGames(), 5000);
|
setInterval(() => this.refreshGames(), 5000);
|
||||||
},
|
},
|
||||||
|
|
||||||
async startCpuGame() {
|
// Auto-training runs server-side; show its target count and let it be changed here.
|
||||||
const difficulty = document.getElementById("cpuDifficulty").value;
|
async loadAutoTrainCount() {
|
||||||
|
const input = document.getElementById("autoTrainCount");
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/chess/watch/cpu/${difficulty}`);
|
const response = await fetch("/api/chess/autotrain");
|
||||||
|
const data = await response.json();
|
||||||
|
input.max = data.max;
|
||||||
|
// Don't clobber the value while the user is editing it.
|
||||||
|
if (document.activeElement !== input)
|
||||||
|
input.value = data.count;
|
||||||
|
} catch {
|
||||||
|
// Leave the control as-is if auto-training status can't be read.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setAutoTrainCount() {
|
||||||
|
const input = document.getElementById("autoTrainCount");
|
||||||
|
const count = Math.max(0, parseInt(input.value, 10) || 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/chess/autotrain?count=${count}`, { method: "POST" });
|
||||||
|
const data = await response.json();
|
||||||
|
input.value = data.count;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Could not set the auto-training game count.", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async startCpuGame() {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
whiteEngine: document.getElementById("whiteEngine").value,
|
||||||
|
whiteSkill: document.getElementById("whiteSkill").value,
|
||||||
|
blackEngine: document.getElementById("blackEngine").value,
|
||||||
|
blackSkill: document.getElementById("blackSkill").value
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/chess/watch/cpu?${params}`);
|
||||||
await this.refreshGames();
|
await this.refreshGames();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("❌ Could not start CPU vs CPU game.", err);
|
console.error("❌ Could not start CPU vs CPU game.", err);
|
||||||
@@ -68,7 +104,9 @@ const Spectate = {
|
|||||||
async addGame(game) {
|
async addGame(game) {
|
||||||
this.games.set(game.gameId, {
|
this.games.set(game.gameId, {
|
||||||
isVsComputer: game.isVsComputer,
|
isVsComputer: game.isVsComputer,
|
||||||
isComputerVsComputer: game.isComputerVsComputer
|
isComputerVsComputer: game.isComputerVsComputer,
|
||||||
|
whiteEngine: game.whiteEngine,
|
||||||
|
blackEngine: game.blackEngine
|
||||||
});
|
});
|
||||||
|
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
@@ -117,7 +155,10 @@ const Spectate = {
|
|||||||
|
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
|
|
||||||
const state = await response.json();
|
this.renderFromState(gameId, await response.json());
|
||||||
|
},
|
||||||
|
|
||||||
|
renderFromState(gameId, state) {
|
||||||
const result = this.resultTextFromState(state);
|
const result = this.resultTextFromState(state);
|
||||||
const stored = this.games.get(gameId);
|
const stored = this.games.get(gameId);
|
||||||
|
|
||||||
@@ -128,10 +169,13 @@ const Spectate = {
|
|||||||
this.setResult(gameId, result);
|
this.setResult(gameId, result);
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleMoveUpdate(gameId, moveDto) {
|
async handleMoveUpdate(gameId, moveDto, state) {
|
||||||
if (!this.games.has(gameId)) return;
|
if (!this.games.has(gameId)) return;
|
||||||
|
|
||||||
await this.renderGame(gameId);
|
// Render from the pushed state; fall back to a fetch only if it's missing.
|
||||||
|
if (state) this.renderFromState(gameId, state);
|
||||||
|
else await this.renderGame(gameId);
|
||||||
|
|
||||||
this.highlightMove(gameId, moveDto);
|
this.highlightMove(gameId, moveDto);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -177,11 +221,21 @@ const Spectate = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
gameLabel(stored) {
|
gameLabel(stored) {
|
||||||
if (stored.isComputerVsComputer) return "CPU vs CPU";
|
if (stored.isComputerVsComputer)
|
||||||
|
return `${this.engineName(stored.whiteEngine)} (W) vs ${this.engineName(stored.blackEngine)} (B)`;
|
||||||
if (stored.isVsComputer) return "Vs CPU";
|
if (stored.isVsComputer) return "Vs CPU";
|
||||||
return "Player vs Player";
|
return "Player vs Player";
|
||||||
},
|
},
|
||||||
|
|
||||||
|
engineName(kind) {
|
||||||
|
switch (kind) {
|
||||||
|
case "CustomLearned": return "Learned";
|
||||||
|
case "Custom": return "Custom";
|
||||||
|
case "Stockfish": return "Stockfish";
|
||||||
|
default: return kind || "CPU";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
headerText(stored, currentPlayer, moveCount, isCheck) {
|
headerText(stored, currentPlayer, moveCount, isCheck) {
|
||||||
if (stored.result)
|
if (stored.result)
|
||||||
return `${this.gameLabel(stored)} · move ${moveCount} · final`;
|
return `${this.gameLabel(stored)} · move ${moveCount} · final`;
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
const Weights = {
|
||||||
|
async init() {
|
||||||
|
await this.refresh();
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
let data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/chess/weights");
|
||||||
|
data = await response.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Could not load weights.", err);
|
||||||
|
document.getElementById("weightsStatus").textContent = "Could not load weights.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderBoardSet(data.mg, "weightsGridMg");
|
||||||
|
this.renderBoardSet(data.eg, "weightsGridEg");
|
||||||
|
this.renderFeatures(data.features);
|
||||||
|
|
||||||
|
const trained = [...data.mg, ...data.eg].some(b => b.squares.some(v => v !== 0))
|
||||||
|
|| data.features.some(f => f.value !== 0);
|
||||||
|
|
||||||
|
document.getElementById("weightsStatus").textContent = trained
|
||||||
|
? "Where the learned engine thinks each piece belongs. Blue = preferred, red = avoided. Midgame vs endgame tables are blended by how much material is left."
|
||||||
|
: "No training yet — everything is neutral. Run some learned games on the Watch page.";
|
||||||
|
},
|
||||||
|
|
||||||
|
// pieces: [{ name, squares[64] }]. Prepends an "Overall" board summing the set.
|
||||||
|
renderBoardSet(pieces, containerId) {
|
||||||
|
const overall = new Array(64).fill(0);
|
||||||
|
for (const piece of pieces)
|
||||||
|
for (let sq = 0; sq < 64; sq++)
|
||||||
|
overall[sq] += piece.squares[sq];
|
||||||
|
|
||||||
|
const boards = [{ name: "Overall", squares: overall }, ...pieces];
|
||||||
|
const grid = document.getElementById(containerId);
|
||||||
|
grid.innerHTML = "";
|
||||||
|
boards.forEach(board => grid.appendChild(this.buildBoard(board)));
|
||||||
|
},
|
||||||
|
|
||||||
|
buildBoard(board) {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "weightBoard";
|
||||||
|
|
||||||
|
const maxAbs = board.squares.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
|
||||||
|
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "weightBoardHeader";
|
||||||
|
if (board.name !== "Overall") {
|
||||||
|
const icon = document.createElement("img");
|
||||||
|
icon.src = `/images/Chess Images/White${board.name}.svg`;
|
||||||
|
icon.alt = board.name;
|
||||||
|
icon.className = "weightBoardIcon";
|
||||||
|
header.appendChild(icon);
|
||||||
|
}
|
||||||
|
const title = document.createElement("span");
|
||||||
|
title.textContent = board.name;
|
||||||
|
header.appendChild(title);
|
||||||
|
const range = document.createElement("span");
|
||||||
|
range.className = "weightRange";
|
||||||
|
range.textContent = maxAbs === 0 ? "neutral" : `±${maxAbs}`;
|
||||||
|
header.appendChild(range);
|
||||||
|
wrapper.appendChild(header);
|
||||||
|
|
||||||
|
const heat = document.createElement("div");
|
||||||
|
heat.className = "miniHeat";
|
||||||
|
|
||||||
|
for (let row = 0; row < 8; row++) {
|
||||||
|
const rankLabel = document.createElement("div");
|
||||||
|
rankLabel.className = "heatLabel";
|
||||||
|
rankLabel.textContent = 8 - row; // rank 8 at top, 1 at bottom
|
||||||
|
heat.appendChild(rankLabel);
|
||||||
|
|
||||||
|
for (let col = 0; col < 8; col++) {
|
||||||
|
const rank = 7 - row; // rank index, 0 = rank 1
|
||||||
|
const sq = rank * 8 + col; // white-relative square (A1 = 0)
|
||||||
|
heat.appendChild(this.buildSquare(board.squares[sq], sq, maxAbs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
heat.appendChild(this.cornerSpacer());
|
||||||
|
for (let col = 0; col < 8; col++) {
|
||||||
|
const fileLabel = document.createElement("div");
|
||||||
|
fileLabel.className = "heatLabel";
|
||||||
|
fileLabel.textContent = String.fromCharCode(97 + col);
|
||||||
|
heat.appendChild(fileLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.appendChild(heat);
|
||||||
|
return wrapper;
|
||||||
|
},
|
||||||
|
|
||||||
|
buildSquare(value, sq, maxAbs) {
|
||||||
|
const cell = document.createElement("div");
|
||||||
|
cell.className = "heatSquare";
|
||||||
|
|
||||||
|
if (value !== 0 && maxAbs > 0) {
|
||||||
|
const ratio = Math.abs(value) / maxAbs;
|
||||||
|
const alpha = (0.12 + 0.88 * ratio).toFixed(3);
|
||||||
|
cell.style.backgroundColor = value > 0
|
||||||
|
? `rgba(74, 134, 232, ${alpha})` // high -> blue
|
||||||
|
: `rgba(232, 74, 74, ${alpha})`; // low -> red
|
||||||
|
cell.textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = String.fromCharCode(97 + (sq & 7));
|
||||||
|
const rank = (sq >> 3) + 1;
|
||||||
|
cell.title = `${file}${rank}: ${value}`;
|
||||||
|
return cell;
|
||||||
|
},
|
||||||
|
|
||||||
|
cornerSpacer() {
|
||||||
|
const spacer = document.createElement("div");
|
||||||
|
spacer.className = "heatLabel";
|
||||||
|
return spacer;
|
||||||
|
},
|
||||||
|
|
||||||
|
// features: [{ name, value }]
|
||||||
|
renderFeatures(features) {
|
||||||
|
const panel = document.getElementById("featureWeights");
|
||||||
|
panel.innerHTML = "";
|
||||||
|
|
||||||
|
const maxAbs = features.reduce((m, f) => Math.max(m, Math.abs(f.value)), 0);
|
||||||
|
|
||||||
|
features.forEach(feature => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "featureRow";
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "featureLabel";
|
||||||
|
label.textContent = feature.name;
|
||||||
|
|
||||||
|
const track = document.createElement("div");
|
||||||
|
track.className = "featureTrack";
|
||||||
|
const bar = document.createElement("div");
|
||||||
|
bar.className = "featureBar";
|
||||||
|
const ratio = maxAbs === 0 ? 0 : Math.abs(feature.value) / maxAbs;
|
||||||
|
bar.style.width = `${(ratio * 100).toFixed(1)}%`;
|
||||||
|
bar.style.backgroundColor = feature.value >= 0
|
||||||
|
? "rgba(74, 134, 232, 0.85)"
|
||||||
|
: "rgba(232, 74, 74, 0.85)";
|
||||||
|
track.appendChild(bar);
|
||||||
|
|
||||||
|
const value = document.createElement("span");
|
||||||
|
value.className = "featureValue";
|
||||||
|
value.textContent = feature.value;
|
||||||
|
|
||||||
|
row.append(label, track, value);
|
||||||
|
panel.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("load", () => Weights.init());
|
||||||
|
|
||||||
|
console.log("Weights.js loaded");
|
||||||
@@ -30,6 +30,18 @@ function resetCopyPgn() {
|
|||||||
window.copyPgn = copyPgn;
|
window.copyPgn = copyPgn;
|
||||||
window.showCopyPgn = showCopyPgn;
|
window.showCopyPgn = showCopyPgn;
|
||||||
|
|
||||||
|
// Mobile slide-up menu (move list + secondary actions).
|
||||||
|
function toggleMenu() {
|
||||||
|
document.body.classList.toggle("menu-open");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
document.body.classList.remove("menu-open");
|
||||||
|
}
|
||||||
|
|
||||||
|
window.toggleMenu = toggleMenu;
|
||||||
|
window.closeMenu = closeMenu;
|
||||||
|
|
||||||
async function forfeitCurrentGame() {
|
async function forfeitCurrentGame() {
|
||||||
const gameId = ChessUtils.getCookie("chessGameId");
|
const gameId = ChessUtils.getCookie("chessGameId");
|
||||||
const playerId = ChessUtils.getCookie("chessPlayerId");
|
const playerId = ChessUtils.getCookie("chessPlayerId");
|
||||||
@@ -44,6 +56,7 @@ async function forfeitCurrentGame() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startNewGame() {
|
async function startNewGame() {
|
||||||
|
closeMenu();
|
||||||
await ChessSignalR.stopConnection();
|
await ChessSignalR.stopConnection();
|
||||||
await forfeitCurrentGame();
|
await forfeitCurrentGame();
|
||||||
resetCopyPgn();
|
resetCopyPgn();
|
||||||
@@ -63,7 +76,7 @@ async function startNewGame() {
|
|||||||
await ChessSignalR.setupConnection();
|
await ChessSignalR.setupConnection();
|
||||||
|
|
||||||
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||||
ChessBoard.renderPieces(gameState.pieces);
|
ChessBoard.renderState(gameState);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start new game:", error);
|
console.error("Failed to start new game:", error);
|
||||||
@@ -71,13 +84,15 @@ async function startNewGame() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startCPUGame() {
|
async function startCPUGame() {
|
||||||
|
closeMenu();
|
||||||
await ChessSignalR.stopConnection();
|
await ChessSignalR.stopConnection();
|
||||||
await forfeitCurrentGame();
|
await forfeitCurrentGame();
|
||||||
resetCopyPgn();
|
resetCopyPgn();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const difficulty = await ChessModals.promptDifficulty();
|
const difficulty = await ChessModals.promptDifficulty();
|
||||||
const gameData = await ChessAPI.createCPUGame(difficulty);
|
const color = await ChessModals.promptColor();
|
||||||
|
const gameData = await ChessAPI.createCPUGame(difficulty, color);
|
||||||
|
|
||||||
GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite);
|
GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite);
|
||||||
GameState.clearPreviousMove();
|
GameState.clearPreviousMove();
|
||||||
@@ -91,7 +106,7 @@ async function startCPUGame() {
|
|||||||
await ChessSignalR.setupConnection();
|
await ChessSignalR.setupConnection();
|
||||||
|
|
||||||
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||||
ChessBoard.renderPieces(gameState.pieces);
|
ChessBoard.renderState(gameState);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start CPU game:", error);
|
console.error("Failed to start CPU game:", error);
|
||||||
@@ -111,7 +126,7 @@ async function resumeSavedGame() {
|
|||||||
GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true");
|
GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true");
|
||||||
|
|
||||||
await ChessSignalR.setupConnection();
|
await ChessSignalR.setupConnection();
|
||||||
ChessBoard.renderPieces(gameState.pieces);
|
ChessBoard.renderState(gameState);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Saved game not found or expired.", err);
|
console.warn("Saved game not found or expired.", err);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
|||||||
|
// "Add chips" — a rebuy, a top-up, or the first buy-in of the night.
|
||||||
|
//
|
||||||
|
// Quick amounts come off the table's starting stack, because a rebuy is almost
|
||||||
|
// always one buy-in, sometimes half or two. The keypad is there for the times it
|
||||||
|
// isn't. Recorded as cash in as well as chips, so at the end of the night the
|
||||||
|
// dealer can say what each player actually handed over.
|
||||||
|
const AddChips = {
|
||||||
|
state: null,
|
||||||
|
onConfirm: null,
|
||||||
|
seatIndex: null,
|
||||||
|
draft: "",
|
||||||
|
|
||||||
|
open(state, seatIndex, onConfirm) {
|
||||||
|
this.state = state;
|
||||||
|
this.seatIndex = seatIndex;
|
||||||
|
this.onConfirm = onConfirm;
|
||||||
|
this.draft = String(this.baseAmount());
|
||||||
|
this.render();
|
||||||
|
document.getElementById("addChipsOverlay").hidden = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
close() {
|
||||||
|
document.getElementById("addChipsOverlay").hidden = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// The table's starting stack if there is one, otherwise something sane to nudge
|
||||||
|
// rather than an empty box.
|
||||||
|
baseAmount() {
|
||||||
|
const configured = Number(this.state.defaultStack);
|
||||||
|
if (!Number.isNaN(configured) && configured > 0) return configured;
|
||||||
|
const bb = Number(this.state.bigBlind) || 0;
|
||||||
|
return bb > 0 ? bb * 100 : 100;
|
||||||
|
},
|
||||||
|
|
||||||
|
quickAmounts() {
|
||||||
|
const base = this.baseAmount();
|
||||||
|
return [
|
||||||
|
{ label: "½ buy-in", value: Money.round(base / 2) },
|
||||||
|
{ label: "Buy-in", value: base },
|
||||||
|
{ label: "2 buy-ins", value: base * 2 },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const player = this.state.seats[this.seatIndex];
|
||||||
|
document.getElementById("addChipsTitle").textContent =
|
||||||
|
`Add chips — ${HoldemState.displayName(player, this.seatIndex)}`;
|
||||||
|
|
||||||
|
const stack = Betting.stackOf(this.state, player);
|
||||||
|
const boughtIn = Number(player.buyIns) || 0;
|
||||||
|
document.getElementById("addChipsSubtitle").textContent = boughtIn > 0
|
||||||
|
? `${Money.format(stack)} in front · ${Money.format(boughtIn)} in so far`
|
||||||
|
: `${Money.format(stack)} in front`;
|
||||||
|
|
||||||
|
const quick = document.getElementById("addChipsQuick");
|
||||||
|
quick.innerHTML = "";
|
||||||
|
this.quickAmounts().forEach(option => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "addChipsQuickBtn";
|
||||||
|
btn.classList.toggle("selected", Number(this.draft) === option.value);
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "addChipsQuickLabel";
|
||||||
|
label.textContent = option.label;
|
||||||
|
const amount = document.createElement("span");
|
||||||
|
amount.className = "addChipsQuickAmount";
|
||||||
|
amount.textContent = Money.format(option.value);
|
||||||
|
|
||||||
|
btn.appendChild(label);
|
||||||
|
btn.appendChild(amount);
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
this.draft = String(option.value);
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
quick.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = document.getElementById("addChipsInput");
|
||||||
|
input.value = this.draft;
|
||||||
|
},
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
const amount = Math.max(0, Number(document.getElementById("addChipsInput").value) || 0);
|
||||||
|
this.close();
|
||||||
|
if (amount > 0) this.onConfirm(this.seatIndex, Money.round(amount));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = AddChips;
|
||||||
|
}
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
// 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 Money.round(amount);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Reading the table ----
|
||||||
|
|
||||||
|
liveSeats(state) {
|
||||||
|
return state.seats
|
||||||
|
.map((player, index) => ({ player, index }))
|
||||||
|
.filter(s => s.player.inHand && !s.player.folded);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Who would post if the hand were dealt right now. Used to preview the blinds
|
||||||
|
// while the table is still being set, so a dealer can see where they'll land
|
||||||
|
// before any money moves.
|
||||||
|
// A seat that is dealt in, has nothing in the pot, and has no chips behind.
|
||||||
|
//
|
||||||
|
// Not the same as all-in: an all-in player has money riding on the hand. This is
|
||||||
|
// somebody who busted and hasn't bought back in yet, which a home game produces
|
||||||
|
// about once an hour. A seat with no stack *recorded* isn't broke either —
|
||||||
|
// stackOf falls back to the table's starting stack for those — only an explicit
|
||||||
|
// zero counts.
|
||||||
|
brokeSeats(state) {
|
||||||
|
const broke = [];
|
||||||
|
state.seats.forEach((player, index) => {
|
||||||
|
if (!player.inHand) return;
|
||||||
|
if (this.handTotal(player) > this.EPSILON_CHIPS) return;
|
||||||
|
if (this.stackOf(state, player) > this.EPSILON_CHIPS) return;
|
||||||
|
broke.push(index);
|
||||||
|
});
|
||||||
|
return broke;
|
||||||
|
},
|
||||||
|
|
||||||
|
// The table as it will be once the seats that can't post are dealt around. A view,
|
||||||
|
// not a change: the preview has to ask the same question the deal will answer, and
|
||||||
|
// asking it of a different shape of table is how the two came apart last time.
|
||||||
|
tableAsDealt(state) {
|
||||||
|
const broke = this.brokeSeats(state);
|
||||||
|
if (broke.length === 0) return state;
|
||||||
|
const skip = new Set(broke);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
seats: state.seats.map((player, index) =>
|
||||||
|
skip.has(index) ? { ...player, inHand: false, folded: false } : player),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
blindPreview(state) {
|
||||||
|
// Dealing relocates a button parked on an empty chair before it works out who
|
||||||
|
// posts, so the preview has to answer for the button where it *will* be. Asking
|
||||||
|
// from where it currently sits gave a different pair of blinds than the deal
|
||||||
|
// produced — the preview promising one thing and doing another is exactly the
|
||||||
|
// guessing this whole step exists to remove. Same reason the broke seats come
|
||||||
|
// out first: the preview once promised a small blind from a seat with no chips.
|
||||||
|
const dealt = this.tableAsDealt(state);
|
||||||
|
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
|
||||||
|
const asDealt = buttonIndex === dealt.dealerSeatIndex
|
||||||
|
? dealt
|
||||||
|
: { ...dealt, dealerSeatIndex: buttonIndex };
|
||||||
|
|
||||||
|
const satOut = this.brokeSeats(state);
|
||||||
|
const { sbIndex, bbIndex, headsUp, manual, deadSmallBlind } = HoldemState.getBlindSeats(asDealt);
|
||||||
|
if (bbIndex === null) return null;
|
||||||
|
return {
|
||||||
|
sbIndex, bbIndex, headsUp, manual: !!manual, deadSmallBlind: !!deadSmallBlind,
|
||||||
|
deadButton: !!asDealt.deadButton && !asDealt.seats[buttonIndex].inHand,
|
||||||
|
// Asked of the table as it will be dealt, for the same reason the blinds
|
||||||
|
// are: someone sitting out for having no chips isn't owed a blind, and the
|
||||||
|
// big blind of the hand about to go out isn't either.
|
||||||
|
debtors: HoldemState.blindDebtors(asDealt, bbIndex),
|
||||||
|
posting: (state.postingIn || []).slice(),
|
||||||
|
buttonIndex,
|
||||||
|
buttonMoves: buttonIndex !== state.dealerSeatIndex,
|
||||||
|
satOut,
|
||||||
|
smallBlind: this.round(state.smallBlind || 0),
|
||||||
|
bigBlind: this.round(state.bigBlind || 0),
|
||||||
|
// What the straddles will be if the hand is dealt as it stands, and how
|
||||||
|
// many more this table could take.
|
||||||
|
straddles: this.straddleSeats(state),
|
||||||
|
maxStraddles: this.maxStraddles(state),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Straddles ----
|
||||||
|
//
|
||||||
|
// A straddle is a blind raise put out before the cards, by the player left of the
|
||||||
|
// big blind, for twice it. It buys the last word before the flop: everyone else
|
||||||
|
// acts first, and the straddler still has the option when it comes back round even
|
||||||
|
// if nobody raised. Re-straddles carry on left, each doubling again.
|
||||||
|
//
|
||||||
|
// Home games run them constantly and a dealer has to get three things right at
|
||||||
|
// once — the amount, who is first to act, and what the minimum raise becomes. The
|
||||||
|
// tool used to do none of them, so a dealer's only way through was to deal, have
|
||||||
|
// the straddler "raise" to $4, and hand the action to the wrong player.
|
||||||
|
|
||||||
|
// How many straddles this table could take. Everyone but the two blinds can put
|
||||||
|
// one out; heads-up that is nobody, which is the right answer — the button is
|
||||||
|
// already the small blind and there is no seat left of the big blind to straddle
|
||||||
|
// from.
|
||||||
|
maxStraddles(state) {
|
||||||
|
return Math.max(0, this.liveSeats(this.tableAsDealt(state)).length - 2);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Who straddles and for how much, walking left from the big blind and doubling.
|
||||||
|
// Asked of the table as it will be dealt, so a seat about to be sat out for having
|
||||||
|
// no chips is never handed a straddle it can't post.
|
||||||
|
straddleSeats(state, count = state.straddleCount) {
|
||||||
|
const wanted = Math.min(Math.max(0, Number(count) || 0), this.maxStraddles(state));
|
||||||
|
if (wanted <= 0) return [];
|
||||||
|
|
||||||
|
const dealt = this.tableAsDealt(state);
|
||||||
|
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
|
||||||
|
const asDealt = buttonIndex === dealt.dealerSeatIndex
|
||||||
|
? dealt
|
||||||
|
: { ...dealt, dealerSeatIndex: buttonIndex };
|
||||||
|
const { bbIndex } = HoldemState.getBlindSeats(asDealt);
|
||||||
|
if (bbIndex === null) return [];
|
||||||
|
|
||||||
|
const straddles = [];
|
||||||
|
let index = bbIndex;
|
||||||
|
let amount = this.round((Number(state.bigBlind) || 0) * 2);
|
||||||
|
for (let n = 0; n < wanted; n++) {
|
||||||
|
index = this.nextToAct(asDealt, index);
|
||||||
|
if (index === null || index === bbIndex) break;
|
||||||
|
straddles.push({ index, amount });
|
||||||
|
amount = this.round(amount * 2);
|
||||||
|
}
|
||||||
|
return straddles;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Does handing this seat chips also put them back in the game?
|
||||||
|
//
|
||||||
|
// At a real table those are one event, and here they were two: a busted player got
|
||||||
|
// their chips and stayed sat out, silently skipped hand after hand until somebody
|
||||||
|
// noticed and found "Deal in" in the seat menu. Nothing said so — the toast just
|
||||||
|
// read "Ben added $100".
|
||||||
|
//
|
||||||
|
// Not for a seat that was sat out while *holding* chips. That's a dealer who sat
|
||||||
|
// somebody out on purpose and is topping them up, and dealing them back in would be
|
||||||
|
// the tool guessing at something it wasn't told. Only a seat with no stack of its
|
||||||
|
// own: busted at zero, or a chair being bought into for the first time.
|
||||||
|
//
|
||||||
|
// Deliberately reads player.stack rather than stackOf(), which substitutes the
|
||||||
|
// table's starting stack for a seat that has none — the question here is what this
|
||||||
|
// seat was actually recorded as holding.
|
||||||
|
buyInDealsThemIn(player) {
|
||||||
|
if (!player || player.inHand) return false;
|
||||||
|
const own = player.stack;
|
||||||
|
const recorded = own !== null && own !== undefined && String(own).trim() !== "";
|
||||||
|
return !recorded || Number(own) <= this.EPSILON_CHIPS;
|
||||||
|
},
|
||||||
|
|
||||||
|
// "bet" or "raise" — what putting more in is actually called right now.
|
||||||
|
//
|
||||||
|
// A wager into a pot nobody has wagered into is a bet; everything after that is a
|
||||||
|
// raise. The bar decided this from what the acting player still owes, which gets
|
||||||
|
// the big blind's option backwards: they owe nothing, so it offered "Bet", but
|
||||||
|
// their own blind is a live bet and putting more in is a raise. It gets the other
|
||||||
|
// half wrong too — the raise pad said "Raise to" for an opening bet on the flop,
|
||||||
|
// and wrote "Ann's raise to $10" into the undo control for it.
|
||||||
|
//
|
||||||
|
// The arithmetic was always right (minRaiseTo returns two big blinds for the big
|
||||||
|
// blind's option and one for an opening bet). This is only what it's called, which
|
||||||
|
// for a tool a dealer announces the action from is not a small thing.
|
||||||
|
wagerVerb(state) {
|
||||||
|
return (state.currentBet || 0) > this.EPSILON_CHIPS ? "raise" : "bet";
|
||||||
|
},
|
||||||
|
|
||||||
|
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));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Never below zero. A seat can't have negative chips in front of it, and a typo in
|
||||||
|
// the stack field used to come back as-is — which then flowed into putIn(), where
|
||||||
|
// `Math.min(amount, -50)` moves a negative amount and *adds* chips to the table.
|
||||||
|
stackOf(state, player) {
|
||||||
|
const own = player.stack;
|
||||||
|
if (own !== null && own !== undefined && String(own).trim() !== "") {
|
||||||
|
return Math.max(0, this.round(own));
|
||||||
|
}
|
||||||
|
const fallback = Number(state.defaultStack);
|
||||||
|
return Number.isNaN(fallback) ? 0 : Math.max(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) return index;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Who the dealer asks next. A player who is all-in is still in the hand and still
|
||||||
|
// wins pots, but has no decision left, so the action passes over them.
|
||||||
|
nextToAct(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) > this.EPSILON_CHIPS) return index;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// A player still in the hand with chips in the pot and nothing left behind. This is
|
||||||
|
// the same stack test nextToAct uses to pass over them, which is the point: the
|
||||||
|
// seat the felt marks all-in is exactly the seat the dealer will never be asked to
|
||||||
|
// act on again.
|
||||||
|
//
|
||||||
|
// The "chips in the pot" half matters. A seat with an empty stack who hasn't put
|
||||||
|
// anything in is a busted player who shouldn't have been dealt in, not a player
|
||||||
|
// who moved all-in, and calling them all-in on the felt would be a lie.
|
||||||
|
isAllIn(state, player) {
|
||||||
|
if (!player || !player.inHand || player.folded) return false;
|
||||||
|
return this.handTotal(player) > this.EPSILON_CHIPS &&
|
||||||
|
this.stackOf(state, player) <= this.EPSILON_CHIPS;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Which chair sits `step` places along in the direction of play. The table owns
|
||||||
|
// this — walking the seats is exactly where the button, the blinds and the action
|
||||||
|
// used to drift apart from each other, so there is one implementation of it.
|
||||||
|
seatAt(state, fromIndex, step) {
|
||||||
|
return HoldemState.nextSeatIndex(state, fromIndex, step);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
const previousBet = state.currentBet || 0;
|
||||||
|
|
||||||
|
if (newBet > previousBet) {
|
||||||
|
const increase = this.round(newBet - previousBet);
|
||||||
|
// Aggression, whether or not it was a full raise — a short all-in doesn't
|
||||||
|
// reopen the betting, but the player who made it is still the last one who
|
||||||
|
// put money in, and at the showdown they are still the one who shows first.
|
||||||
|
state.streetAggressorIndex = index;
|
||||||
|
state.handAggressorIndex = index;
|
||||||
|
// What a full raise would have had to be: at least as much again as the
|
||||||
|
// last one, or the big blind if there hasn't been a raise yet.
|
||||||
|
const fullRaise = this.round(state.lastRaiseSize || state.bigBlind || 0);
|
||||||
|
const isFullRaise = increase >= fullRaise - this.EPSILON_CHIPS;
|
||||||
|
|
||||||
|
state.currentBet = newBet;
|
||||||
|
|
||||||
|
if (isFullRaise) {
|
||||||
|
state.lastRaiseSize = increase;
|
||||||
|
// A real 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Otherwise this is a short all-in — somebody with less than a full raise
|
||||||
|
// left shoving it in. The most misread rule in poker: it does *not* reopen
|
||||||
|
// the betting to anyone who has already acted. They owe the difference or
|
||||||
|
// they fold; they don't get to raise again because a short stack happened
|
||||||
|
// to be between them. Players yet to act keep their full rights, which
|
||||||
|
// they do here because their hasActed is already false.
|
||||||
|
//
|
||||||
|
// lastRaiseSize deliberately isn't lowered either, or the next legal raise
|
||||||
|
// would be computed off the short amount and come out too small.
|
||||||
|
}
|
||||||
|
|
||||||
|
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.nextToAct(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;
|
||||||
|
// Whoever led this street stops being the answer the moment the next one opens.
|
||||||
|
// The hand's aggressor is deliberately left alone: it's the fallback for a
|
||||||
|
// river nobody could bet on.
|
||||||
|
state.streetAggressorIndex = null;
|
||||||
|
|
||||||
|
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
|
||||||
|
state.street = this.STREETS[nextStreetIndex] || "river";
|
||||||
|
|
||||||
|
// It takes two players with chips to have a bet. Once everyone left is all-in
|
||||||
|
// the rest of the board is just dealt out.
|
||||||
|
if (!this.canStillBet(state)) {
|
||||||
|
state.actingSeatIndex = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the flop the first live player left of the button acts — which heads-up
|
||||||
|
// is the big blind, since the button is the small blind. Naming the big blind
|
||||||
|
// directly also covers a button parked on a seat nobody is sitting in, and it
|
||||||
|
// still has to be someone with chips left to bet.
|
||||||
|
const { bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||||
|
const bigBlindCanAct =
|
||||||
|
headsUp &&
|
||||||
|
bbIndex !== null &&
|
||||||
|
!state.seats[bbIndex].folded &&
|
||||||
|
this.stackOf(state, state.seats[bbIndex]) > this.EPSILON_CHIPS;
|
||||||
|
|
||||||
|
state.actingSeatIndex = bigBlindCanAct
|
||||||
|
? bbIndex
|
||||||
|
: this.nextToAct(state, state.dealerSeatIndex);
|
||||||
|
},
|
||||||
|
|
||||||
|
isHandOver(state) {
|
||||||
|
return this.liveSeats(state).length <= 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Players still in the hand who have chips left to put in. Fewer than two of them
|
||||||
|
// means the betting is finished even though the hand isn't.
|
||||||
|
seatsWithChips(state) {
|
||||||
|
return this.liveSeats(state).filter(s => this.stackOf(state, s.player) > this.EPSILON_CHIPS);
|
||||||
|
},
|
||||||
|
|
||||||
|
EPSILON_CHIPS: Money.EPSILON,
|
||||||
|
|
||||||
|
canStillBet(state) {
|
||||||
|
return this.liveSeats(state).length > 1 && this.seatsWithChips(state).length > 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
// True when the hand is still live but nobody can bet again — the dealer is just
|
||||||
|
// running the board out.
|
||||||
|
isRunningOut(state) {
|
||||||
|
// Only while a hand is actually being played. Before the deal, a table of
|
||||||
|
// busted players who haven't rebought looks exactly like a table of all-ins to
|
||||||
|
// this test, and the bar was announcing a run-out over a felt with no cards and
|
||||||
|
// no chips on it.
|
||||||
|
if (!state.bettingOpen) return false;
|
||||||
|
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
|
||||||
|
},
|
||||||
|
|
||||||
|
// True while nothing has happened in this hand except the blinds going in. Up to
|
||||||
|
// that point the dealer can still move the button or rename the blinds and have
|
||||||
|
// it cost nothing — which is the state a home game spends a lot of time in,
|
||||||
|
// arguing about whose turn it is before the cards come out.
|
||||||
|
onlyBlindsIn(state) {
|
||||||
|
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pulls the blinds back off the table and posts them again from wherever the
|
||||||
|
// button and the blind assignment now say they belong. Refunding first is what
|
||||||
|
// keeps the chip count straight when the button gets passed round three or four
|
||||||
|
// times before anybody is happy with it.
|
||||||
|
repostBlinds(state) {
|
||||||
|
state.seats.forEach(player => {
|
||||||
|
const back = this.committed(player);
|
||||||
|
if (back > 0) player.stack = this.round(this.stackOf(state, player) + back);
|
||||||
|
player.committed = 0;
|
||||||
|
player.hasActed = false;
|
||||||
|
});
|
||||||
|
this.beginHand(state);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// Called when the dealer says the table is ready, not automatically when the
|
||||||
|
// previous hand ends — everything before this point is setup, where seats and the
|
||||||
|
// button can move freely because no money has been committed.
|
||||||
|
beginHand(state) {
|
||||||
|
state.bettingOpen = true;
|
||||||
|
state.street = "preflop";
|
||||||
|
state.currentBet = 0;
|
||||||
|
state.lastRaiseSize = 0;
|
||||||
|
// Posting a blind isn't aggression — a hand limped and checked all the way
|
||||||
|
// down has no aggressor at all, which is exactly the case the showdown rule
|
||||||
|
// sends to the seat left of the button.
|
||||||
|
state.streetAggressorIndex = null;
|
||||||
|
state.handAggressorIndex = null;
|
||||||
|
|
||||||
|
// Deal around anyone with no chips, before anything else reads the table.
|
||||||
|
//
|
||||||
|
// A busted player who hasn't rebought used to be dealt in anyway, and the
|
||||||
|
// result was a hand nobody could finish: the table promised a small blind from
|
||||||
|
// a seat with nothing to post it with, took it from nobody, put the action on
|
||||||
|
// the big blind with only their own money in front of them, and announced
|
||||||
|
// "3 players — all-in, running out the board" before a card was dealt.
|
||||||
|
//
|
||||||
|
// Sitting them out is what a dealer does. Seat, name and buy-in record are
|
||||||
|
// untouched, so dealing them back in is one tap when they buy chips.
|
||||||
|
this.brokeSeats(state).forEach(index => {
|
||||||
|
state.seats[index].inHand = false;
|
||||||
|
state.seats[index].folded = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Position is read off the button all through the hand, so it has to be on
|
||||||
|
// somebody before the blinds are worked out — not on the empty seat 1 a
|
||||||
|
// brand-new table starts on.
|
||||||
|
HoldemState.ensureButtonOnPlayer(state);
|
||||||
|
|
||||||
|
state.seats.forEach(player => {
|
||||||
|
player.committed = 0;
|
||||||
|
player.hasActed = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sb = Number(state.smallBlind) || 0;
|
||||||
|
const bb = Number(state.bigBlind) || 0;
|
||||||
|
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||||
|
|
||||||
|
// A null big blind means there aren't enough players to post at all. A null
|
||||||
|
// small blind with a big blind behind it is the dead small blind, which is a
|
||||||
|
// hand that gets dealt like any other — it just has one blind in it.
|
||||||
|
if (bbIndex === null) {
|
||||||
|
state.currentBet = 0;
|
||||||
|
state.actingSeatIndex = null;
|
||||||
|
state.postedBlinds = null;
|
||||||
|
state.postedStraddles = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sb > 0 && sbIndex !== null) this.putIn(state, sbIndex, sb);
|
||||||
|
if (bb > 0) this.putIn(state, bbIndex, bb);
|
||||||
|
|
||||||
|
// Anyone the dealer said would post to come in past the blinds. It's a big
|
||||||
|
// blind like any other — it buys them a hand they'd otherwise have to wait for
|
||||||
|
// — so it goes in the middle the same way, before the count below reads it.
|
||||||
|
const posting = Array.isArray(state.postingIn) ? state.postingIn : [];
|
||||||
|
if (bb > 0) {
|
||||||
|
state.seats.forEach((player, index) => {
|
||||||
|
if (index === sbIndex || index === bbIndex) return;
|
||||||
|
if (player.inHand && posting.includes(player.id)) this.putIn(state, index, bb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Deliberately not cleared here. repostBlinds takes every posted chip back and
|
||||||
|
// deals again through this same function, so a post that cleared itself on the
|
||||||
|
// way in would be refunded and never retaken — the dealer moves the button and
|
||||||
|
// somebody's post quietly vanishes. startNextHand is what ends it.
|
||||||
|
|
||||||
|
// What the next hand's rotation is worked out from. Recorded here, where the
|
||||||
|
// blinds are actually posted, because by the time it's needed the seats have
|
||||||
|
// moved on and the answer can no longer be read off the table.
|
||||||
|
state.postedBlinds = { sbIndex, bbIndex };
|
||||||
|
|
||||||
|
// Who got cards. Read next hand to tell a player who has been here all along
|
||||||
|
// from one who has just walked up and had the blinds go past them.
|
||||||
|
state.dealtLastHand = state.seats.filter(p => p.inHand).map(p => p.id);
|
||||||
|
|
||||||
|
// Straddles go out after the blinds and before the cards, each one left of the
|
||||||
|
// last. Posted here rather than left to the dealer to fake as a raise, because
|
||||||
|
// a raise from that seat would hand the action to the wrong player.
|
||||||
|
const straddles = this.straddleSeats(state);
|
||||||
|
straddles.forEach(({ index, amount }) => this.putIn(state, index, amount));
|
||||||
|
state.postedStraddles = straddles.map(({ index }) => index);
|
||||||
|
|
||||||
|
state.currentBet = this.round(state.seats.reduce(
|
||||||
|
(most, player) => Math.max(most, this.committed(player)), 0));
|
||||||
|
// A straddle is a blind raise, so what has to be matched to raise again is the
|
||||||
|
// straddle itself, not the big blind underneath it: $2 blinds with a $4
|
||||||
|
// straddle make the next raise $8, the same way the $2 blind makes it $4.
|
||||||
|
// Left alone, the minimum would come out at $6 and the dealer would be talked
|
||||||
|
// out of it by every player at the table.
|
||||||
|
state.lastRaiseSize = straddles.length > 0 ? state.currentBet : (bb || 0);
|
||||||
|
|
||||||
|
// Heads-up the button is the small blind and acts first before the flop; with
|
||||||
|
// three or more it's the seat after the big blind. Either way, a blind big
|
||||||
|
// enough to put that player all-in leaves them nothing to decide.
|
||||||
|
//
|
||||||
|
// A straddle moves that seat along: first to act is left of the last straddler,
|
||||||
|
// and the straddler keeps the option, which falls out of nobody having acted
|
||||||
|
// yet.
|
||||||
|
const lastPosted = straddles.length > 0
|
||||||
|
? straddles[straddles.length - 1].index
|
||||||
|
: bbIndex;
|
||||||
|
state.actingSeatIndex = headsUp
|
||||||
|
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
|
||||||
|
: this.nextToAct(state, lastPosted);
|
||||||
|
},
|
||||||
|
|
||||||
|
// What a pot-sized raise actually is, as a "raise to" total.
|
||||||
|
//
|
||||||
|
// Not "the bet plus the pot as it stands". The raiser calls first, and their call
|
||||||
|
// is in the pot before the raise is measured off it, so the pot being matched is
|
||||||
|
// the one that exists after the call:
|
||||||
|
//
|
||||||
|
// raise to = the current bet + (pot + what they owe) × fraction
|
||||||
|
//
|
||||||
|
// Pre-flop at 1/2 that gives $7, not $5: call the $2, which makes the pot $5, then
|
||||||
|
// raise $5. Leaving the call out understates every one of these, and players say
|
||||||
|
// "pot" out loud meaning a definite number.
|
||||||
|
//
|
||||||
|
// Facing no bet this is a plain pot-sized bet — toCall is zero and currentBet is
|
||||||
|
// zero, so it comes out as the pot, which is the right answer there too.
|
||||||
|
// Rounded up to something the table's chips can actually build. Half the pot is
|
||||||
|
// constantly a half-chip amount — pre-flop at 1/2 it is $4.50, and the smallest
|
||||||
|
// chip in the default legend is a dollar, so the button offered a number the
|
||||||
|
// dealer would have to round themselves every single time.
|
||||||
|
//
|
||||||
|
// Only when the legend describes this game. A smallest chip *larger* than the big
|
||||||
|
// blind means the denominations were never set for these stakes, and rounding a
|
||||||
|
// 5c/10c table up to whole dollars would be the tool doing real damage on the
|
||||||
|
// strength of a field somebody forgot to fill in. Left exact there, and the wrong
|
||||||
|
// legend stays visible rather than being quietly applied.
|
||||||
|
potRaiseTo(state, index, fraction = 1) {
|
||||||
|
const player = state.seats[index];
|
||||||
|
if (!player) return 0;
|
||||||
|
const potAfterCall = this.round(this.potSoFar(state) + this.toCall(state, player));
|
||||||
|
const exact = this.round((state.currentBet || 0) + potAfterCall * fraction);
|
||||||
|
|
||||||
|
const step = Money.fromCents(Money.chipStepCents(state.chipLegend));
|
||||||
|
const bigBlind = Number(state.bigBlind) || 0;
|
||||||
|
if (step <= 0 || (bigBlind > 0 && step > bigBlind)) return exact;
|
||||||
|
return Money.upToChip(exact, state.chipLegend);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Who turns their hand over first.
|
||||||
|
//
|
||||||
|
// A call the dealer makes at every showdown that isn't a fold, and one nobody at
|
||||||
|
// the table volunteers for — everyone would rather see a hand than show one. The
|
||||||
|
// tool knew who last raised and who sits left of the button and said neither, so
|
||||||
|
// the dealer was working it out from memory while five people waited.
|
||||||
|
//
|
||||||
|
// Three cases, in the order the rule asks them:
|
||||||
|
// * somebody bet or raised on the final round — they show first;
|
||||||
|
// * the round was checked through — first live player left of the button;
|
||||||
|
// * nobody could bet, because the chips were already in — the last player who
|
||||||
|
// did put money in, on whichever round that was.
|
||||||
|
//
|
||||||
|
// Returns null when there's nothing to call: one player left, or none.
|
||||||
|
showdownOrder(state) {
|
||||||
|
const live = this.liveSeats(state);
|
||||||
|
if (live.length < 2) return null;
|
||||||
|
|
||||||
|
const seatOf = index =>
|
||||||
|
Number.isInteger(index) && live.some(s => s.index === index) ? index : null;
|
||||||
|
|
||||||
|
const streetAggressor = seatOf(state.streetAggressorIndex);
|
||||||
|
if (streetAggressor !== null) {
|
||||||
|
return { index: streetAggressor, reason: "aggressor" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nobody could have bet this round, so "no bet on the final round" isn't a
|
||||||
|
// choice anybody made and the seat left of the button hasn't earned the
|
||||||
|
// obligation. It falls to whoever last put money in.
|
||||||
|
if (!this.canStillBet(state)) {
|
||||||
|
const handAggressor = seatOf(state.handAggressorIndex);
|
||||||
|
if (handAggressor !== null) return { index: handAggressor, reason: "allInAggressor" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = HoldemState.liveOrderFromButton(state)
|
||||||
|
.filter(index => live.some(s => s.index === index));
|
||||||
|
return order.length > 0 ? { index: order[0], reason: "leftOfButton" } : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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,127 @@
|
|||||||
|
// 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 "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Money columns go in as bare numbers. Written as "$1,250,000.00" they import as
|
||||||
|
// text, so the one thing anybody opens this file to do — total a column and see
|
||||||
|
// who owes what — doesn't work without cleaning every cell first. The commas were
|
||||||
|
// quoted correctly, so the file parsed; it just wouldn't add up.
|
||||||
|
toCsv(handHistory, standings = [], { notes = "" } = {}) {
|
||||||
|
// The rake gets its own column rather than being folded into the pot: the two
|
||||||
|
// numbers answer different questions — what was in the middle, and what the
|
||||||
|
// house took out of it — and a night's worth of the second one is what the box
|
||||||
|
// is counted against.
|
||||||
|
const rows = [["Hand #", "Time", "Pot", "House take", "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.forSheet(entry.pot),
|
||||||
|
Money.forSheet(entry.rake || 0),
|
||||||
|
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"]);
|
||||||
|
// Bought in and chips now are what a cash-out is settled from; paid in and
|
||||||
|
// won describe the hands. Both belong in the file the dealer hands over.
|
||||||
|
//
|
||||||
|
// Notes go last so the money columns keep the shape a spreadsheet expects.
|
||||||
|
// "IOU: $20 to Ann" is exactly the kind of thing that has to be in the file
|
||||||
|
// somebody settles up from, and it was only ever visible inside that seat's
|
||||||
|
// own editor.
|
||||||
|
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]);
|
||||||
|
standings.forEach(row => {
|
||||||
|
rows.push([
|
||||||
|
row.name,
|
||||||
|
Money.forSheet(row.in),
|
||||||
|
Money.forSheet(row.won),
|
||||||
|
Money.forSheet(row.net),
|
||||||
|
Money.forSheet(row.boughtIn || 0),
|
||||||
|
Money.forSheet(row.chipsNow || 0),
|
||||||
|
Money.forSheet(row.cashPosition || 0),
|
||||||
|
row.notes || "",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// And the answer the standings are read to get to. Whoever opens this file
|
||||||
|
// wants the payment list, not to redo the arithmetic in a spreadsheet.
|
||||||
|
const plan = SettleUp.plan(standings);
|
||||||
|
if (plan.payments.length > 0) {
|
||||||
|
rows.push([]);
|
||||||
|
rows.push(["Settle Up"]);
|
||||||
|
rows.push(["Pays", "Receives", "Amount"]);
|
||||||
|
plan.payments.forEach(payment => {
|
||||||
|
rows.push([payment.from, payment.to, Money.forSheet(payment.amount)]);
|
||||||
|
});
|
||||||
|
if (!plan.balanced) {
|
||||||
|
// The file is the paper trail, so the caveat travels with it rather
|
||||||
|
// than living only in a sidebar nobody exported.
|
||||||
|
rows.push([
|
||||||
|
plan.shortBy > 0
|
||||||
|
? `Unpaid: ${Money.forSheet(plan.shortBy)} — more chips in play than cash taken`
|
||||||
|
: `Unclaimed: ${Money.forSheet(plan.overBy)} — more cash taken than chips in play`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The table's own notes — side bets, IOUs, the promo running until midnight.
|
||||||
|
// The dealer typed them because they matter to the night, and the export is the
|
||||||
|
// thing that outlives the browser tab; leaving them behind meant the one place
|
||||||
|
// the context was written down was the one place it couldn't be handed over.
|
||||||
|
// csvField quotes the line breaks, so a multi-line note survives as one cell.
|
||||||
|
if (notes && notes.trim()) {
|
||||||
|
rows.push([]);
|
||||||
|
rows.push(["Table Notes"]);
|
||||||
|
rows.push([notes.trim()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = [], options = {}) {
|
||||||
|
const csv = this.toCsv(handHistory, standings, options);
|
||||||
|
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,721 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const state = HoldemState.load();
|
||||||
|
|
||||||
|
// Focus, Tab-trapping and Escape for every overlay, wired once by watching the
|
||||||
|
// `hidden` attribute they all already toggle.
|
||||||
|
Modal.mount();
|
||||||
|
|
||||||
|
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
|
||||||
|
// by reference to every module at mount time, so it has to be refilled in place —
|
||||||
|
// reassigning the variable would leave the renderer and the betting bar pointing at
|
||||||
|
// the old object and quietly ignoring the undo.
|
||||||
|
const snapshotState = () => JSON.parse(JSON.stringify(state));
|
||||||
|
|
||||||
|
const restoreState = snapshot => {
|
||||||
|
Object.keys(state).forEach(key => delete state[key]);
|
||||||
|
Object.assign(state, snapshot);
|
||||||
|
HoldemState.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 = snapshotState();
|
||||||
|
mutate(state.seats[index], index);
|
||||||
|
HoldemState.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
|
||||||
|
Toast.show(message, {
|
||||||
|
actionLabel: "Undo",
|
||||||
|
onAction: () => {
|
||||||
|
restoreState(snapshot);
|
||||||
|
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),
|
||||||
|
onGiveButton: index => seatMenuHooks.makeDealer(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 player = state.seats[index];
|
||||||
|
const dealtIn = player.inHand;
|
||||||
|
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
|
||||||
|
const alreadyIn = Betting.handTotal(player);
|
||||||
|
|
||||||
|
// Somebody walking away mid-hand doesn't get their chips back — they're
|
||||||
|
// dead money the winner collects. Say so, because the pot total pointedly
|
||||||
|
// does not go down and a dealer needs to know that's deliberate.
|
||||||
|
const message = dealtIn
|
||||||
|
? (alreadyIn > 0
|
||||||
|
? `${nameOf(index)} is out — their ${Money.format(alreadyIn)} stays in the pot.`
|
||||||
|
: `${nameOf(index)} sits out.`)
|
||||||
|
: (handRunning
|
||||||
|
? `${nameOf(index)} is in from the next hand.`
|
||||||
|
: `${nameOf(index)} is dealt in.`);
|
||||||
|
|
||||||
|
seatAction(index, message, p => {
|
||||||
|
p.inHand = !dealtIn;
|
||||||
|
if (!p.inHand) {
|
||||||
|
// Their chips stay put; only the fold flag is cleared, since being
|
||||||
|
// out of the hand already covers not winning it.
|
||||||
|
p.folded = false;
|
||||||
|
if (state.actingSeatIndex === index) Betting.passAction(state, index);
|
||||||
|
} else if (handRunning) {
|
||||||
|
// Joining a hand already in progress means sitting this one out —
|
||||||
|
// you can't buy into a pot you haven't been paying for. `folded`
|
||||||
|
// is exactly that state, and the next hand clears it.
|
||||||
|
p.folded = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Two players change chairs. Undoable like every other seat action, because a
|
||||||
|
// swap is easy to aim wrong and the seats look alike at a glance.
|
||||||
|
swapSeats: (from, to) => {
|
||||||
|
const movedName = nameOf(from);
|
||||||
|
const intoName = nameOf(to);
|
||||||
|
const wasEmpty = Render.isEmptySeat(state.seats[to]);
|
||||||
|
runUndoable(
|
||||||
|
wasEmpty
|
||||||
|
? `${movedName} moved to seat ${to + 1}.`
|
||||||
|
: `${movedName} and ${intoName} changed seats.`,
|
||||||
|
() => HoldemState.swapSeats(state, from, to));
|
||||||
|
},
|
||||||
|
|
||||||
|
makeDealer: index => {
|
||||||
|
// Before the deal there's nothing posted, so this is just moving a marker.
|
||||||
|
// After it, the blinds have to follow the button or the badges and the
|
||||||
|
// money disagree — but that's a real thing happening to real chips, and
|
||||||
|
// the dealer gets told rather than finding out later.
|
||||||
|
const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) &&
|
||||||
|
HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS;
|
||||||
|
|
||||||
|
seatAction(index, willRepost
|
||||||
|
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
|
||||||
|
: `Button on ${nameOf(index)}.`, () => {
|
||||||
|
state.dealerSeatIndex = index;
|
||||||
|
// Putting the button somewhere by hand is the dealer overruling the
|
||||||
|
// rotation, dead positions included. Leaving them set would hold a
|
||||||
|
// small blind dead at a table whose button has moved out from under it.
|
||||||
|
state.deadButton = false;
|
||||||
|
state.deadSmallBlind = false;
|
||||||
|
if (willRepost) Betting.repostBlinds(state);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
assignBlind: (index, which) => {
|
||||||
|
const blinds = HoldemState.getBlindSeats(state);
|
||||||
|
const other = which === "sb" ? blinds.bbIndex : blinds.sbIndex;
|
||||||
|
// Naming one blind pins the other where it already is, so the dealer only
|
||||||
|
// has to state the part they're changing. If that would land both on one
|
||||||
|
// player, the other blind moves to the next seat along.
|
||||||
|
let partner = other !== null && other !== undefined && other !== index
|
||||||
|
? other
|
||||||
|
: Betting.nextLiveIndex(state, index);
|
||||||
|
if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index);
|
||||||
|
|
||||||
|
const label = which === "sb" ? "small blind" : "big blind";
|
||||||
|
seatAction(index, `${nameOf(index)} posts the ${label}.`, () => {
|
||||||
|
state.blindOverride = which === "sb"
|
||||||
|
? { sbIndex: index, bbIndex: partner }
|
||||||
|
: { sbIndex: partner, bbIndex: index };
|
||||||
|
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => {
|
||||||
|
state.blindOverride = null;
|
||||||
|
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||||
|
}),
|
||||||
|
|
||||||
|
editSeat: index => Render.toggleSeatEditor(index),
|
||||||
|
|
||||||
|
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
|
||||||
|
// Buying chips puts you back in the game — see Betting.buyInDealsThemIn.
|
||||||
|
const dealBackIn = Betting.buyInDealsThemIn(state.seats[seatIndex]);
|
||||||
|
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
|
||||||
|
|
||||||
|
const message = `${nameOf(seatIndex)} added ${Money.format(amount)}` +
|
||||||
|
(dealBackIn
|
||||||
|
? handRunning ? " — in from the next hand." : " — dealt in."
|
||||||
|
: ".");
|
||||||
|
|
||||||
|
seatAction(seatIndex, message, p => {
|
||||||
|
// Chips in front and cash in are different questions — the stack moves
|
||||||
|
// every hand, what they've handed over only moves when they buy.
|
||||||
|
p.stack = Money.round(Betting.stackOf(state, p) + amount);
|
||||||
|
p.buyIns = Money.round((Number(p.buyIns) || 0) + amount);
|
||||||
|
|
||||||
|
if (dealBackIn) {
|
||||||
|
p.inHand = true;
|
||||||
|
// Same rule "Deal in" follows: you can't buy into a pot you haven't
|
||||||
|
// been paying for, and `folded` is exactly that state for one hand.
|
||||||
|
p.folded = handRunning;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
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, { rake = 0 } = {}) => {
|
||||||
|
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
||||||
|
const withRake = rake > 0 ? `${paid}, house ${Money.format(rake)}` : paid;
|
||||||
|
runUndoable(withRake ? `Paid ${withRake}.` : "Hand closed with nothing to pay out.", () => {
|
||||||
|
if (rake > 0) {
|
||||||
|
state.rakeTaken = Money.round((Number(state.rakeTaken) || 0) + rake);
|
||||||
|
}
|
||||||
|
if (payouts.length > 0) {
|
||||||
|
HoldemState.recordHand(state, payouts, rake);
|
||||||
|
// Winnings go straight back in front of the player, so the next
|
||||||
|
// hand's stacks are already right.
|
||||||
|
Betting.creditWinnings(state, payouts);
|
||||||
|
}
|
||||||
|
// Settling a pot ends the hand; it doesn't start the next one. The
|
||||||
|
// table goes back to setup so people can sit down or cash out before
|
||||||
|
// any blinds are committed.
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// A short history of actions taken from the bar, so a misclicked Fold can be taken
|
||||||
|
// back — and the one before it, since a dealer usually notices a beat late. Kept in
|
||||||
|
// memory only: this is for the mistake you just made, not last night's hand.
|
||||||
|
const MAX_UNDO_STEPS = 12;
|
||||||
|
const actionHistory = [];
|
||||||
|
|
||||||
|
ActionBar.mount(state, {
|
||||||
|
save: hooks.save,
|
||||||
|
rerender: hooks.rerender,
|
||||||
|
resolvePot: openPotWizard,
|
||||||
|
|
||||||
|
beginAction: label => {
|
||||||
|
actionHistory.push({ label, snapshot: snapshotState() });
|
||||||
|
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
|
||||||
|
},
|
||||||
|
|
||||||
|
lastActionLabel: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].label : null),
|
||||||
|
|
||||||
|
undoLast: () => {
|
||||||
|
const step = actionHistory.pop();
|
||||||
|
if (!step) return;
|
||||||
|
restoreState(step.snapshot);
|
||||||
|
Toast.show(`Took back ${step.label}.`, { duration: 2500 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Anything that ends the hand makes the per-action history meaningless — the
|
||||||
|
// snapshots refer to a hand that no longer exists.
|
||||||
|
const clearActionHistory = () => { actionHistory.length = 0; };
|
||||||
|
|
||||||
|
// Keyboard shortcuts press the very same buttons a dealer would tap, rather than
|
||||||
|
// calling the betting engine themselves — there's no second code path to drift out
|
||||||
|
// of step with what the bar is actually offering right now.
|
||||||
|
const pressInBar = selector => {
|
||||||
|
const btn = document.querySelector(`#actionBar ${selector}`);
|
||||||
|
if (!btn || btn.disabled) return false;
|
||||||
|
btn.click();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const quickRaiseButton = label =>
|
||||||
|
[...document.querySelectorAll("#actionBar .raiseQuickBtn")]
|
||||||
|
.find(b => b.querySelector(".actionBtnLabel").textContent === label);
|
||||||
|
|
||||||
|
Shortcuts.mount({
|
||||||
|
fold: () => pressInBar(".actionFold"),
|
||||||
|
checkOrCall: () => pressInBar(".actionCheck") || pressInBar(".actionCall"),
|
||||||
|
raise: () => pressInBar(".actionRaise"),
|
||||||
|
allIn: () => {
|
||||||
|
// Open the raise pad first if it isn't already, then take its All-in amount.
|
||||||
|
if (!document.querySelector("#actionBar .raisePad") && !pressInBar(".actionRaise")) return false;
|
||||||
|
const quick = quickRaiseButton("All-in");
|
||||||
|
if (!quick) return false;
|
||||||
|
quick.click();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
// Enter confirms a raise if one is being entered, otherwise moves the hand on.
|
||||||
|
// Dealing is an advance too — Enter should carry a dealer through setup, deal,
|
||||||
|
// streets and showdown without reaching for the trackpad.
|
||||||
|
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance") || pressInBar(".actionDeal"),
|
||||||
|
undo: () => pressInBar(".actionUndo"),
|
||||||
|
cancel: () => pressInBar(".raiseCancel"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 = snapshotState();
|
||||||
|
mutate();
|
||||||
|
// Starting or settling a hand invalidates the per-action history: those
|
||||||
|
// snapshots describe a hand that's over. The whole-step Undo below replaces it.
|
||||||
|
clearActionHistory();
|
||||||
|
hooks.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
|
||||||
|
Toast.show(message, {
|
||||||
|
actionLabel: "Undo",
|
||||||
|
onAction: () => {
|
||||||
|
restoreState(snapshot);
|
||||||
|
Toast.show("Undone.", { duration: 2500 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// A seat that has anything of a player in it — a name, chips, cash in, or a hand
|
||||||
|
// in progress. Shrinking the table throws these away permanently, and it also
|
||||||
|
// orphans them in the session standings, where they keep their winnings but lose
|
||||||
|
// the stack and buy-in the settle-up is worked out from.
|
||||||
|
const seatHasSomeoneIn = player =>
|
||||||
|
!!(player.name && player.name.trim()) ||
|
||||||
|
player.inHand ||
|
||||||
|
(Number(player.stack) || 0) > 0 ||
|
||||||
|
(Number(player.buyIns) || 0) > 0 ||
|
||||||
|
(Number(player.amountOverride) || 0) > 0;
|
||||||
|
|
||||||
|
document.getElementById("seatCountInput").addEventListener("change", async e => {
|
||||||
|
const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount));
|
||||||
|
const losing = state.seats.slice(requested).filter(seatHasSomeoneIn);
|
||||||
|
|
||||||
|
if (losing.length > 0) {
|
||||||
|
const who = losing
|
||||||
|
.map((p, i) => HoldemState.displayName(p, requested + state.seats.slice(requested).indexOf(p)))
|
||||||
|
.join(", ");
|
||||||
|
const ok = await Dialog.confirm({
|
||||||
|
title: `Remove ${losing.length} ${losing.length === 1 ? "player" : "players"}?`,
|
||||||
|
message: `Going down to ${requested} seats takes ${who} off the table along with their chips and buy-ins, and their session totals stop adding up.`,
|
||||||
|
confirmLabel: "Remove them",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
e.target.value = state.seatCount;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runUndoable(`Table set to ${requested} seats.`, () => HoldemState.resizeSeats(state, requested));
|
||||||
|
});
|
||||||
|
|
||||||
|
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", async () => {
|
||||||
|
// Moving on wipes every seat's contribution, so an unresolved pot is simply
|
||||||
|
// destroyed — measured at a four-handed table, pressing this with $8 in the
|
||||||
|
// middle took the table from 800 chips to 792, and the toast said nothing
|
||||||
|
// about it. Chips only ever leave this tool because a dealer meant them to.
|
||||||
|
const unresolved = HoldemState.unresolvedContributions(state);
|
||||||
|
|
||||||
|
if (unresolved > Betting.EPSILON_CHIPS) {
|
||||||
|
const ok = await Dialog.confirm({
|
||||||
|
title: `Throw away ${Money.format(unresolved)}?`,
|
||||||
|
message: "There's still money in the middle from this hand. Starting the next one takes it off the table without paying anybody — use Resolve Pot if somebody won it.",
|
||||||
|
confirmLabel: "Throw it away",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Say where the button landed so the dealer can confirm the rotation without
|
||||||
|
// hunting for the badge. Asked of the rotation itself rather than guessed at as
|
||||||
|
// "the next chair along" — which was only ever right at a table with nobody
|
||||||
|
// missing, and is the exact thing the dead button makes untrue.
|
||||||
|
const rotation = HoldemState.nextButtonRotation(state);
|
||||||
|
const nextDealer = rotation.buttonIndex !== null
|
||||||
|
? rotation.buttonIndex
|
||||||
|
: HoldemState.buttonSeatForDeal({ ...state, dealerSeatIndex: HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1) });
|
||||||
|
const where = rotation.deadButton
|
||||||
|
? `dead button on seat ${nextDealer + 1}`
|
||||||
|
: state.dedicatedDealer
|
||||||
|
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
|
||||||
|
: `button on seat ${nextDealer + 1}`;
|
||||||
|
const discarded = unresolved > Betting.EPSILON_CHIPS
|
||||||
|
? ` ${Money.format(unresolved)} taken off the table.`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
|
||||||
|
// Written down as well as announced. The toast is a moment; the bank check
|
||||||
|
// is asked at cash-out, and until now it saw only that the chips were gone
|
||||||
|
// and reported them as missing.
|
||||||
|
if (unresolved > Betting.EPSILON_CHIPS) {
|
||||||
|
state.discarded = Money.round((Number(state.discarded) || 0) + unresolved);
|
||||||
|
}
|
||||||
|
HoldemState.startNextHand(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());
|
||||||
|
clearActionHistory();
|
||||||
|
Render.mount(state, hooks);
|
||||||
|
Toast.show("Table reset.", { duration: 2500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// A pre-filled amount is a suggestion, so reaching for the box replaces it.
|
||||||
|
//
|
||||||
|
// Delegated from the document rather than wired per field: the raise pad, the
|
||||||
|
// add-chips sheet and the seat editor all build their inputs fresh every render,
|
||||||
|
// and a box added later should not have to remember to ask for this.
|
||||||
|
document.addEventListener("focusin", event => {
|
||||||
|
const field = event.target;
|
||||||
|
if (!Money.selectsOnFocus(field)) return;
|
||||||
|
if (!field.value) return;
|
||||||
|
// After the browser has finished placing its own caret, or it wins the race
|
||||||
|
// and the selection collapses again the moment the tap completes.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.activeElement !== field) return;
|
||||||
|
try {
|
||||||
|
field.select();
|
||||||
|
} catch {
|
||||||
|
// A number input refuses setSelectionRange in some browsers; select()
|
||||||
|
// is the one that works on both, and if neither does the field is
|
||||||
|
// simply left as it was.
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The house's cut. Kept as a group rather than three loose fields, because
|
||||||
|
// migrate() guarantees the shape and half a rake object would be a table nobody
|
||||||
|
// could read back.
|
||||||
|
const readRake = () => {
|
||||||
|
const percent = Number(document.getElementById("rakePercentInput").value);
|
||||||
|
const cap = Number(document.getElementById("rakeCapInput").value);
|
||||||
|
state.rake = {
|
||||||
|
percent: Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0,
|
||||||
|
cap: Number.isFinite(cap) && cap > 0 ? Money.round(cap) : null,
|
||||||
|
noFlopNoDrop: document.getElementById("rakeNoFlopInput").checked,
|
||||||
|
};
|
||||||
|
hooks.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
};
|
||||||
|
["rakePercentInput", "rakeCapInput"].forEach(id => {
|
||||||
|
document.getElementById(id).addEventListener("input", readRake);
|
||||||
|
});
|
||||||
|
document.getElementById("rakeNoFlopInput").addEventListener("change", readRake);
|
||||||
|
|
||||||
|
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
|
||||||
|
|
||||||
|
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
|
||||||
|
document.getElementById("addChipsCancelBtn").addEventListener("click", () => AddChips.close());
|
||||||
|
document.getElementById("addChipsOverlay").addEventListener("click", e => {
|
||||||
|
if (e.target.id === "addChipsOverlay") AddChips.close();
|
||||||
|
});
|
||||||
|
document.getElementById("addChipsInput").addEventListener("keydown", e => {
|
||||||
|
if (e.key === "Enter") AddChips.confirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape is Modal's job now, for this sheet and the five others that never had it.
|
||||||
|
const rulingsOverlay = document.getElementById("rulingsOverlay");
|
||||||
|
const closeRulings = () => { rulingsOverlay.hidden = true; };
|
||||||
|
// The one time the browser refuses to save, say so out loud as well as leaving the
|
||||||
|
// standing warning in the Session panel. Set before the first render so a failure
|
||||||
|
// during start-up is caught too.
|
||||||
|
// Another tab writing to the same table. Said once, with the way out attached —
|
||||||
|
// reloading here adopts whatever that tab has done, which is the only resolution
|
||||||
|
// that doesn't throw somebody's work away.
|
||||||
|
HoldemState.onOtherTab = () => {
|
||||||
|
Toast.show(
|
||||||
|
"This table is open in another tab. Whichever acts last wins.",
|
||||||
|
{ actionLabel: "Reload", onAction: () => window.location.reload(), duration: 20000 });
|
||||||
|
Render.renderStorageWarning();
|
||||||
|
};
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
|
||||||
|
HoldemState.onSaveFailed = () => {
|
||||||
|
Toast.show(
|
||||||
|
"This table isn't being saved — refreshing would lose it. Keep this tab open.",
|
||||||
|
{ duration: 12000 });
|
||||||
|
Render.renderStorageWarning();
|
||||||
|
};
|
||||||
|
|
||||||
|
// The shortcut legend, built from the same list the handlers are keyed off, so a
|
||||||
|
// shortcut can't exist without a line here saying what it does.
|
||||||
|
const legend = document.getElementById("shortcutLegend");
|
||||||
|
if (legend) {
|
||||||
|
Shortcuts.LEGEND.forEach(entry => {
|
||||||
|
const term = document.createElement("dt");
|
||||||
|
entry.keys.forEach(key => {
|
||||||
|
const kbd = document.createElement("kbd");
|
||||||
|
kbd.className = "shortcutKey";
|
||||||
|
kbd.textContent = key;
|
||||||
|
term.appendChild(kbd);
|
||||||
|
});
|
||||||
|
const detail = document.createElement("dd");
|
||||||
|
detail.textContent = entry.what;
|
||||||
|
legend.appendChild(term);
|
||||||
|
legend.appendChild(detail);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// An index across the top of the rulings sheet, one chip per ruling.
|
||||||
|
//
|
||||||
|
// This is opened mid-argument with players waiting, and it was nine rulings of
|
||||||
|
// prose with no way in: reaching the last one meant scrolling 1.6 screens on a
|
||||||
|
// desktop, 2.1 on a phone and 3.5 on a phone held sideways. A dealer looking for
|
||||||
|
// the odd-chip rule had to read past seven others to find it.
|
||||||
|
//
|
||||||
|
// Built from the sections rather than written out beside them, so a ruling added
|
||||||
|
// later turns up in the index without anyone having to remember to add it twice.
|
||||||
|
const buildRulingsIndex = () => {
|
||||||
|
const index = document.getElementById("rulingsIndex");
|
||||||
|
if (!index || index.childElementCount > 0) return;
|
||||||
|
[...rulingsOverlay.querySelectorAll(".ruling")].forEach((section, i) => {
|
||||||
|
const heading = section.querySelector("h3");
|
||||||
|
if (!heading) return;
|
||||||
|
if (!section.id) section.id = `ruling${i + 1}`;
|
||||||
|
|
||||||
|
const jump = document.createElement("button");
|
||||||
|
jump.type = "button";
|
||||||
|
jump.className = "rulingsIndexBtn";
|
||||||
|
// A short label where the ruling gives one. The headings are sentences —
|
||||||
|
// "A short all-in doesn't reopen the betting" — and nine of those stacked
|
||||||
|
// one per row made the index 342px tall on a phone, which is most of the
|
||||||
|
// sheet before the first ruling. "Short all-in" says the same thing to
|
||||||
|
// somebody who is looking for it, and three fit on a line.
|
||||||
|
jump.textContent = section.dataset.short || heading.textContent;
|
||||||
|
// The full heading is still what it announces, so the short label never
|
||||||
|
// costs a screen reader the sentence.
|
||||||
|
if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent);
|
||||||
|
jump.addEventListener("click", () => {
|
||||||
|
section.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||||
|
// The heading is what the dealer is now reading, so that is where the
|
||||||
|
// keyboard and a screen reader should be too.
|
||||||
|
heading.setAttribute("tabindex", "-1");
|
||||||
|
heading.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
index.appendChild(jump);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("openRulingsBtn").addEventListener("click", () => {
|
||||||
|
buildRulingsIndex();
|
||||||
|
rulingsOverlay.hidden = false;
|
||||||
|
});
|
||||||
|
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
|
||||||
|
rulingsOverlay.addEventListener("click", e => {
|
||||||
|
if (e.target === rulingsOverlay) closeRulings();
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
Render.historyFilter = "";
|
||||||
|
document.getElementById("handHistoryFilter").value = "";
|
||||||
|
hooks.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
Toast.show("Hand history cleared.", { duration: 2500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("saveTableBtn").addEventListener("click", () => {
|
||||||
|
const blob = new Blob([HoldemState.toBackup(state)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = HoldemState.backupFileName();
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
Toast.show(`Table saved as ${link.download}.`, { duration: 3500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("loadTableBtn").addEventListener("click", () => {
|
||||||
|
document.getElementById("loadTableInput").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("loadTableInput").addEventListener("change", async e => {
|
||||||
|
const file = e.target.files && e.target.files[0];
|
||||||
|
// Cleared straight away so picking the same file twice still fires a change.
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
text = await file.text();
|
||||||
|
} catch {
|
||||||
|
Toast.show("That file couldn't be read.", { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = HoldemState.fromBackup(text);
|
||||||
|
if (result.error) {
|
||||||
|
Toast.show(result.error, { duration: 5000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading replaces everything: stacks, buy-ins, the button, the log. Naming
|
||||||
|
// what is about to be lost is the difference between a dealer choosing that
|
||||||
|
// and discovering it — and the current table might be the live one.
|
||||||
|
const here = HoldemState.sessionStandings(state).length;
|
||||||
|
const incoming = HoldemState.sessionStandings(result.table).length;
|
||||||
|
const when = result.savedAt ? new Date(result.savedAt) : null;
|
||||||
|
const saidWhen = when && !Number.isNaN(when.getTime())
|
||||||
|
? ` It was saved ${when.toLocaleString()}.`
|
||||||
|
: "";
|
||||||
|
const ok = await Dialog.confirm({
|
||||||
|
title: "Replace this table?",
|
||||||
|
message:
|
||||||
|
`The file has a table with ${incoming} ${incoming === 1 ? "player" : "players"} in it.${saidWhen} ` +
|
||||||
|
`Loading it replaces the table on screen — ${here} ${here === 1 ? "player" : "players"}, ` +
|
||||||
|
`${(state.handHistory || []).length} logged ${(state.handHistory || []).length === 1 ? "hand" : "hands"}. ` +
|
||||||
|
`Undo will bring it back for a few seconds; after that it is gone.`,
|
||||||
|
confirmLabel: "Load it",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
runUndoable("loading a saved table", () => {
|
||||||
|
// Replace the contents rather than the reference: everything on the page
|
||||||
|
// holds a pointer to this object.
|
||||||
|
Object.keys(state).forEach(key => { delete state[key]; });
|
||||||
|
Object.assign(state, result.table);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The filter is how the dealer looks at the log, not something about the table, so
|
||||||
|
// it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other
|
||||||
|
// tab. Clearing the history clears it too, since there is nothing left to filter.
|
||||||
|
document.getElementById("handHistoryFilter").addEventListener("input", e => {
|
||||||
|
Render.historyFilter = e.target.value;
|
||||||
|
Render.renderHandHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
||||||
|
if ((state.handHistory || []).length === 0) return;
|
||||||
|
// Always the whole night. A filter is for reading the panel; an export that
|
||||||
|
// quietly dropped the hands scrolled past would be a record with holes in it,
|
||||||
|
// and nothing on the CSV would say so.
|
||||||
|
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state),
|
||||||
|
{ notes: state.notes });
|
||||||
|
if (Render.historyFilter) {
|
||||||
|
Toast.show(`Exported all ${state.handHistory.length} hands, not just the ${
|
||||||
|
state.handHistory.filter(h => Render.handMatchesFilter(h, Render.historyFilter)).length} shown.`,
|
||||||
|
{ duration: 3500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The load happens on the first line of this file, long before there's a Toast to
|
||||||
|
// show it in, so this is checked here rather than wired as a callback. The standing
|
||||||
|
// warning in the Session panel doesn't need it — that reads the flag every render —
|
||||||
|
// but an empty table is a thing a dealer needs told, not left to notice.
|
||||||
|
if (HoldemState.loadFailed) {
|
||||||
|
Toast.show(
|
||||||
|
"The saved table couldn't be read, so this one started empty.",
|
||||||
|
{ duration: 12000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Keyboard and screen-reader behaviour for every overlay in the tool, in one place.
|
||||||
|
//
|
||||||
|
// There are six of them — seat menu, card picker, add chips, rulings, pot wizard,
|
||||||
|
// confirm — opened and closed by five different modules, and each one was missing
|
||||||
|
// the same three things: focus never moved into the dialog, Tab walked straight out
|
||||||
|
// of it into the page behind the scrim, and Escape did nothing (only the rulings
|
||||||
|
// sheet handled it). Measured before this: 0 of 5 moved focus in, 5 of 5 leaked Tab,
|
||||||
|
// 1 of 5 closed on Escape.
|
||||||
|
//
|
||||||
|
// Rather than patch that into five modules, this watches the `hidden` attribute every
|
||||||
|
// overlay already toggles. Nothing else had to change, and an overlay added later
|
||||||
|
// gets the same behaviour without anyone remembering to wire it up — which is the
|
||||||
|
// failure this is fixing in the first place.
|
||||||
|
const Modal = {
|
||||||
|
// `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
|
||||||
|
// must not be a Tab stop.
|
||||||
|
FOCUSABLE: [
|
||||||
|
"a[href]",
|
||||||
|
"button:not([disabled])",
|
||||||
|
'input:not([disabled]):not([type="hidden"])',
|
||||||
|
"select:not([disabled])",
|
||||||
|
"textarea:not([disabled])",
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(","),
|
||||||
|
|
||||||
|
open: [],
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
document.querySelectorAll('[id$="Overlay"]').forEach(overlay => this.watch(overlay));
|
||||||
|
// Capture phase: the dialog gets first refusal on Escape and Tab, before any
|
||||||
|
// table-level shortcut sees them.
|
||||||
|
document.addEventListener("keydown", e => this.onKeyDown(e), true);
|
||||||
|
},
|
||||||
|
|
||||||
|
watch(overlay) {
|
||||||
|
const sync = () => {
|
||||||
|
const isOpen = !overlay.hidden;
|
||||||
|
const known = this.open.some(entry => entry.overlay === overlay);
|
||||||
|
if (isOpen && !known) this.onOpened(overlay);
|
||||||
|
else if (!isOpen && known) this.onClosed(overlay);
|
||||||
|
};
|
||||||
|
new MutationObserver(sync).observe(overlay, { attributes: true, attributeFilter: ["hidden"] });
|
||||||
|
sync();
|
||||||
|
},
|
||||||
|
|
||||||
|
sheetOf(overlay) {
|
||||||
|
return overlay.querySelector('[role="dialog"], [role="alertdialog"]') || overlay.firstElementChild || overlay;
|
||||||
|
},
|
||||||
|
|
||||||
|
focusableIn(overlay) {
|
||||||
|
return [...overlay.querySelectorAll(this.FOCUSABLE)].filter(el => {
|
||||||
|
if (el.closest("[hidden]")) return false;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return rect.width > 0 && rect.height > 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onOpened(overlay) {
|
||||||
|
const sheet = this.sheetOf(overlay);
|
||||||
|
// aria-modal is only true while the thing is actually open, so it's set here
|
||||||
|
// rather than baked into the markup.
|
||||||
|
if (sheet) sheet.setAttribute("aria-modal", "true");
|
||||||
|
|
||||||
|
this.open.push({ overlay, returnFocus: document.activeElement });
|
||||||
|
|
||||||
|
// Focus the first real control. A dialog that leaves focus on the button
|
||||||
|
// behind it reads, to a screen reader, as though nothing happened at all.
|
||||||
|
// Content-only sheets get focus on the sheet so the title is announced.
|
||||||
|
const focusable = this.focusableIn(overlay);
|
||||||
|
const target = focusable[0];
|
||||||
|
if (target) {
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
} else if (sheet) {
|
||||||
|
if (!sheet.hasAttribute("tabindex")) sheet.setAttribute("tabindex", "-1");
|
||||||
|
sheet.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onClosed(overlay) {
|
||||||
|
const index = this.open.findIndex(entry => entry.overlay === overlay);
|
||||||
|
if (index === -1) return;
|
||||||
|
const [entry] = this.open.splice(index, 1);
|
||||||
|
|
||||||
|
const sheet = this.sheetOf(overlay);
|
||||||
|
if (sheet) sheet.removeAttribute("aria-modal");
|
||||||
|
|
||||||
|
// Put the dealer back where they were. Without this, closing the pot wizard
|
||||||
|
// drops focus to the top of the document and the next Tab starts from the
|
||||||
|
// page header.
|
||||||
|
const back = entry.returnFocus;
|
||||||
|
if (back && back.isConnected && typeof back.focus === "function") {
|
||||||
|
back.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
topmost() {
|
||||||
|
return this.open.length ? this.open[this.open.length - 1] : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Escape presses the dialog's own cancel/close button rather than just hiding the
|
||||||
|
// overlay, so whatever that button does — resolving the confirm promise, clearing
|
||||||
|
// the add-chips draft — still happens. Only if there isn't one does it hide the
|
||||||
|
// overlay directly.
|
||||||
|
dismiss(overlay) {
|
||||||
|
const closer = overlay.querySelector("[data-modal-close]");
|
||||||
|
if (closer) closer.click();
|
||||||
|
else overlay.hidden = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
onKeyDown(event) {
|
||||||
|
const top = this.topmost();
|
||||||
|
if (!top) return;
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.dismiss(top.overlay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
|
||||||
|
// Keep Tab inside the dialog. Everything behind the scrim is unreachable by
|
||||||
|
// mouse, so letting the keyboard wander back there is a trap with no way out.
|
||||||
|
const focusable = this.focusableIn(top.overlay);
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
|
||||||
|
if (event.shiftKey && (active === first || !top.overlay.contains(active))) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus({ preventScroll: true });
|
||||||
|
} else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = Modal;
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Shared money handling for every casino surface. Kept apart from the table's
|
||||||
|
// rendering so the quick-split calculator can pull it in without dragging the whole
|
||||||
|
// seat/chip UI along with it.
|
||||||
|
//
|
||||||
|
// Everything the dealer touches is money, and money is decimal, so every module used
|
||||||
|
// to carry its own `Math.round(x * 100) / 100`. That's one rounding rule written out
|
||||||
|
// sixteen times in six files, which is sixteen chances for one of them to drift.
|
||||||
|
// It lives here now, and the modules that do arithmetic call these instead.
|
||||||
|
const Money = {
|
||||||
|
// Half a cent. Chip comparisons need a tolerance: a stack that lands on
|
||||||
|
// 0.0000000001 after a split is empty as far as a dealer is concerned, and an
|
||||||
|
// exact `> 0` would treat that player as still having chips to bet.
|
||||||
|
EPSILON: 0.004,
|
||||||
|
|
||||||
|
// All money maths goes through cents, so 0.1 + 0.2 never reaches a payout.
|
||||||
|
toCents(amount) {
|
||||||
|
return Math.round((Number(amount) || 0) * 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
fromCents(cents) {
|
||||||
|
return cents / 100;
|
||||||
|
},
|
||||||
|
|
||||||
|
round(amount) {
|
||||||
|
return this.toCents(amount) / 100;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cents only when there are cents.
|
||||||
|
//
|
||||||
|
// A chip game deals in whole numbers nearly all the time, and a trailing ".00"
|
||||||
|
// is two characters that say nothing — measured at ~22px, against the 64px a
|
||||||
|
// seat puck has to show a stack in. "$1,250,000.00" doesn't fit and wraps;
|
||||||
|
// "$1,250,000" does. Amounts that genuinely carry cents still show them, so a
|
||||||
|
// 25c-blind game reads exactly as it did.
|
||||||
|
format(amount) {
|
||||||
|
const value = Number(amount) || 0;
|
||||||
|
const hasCents = this.toCents(value) % 100 !== 0;
|
||||||
|
return `$${value.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: hasCents ? 2 : 0,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// The same amount for a spreadsheet rather than for a person: no currency symbol
|
||||||
|
// and no thousands separators, because those are what make a column of money
|
||||||
|
// import as text and refuse to add up.
|
||||||
|
forSheet(amount) {
|
||||||
|
return (this.toCents(amount) / 100).toFixed(2);
|
||||||
|
},
|
||||||
|
|
||||||
|
// The chips to actually push, for an amount.
|
||||||
|
//
|
||||||
|
// A payout figure is the answer to the arithmetic, not to the dealer's job: they
|
||||||
|
// still have to work out that $132 is two blacks, a green, a red and two whites
|
||||||
|
// while six people watch. The chip legend already knows the denominations.
|
||||||
|
//
|
||||||
|
// Largest first, which is greedy and therefore not optimal for an arbitrary set of
|
||||||
|
// values — but poker chips are not arbitrary. Every real set is canonical (each
|
||||||
|
// denomination divides the next: 1, 5, 25, 100, 500), and greedy is exactly optimal
|
||||||
|
// on those. A dealer who invents a 3-and-7 chip set gets a valid answer that might
|
||||||
|
// use one chip more than it had to.
|
||||||
|
//
|
||||||
|
// Anything the smallest chip can't make comes back as `remainder` rather than being
|
||||||
|
// rounded away: a 50c share in a game whose smallest chip is a dollar is a real
|
||||||
|
// problem for the dealer to solve, not a rounding error to hide.
|
||||||
|
// The smallest chip the table has said it plays with, in cents. Zero when no
|
||||||
|
// denominations are set, which means the tool has been told nothing about what
|
||||||
|
// chips exist and shouldn't pretend otherwise.
|
||||||
|
chipStepCents(legend) {
|
||||||
|
const values = (legend || [])
|
||||||
|
.map(chip => this.toCents(chip.value))
|
||||||
|
.filter(cents => cents > 0);
|
||||||
|
return values.length > 0 ? Math.min(...values) : 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rounds an amount up to something the table can actually build out of its chips.
|
||||||
|
//
|
||||||
|
// Up rather than down or nearest: these are bet sizes, and a pot-sized bet that
|
||||||
|
// has been shaved is no longer a pot-sized bet. Rounding up also can't drop an
|
||||||
|
// amount below a legal minimum, which rounding the other way can.
|
||||||
|
upToChip(amount, legend) {
|
||||||
|
const step = this.chipStepCents(legend);
|
||||||
|
if (step <= 0) return this.round(amount);
|
||||||
|
const cents = this.toCents(amount);
|
||||||
|
if (cents <= 0) return this.round(amount);
|
||||||
|
return this.fromCents(Math.ceil(cents / step) * step);
|
||||||
|
},
|
||||||
|
|
||||||
|
// And down, for money coming *out* of a pot rather than going into it.
|
||||||
|
//
|
||||||
|
// The rake is the one amount in this tool that leaves the table, so the direction
|
||||||
|
// of the rounding is a decision about whose money it is. Down: a house that can't
|
||||||
|
// make 40c out of its chips takes nothing rather than a dollar, and the players
|
||||||
|
// keep the difference. Being wrong in the players' favour is the only way to be
|
||||||
|
// wrong about this that nobody has to argue about.
|
||||||
|
downToChip(amount, legend) {
|
||||||
|
const step = this.chipStepCents(legend);
|
||||||
|
if (step <= 0) return this.round(amount);
|
||||||
|
const cents = this.toCents(amount);
|
||||||
|
if (cents <= 0) return 0;
|
||||||
|
return this.fromCents(Math.floor(cents / step) * step);
|
||||||
|
},
|
||||||
|
|
||||||
|
chipBreakdown(amount, legend) {
|
||||||
|
let cents = this.toCents(amount);
|
||||||
|
if (cents <= 0) return { chips: [], remainder: 0 };
|
||||||
|
|
||||||
|
const values = (legend || [])
|
||||||
|
.map(chip => ({ chip, cents: this.toCents(chip.value) }))
|
||||||
|
.filter(entry => entry.cents > 0)
|
||||||
|
.sort((a, b) => b.cents - a.cents);
|
||||||
|
|
||||||
|
const chips = [];
|
||||||
|
values.forEach(entry => {
|
||||||
|
const count = Math.floor(cents / entry.cents);
|
||||||
|
if (count <= 0) return;
|
||||||
|
cents -= count * entry.cents;
|
||||||
|
chips.push({ value: this.fromCents(entry.cents), count, color: entry.chip.color });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { chips, remainder: this.fromCents(cents) };
|
||||||
|
},
|
||||||
|
|
||||||
|
// "2×$50, 1×$25, 2×$1" — the breakdown as a dealer would say it out loud.
|
||||||
|
//
|
||||||
|
// A table with no chip legend gets nothing rather than "$132 no chip covers": it
|
||||||
|
// hasn't told us its denominations, so there is no advice to give and the amount
|
||||||
|
// stands on its own. A legend that exists but can't reach the amount is different,
|
||||||
|
// and does say so.
|
||||||
|
formatChipBreakdown(amount, legend) {
|
||||||
|
const hasDenominations = (legend || []).some(chip => this.toCents(chip.value) > 0);
|
||||||
|
if (!hasDenominations) return "";
|
||||||
|
|
||||||
|
const { chips, remainder } = this.chipBreakdown(amount, legend);
|
||||||
|
if (chips.length === 0 && remainder === 0) return "";
|
||||||
|
const parts = chips.map(chip => `${chip.count}×${this.format(chip.value)}`);
|
||||||
|
if (remainder > 0) parts.push(`${this.format(remainder)} no chip covers`);
|
||||||
|
return parts.join(", ");
|
||||||
|
},
|
||||||
|
|
||||||
|
// Should focusing this field select what's already in it?
|
||||||
|
//
|
||||||
|
// Every amount box in this tool opens pre-filled with a suggestion: the raise pad
|
||||||
|
// offers the minimum, Add chips offers a buy-in, the seat editor offers the stack
|
||||||
|
// it already has. A dealer taps the box because they want a *different* number,
|
||||||
|
// and a tap puts the caret after the suggestion instead of over it. Measured on a
|
||||||
|
// phone: the raise pad showing 4, tap, type "60" — the field reads 460. Add chips
|
||||||
|
// showing 200, tap, type "50" — 20050.
|
||||||
|
//
|
||||||
|
// Only number boxes. A name or a note is a thing you edit, and selecting all of
|
||||||
|
// it on focus would be the same mistake pointing the other way.
|
||||||
|
selectsOnFocus(field) {
|
||||||
|
if (!field || field.tagName !== "INPUT") return false;
|
||||||
|
const type = String(field.type || "").toLowerCase();
|
||||||
|
if (type === "number") return true;
|
||||||
|
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
|
||||||
|
},
|
||||||
|
|
||||||
|
// "+$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,173 @@
|
|||||||
|
// 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: Money.EPSILON,
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Splits the money on the table into the main pot and any side pots, with who can
|
||||||
|
// win each. Read-only — this is what a dealer says out loud before the showdown
|
||||||
|
// ("main pot four-eighty, side pot ninety between seats 3 and 5"), which is the
|
||||||
|
// announcement that goes missing far more often than the arithmetic goes wrong.
|
||||||
|
//
|
||||||
|
// Every distinct all-in amount opens a new layer: each player pays into a layer up
|
||||||
|
// to their own contribution, and only players who reached that height can win it.
|
||||||
|
// Folded money stays in — it's dead money owed to whoever wins the layer it fell in.
|
||||||
|
layers(players) {
|
||||||
|
// Uncalled chips were never played for, so they come off the top before any
|
||||||
|
// pot is built. Deriving the pots from what's left is what keeps this function
|
||||||
|
// and uncalledReturns() from disagreeing about the same money — they used to
|
||||||
|
// decide "uncontested" separately, and an overbet that its own bettor was
|
||||||
|
// still live for got announced as a side pot only they could win.
|
||||||
|
const returned = new Map(this.uncalledReturns(players).map(r => [r.id, Money.toCents(r.amount)]));
|
||||||
|
const live = players
|
||||||
|
.map(p => ({ ...p, cents: Money.toCents(p.contribution) - (returned.get(p.id) || 0) }))
|
||||||
|
.filter(p => p.cents > 0);
|
||||||
|
if (live.length === 0) return [];
|
||||||
|
|
||||||
|
const heights = [...new Set(live.map(p => p.cents))].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const layers = [];
|
||||||
|
let floor = 0;
|
||||||
|
heights.forEach(height => {
|
||||||
|
const band = height - floor;
|
||||||
|
const payers = live.filter(p => p.cents >= height);
|
||||||
|
const amount = band * payers.length;
|
||||||
|
const eligible = payers.filter(p => !p.folded).map(p => p.id);
|
||||||
|
|
||||||
|
if (amount > 0) {
|
||||||
|
if (eligible.length > 0) {
|
||||||
|
layers.push({ amount: Money.fromCents(amount), eligible });
|
||||||
|
} else if (layers.length > 0) {
|
||||||
|
// Everyone who reached this height folded, so it isn't a pot of its
|
||||||
|
// own — it's dead money owed to whoever wins the layer underneath.
|
||||||
|
// Dropping it would quietly shrink the announced pot.
|
||||||
|
const below = layers[layers.length - 1];
|
||||||
|
below.amount = Money.round(below.amount + Money.fromCents(amount));
|
||||||
|
}
|
||||||
|
// With no layer underneath either, nobody in the hand is live at all.
|
||||||
|
// That's a fold flag the dealer needs to fix, not a payout to guess at.
|
||||||
|
}
|
||||||
|
floor = height;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Consecutive layers with the same set of eligible players are one pot to a
|
||||||
|
// dealer — nobody announces two side pots between the same two people.
|
||||||
|
const merged = [];
|
||||||
|
layers.forEach(layer => {
|
||||||
|
const previous = merged[merged.length - 1];
|
||||||
|
const sameEligibility =
|
||||||
|
previous &&
|
||||||
|
previous.eligible.length === layer.eligible.length &&
|
||||||
|
previous.eligible.every(id => layer.eligible.includes(id));
|
||||||
|
|
||||||
|
if (sameEligibility) {
|
||||||
|
previous.amount = Money.round(previous.amount + layer.amount);
|
||||||
|
} else {
|
||||||
|
merged.push({ ...layer });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return merged.map((layer, index) => ({
|
||||||
|
...layer,
|
||||||
|
label: index === 0 ? "Main pot" : `Side pot ${index}`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Chips nobody could call, which the dealer pushes back rather than into the pot.
|
||||||
|
//
|
||||||
|
// A bet is only called up to what somebody *else* was willing to put in, so the
|
||||||
|
// ceiling for each player is the largest contribution among everyone but them.
|
||||||
|
// Measuring against the whole table instead — including the bettor's own chips —
|
||||||
|
// makes the biggest bet its own yardstick, so the one case this exists for (you
|
||||||
|
// shove, a short stack calls for less) comes back empty.
|
||||||
|
uncalledReturns(players) {
|
||||||
|
const contributing = players.filter(p => p.contribution > this.EPSILON);
|
||||||
|
|
||||||
|
// Nobody live at all means the fold flags are wrong, not that every chip on
|
||||||
|
// the table was uncalled. That's a correction for the dealer to make, so say
|
||||||
|
// nothing here and let the wizard ask them to fix it.
|
||||||
|
if (contributing.filter(p => !p.folded).length === 0) return [];
|
||||||
|
|
||||||
|
const cents = contributing.map(p => Money.toCents(p.contribution));
|
||||||
|
|
||||||
|
return contributing
|
||||||
|
.map((p, index) => {
|
||||||
|
const others = cents.filter((_, i) => i !== index);
|
||||||
|
const cap = others.length > 0 ? Math.max(...others) : 0;
|
||||||
|
return { id: p.id, amount: Money.fromCents(cents[index] - cap) };
|
||||||
|
})
|
||||||
|
.filter(r => r.amount > 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: Money.round(e.remaining - used) };
|
||||||
|
});
|
||||||
|
|
||||||
|
payoutPool = Money.round(payoutPool);
|
||||||
|
const baseShareCents = Math.floor(Money.toCents(payoutPool) / winners.length);
|
||||||
|
let leftoverCents = Money.toCents(payoutPool) - 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: Money.fromCents(w.amountCents) }));
|
||||||
|
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,346 @@
|
|||||||
|
// 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.seatIndexById = new Map(players.map(p => [p.id, state.seats.indexOf(p)]));
|
||||||
|
// The label carries name and cards together for the log and the payout lines;
|
||||||
|
// the choice rows want them separately so each can be styled and read on its own.
|
||||||
|
this.nameById = new Map(
|
||||||
|
players.map(p => [p.id, HoldemState.displayName(p, state.seats.indexOf(p))])
|
||||||
|
);
|
||||||
|
this.cardsById = new Map(
|
||||||
|
players.map(p => [p.id, (p.cards || []).filter(Boolean)])
|
||||||
|
);
|
||||||
|
const contributions = players.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||||
|
// A seat switched off mid-hand still has its chips in the pot, but it
|
||||||
|
// can no more win them than a folded one can — same dead money, and the
|
||||||
|
// wizard must not offer it as a winner.
|
||||||
|
folded: p.folded || HoldemState.isDeadMoney(p),
|
||||||
|
}));
|
||||||
|
this.entries = PotCalculator.buildEntries(contributions);
|
||||||
|
// Kept so the wizard can show the whole breakdown up front, not just the layer
|
||||||
|
// it happens to be asking about.
|
||||||
|
this.layers = PotCalculator.layers(contributions);
|
||||||
|
this.returns = PotCalculator.uncalledReturns(contributions);
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
|
||||||
|
// A chopped pot rarely divides exactly, and the odd chip goes to the first player
|
||||||
|
// left of the button — not to whoever the dealer happened to tick first. The
|
||||||
|
// calculator hands leftovers out in the order it's given the winners, so ordering
|
||||||
|
// them here is what makes the payout match the rule.
|
||||||
|
orderFromButton(ids) {
|
||||||
|
const distance = new Map();
|
||||||
|
for (let step = 1; step <= this.state.seatCount; step++) {
|
||||||
|
distance.set(HoldemState.nextSeatIndex(this.state, this.state.dealerSeatIndex, step), step);
|
||||||
|
}
|
||||||
|
return [...ids].sort((a, b) =>
|
||||||
|
(distance.get(this.seatIndexById.get(a)) ?? 0) - (distance.get(this.seatIndexById.get(b)) ?? 0)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
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 = "";
|
||||||
|
if (this.log.length === 0) body.appendChild(this.renderBreakdown());
|
||||||
|
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";
|
||||||
|
|
||||||
|
// Money left over because nobody could call it isn't a pot anyone won —
|
||||||
|
// it goes back to whoever put it in. Telling the dealer to un-fold someone
|
||||||
|
// to "win" it would be wrong.
|
||||||
|
const returned = (this.returns || []).reduce((sum, r) => sum + r.amount, 0);
|
||||||
|
if (Math.abs(returned - remaining) <= PotCalculator.EPSILON && returned > 0) {
|
||||||
|
const who = this.returns.map(r => this.playerLabelById.get(r.id)).join(", ");
|
||||||
|
warning.textContent = `${Money.format(remaining)} was never called — push it back to ${who} rather than paying it out.`;
|
||||||
|
} else {
|
||||||
|
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;
|
||||||
|
// The row is the target, not the box — so the row has to look chosen too.
|
||||||
|
checkbox.addEventListener("change", () => {
|
||||||
|
label.classList.toggle("chosen", checkbox.checked);
|
||||||
|
});
|
||||||
|
label.appendChild(checkbox);
|
||||||
|
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.className = "potWizardChoiceText";
|
||||||
|
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.className = "potWizardChoiceName";
|
||||||
|
name.textContent = this.nameById.get(c.id);
|
||||||
|
text.appendChild(name);
|
||||||
|
|
||||||
|
const upTo = document.createElement("span");
|
||||||
|
upTo.className = "potWizardChoiceUpTo";
|
||||||
|
upTo.textContent = `up to ${Money.format(c.remaining)}`;
|
||||||
|
text.appendChild(upTo);
|
||||||
|
label.appendChild(text);
|
||||||
|
|
||||||
|
// Cards sit apart from the name rather than inside it, so a dealer reading
|
||||||
|
// a row across the table sees the hand, not a sentence.
|
||||||
|
const cards = this.cardsById.get(c.id) || [];
|
||||||
|
if (cards.length > 0) {
|
||||||
|
const cardsEl = document.createElement("span");
|
||||||
|
cardsEl.className = "potWizardChoiceCards";
|
||||||
|
cards.forEach((card, i) => {
|
||||||
|
if (i > 0) cardsEl.appendChild(document.createTextNode(" "));
|
||||||
|
const span = document.createElement("span");
|
||||||
|
if (/[HD]$/i.test(card)) span.className = "cardRed";
|
||||||
|
span.textContent = Render.formatCard(card);
|
||||||
|
cardsEl.appendChild(span);
|
||||||
|
});
|
||||||
|
label.appendChild(cardsEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, this.orderFromButton(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)})`),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// The announcement, spelled out before anyone is asked to pick a winner: what the
|
||||||
|
// main pot is worth, what each side pot is worth, and who can win each.
|
||||||
|
renderBreakdown() {
|
||||||
|
const node = Render.buildPotBreakdown(
|
||||||
|
this.layers || [],
|
||||||
|
this.returns || [],
|
||||||
|
id => this.playerLabelById.get(id),
|
||||||
|
"Announce before the showdown"
|
||||||
|
);
|
||||||
|
if (!node) return document.createDocumentFragment();
|
||||||
|
node.classList.add("potBreakdownWizard");
|
||||||
|
return node;
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
|
||||||
|
// What the house takes out of this pot, and what is left to push.
|
||||||
|
//
|
||||||
|
// Worked out here rather than at the end so the dealer sees it before anything
|
||||||
|
// moves: the summary is the last chance to disagree with the arithmetic, and a
|
||||||
|
// rake that only appeared in the hand log afterwards would be the tool taking
|
||||||
|
// money quietly.
|
||||||
|
rakeSplit() {
|
||||||
|
const gross = Array.from(this.totals.entries()).map(([id, amount]) => ({
|
||||||
|
id,
|
||||||
|
label: this.playerLabelById.get(id),
|
||||||
|
amount,
|
||||||
|
}));
|
||||||
|
const pot = gross.reduce((sum, p) => sum + p.amount, 0);
|
||||||
|
const sawFlop = (this.state.street || "preflop") !== "preflop";
|
||||||
|
const rake = HoldemState.rakeOn(this.state, pot, sawFlop);
|
||||||
|
return { pot, sawFlop, ...HoldemState.applyRake(gross, rake) };
|
||||||
|
},
|
||||||
|
|
||||||
|
renderSummary() {
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "potWizardSummary";
|
||||||
|
|
||||||
|
const heading = document.createElement("h3");
|
||||||
|
heading.textContent = "Final Payouts";
|
||||||
|
wrap.appendChild(heading);
|
||||||
|
|
||||||
|
const split = this.rakeSplit();
|
||||||
|
const rakeSet = Number((this.state.rake || {}).percent) > 0;
|
||||||
|
if (rakeSet && this.totals.size > 0) {
|
||||||
|
const line = document.createElement("p");
|
||||||
|
line.className = "potWizardRake";
|
||||||
|
const cap = Number(this.state.rake.cap) > 0 ? `, capped at ${Money.format(this.state.rake.cap)}` : "";
|
||||||
|
line.textContent = split.rake > 0
|
||||||
|
? `House takes ${Money.format(split.rake)} of ${Money.format(split.pot)} — ${this.state.rake.percent}%${cap}.`
|
||||||
|
: split.sawFlop
|
||||||
|
? `No rake on this one — ${this.state.rake.percent}% of ${Money.format(split.pot)} doesn't make a chip the table has.`
|
||||||
|
: "No flop, no drop — nothing taken from a pot that ended before the flop.";
|
||||||
|
wrap.appendChild(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 legend = (this.state && this.state.chipLegend) || [];
|
||||||
|
const list = document.createElement("ul");
|
||||||
|
for (const { id, amount } of split.payouts) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
|
||||||
|
|
||||||
|
// The amount is the answer to the arithmetic; the chips are the answer
|
||||||
|
// to the dealer's job. Only when the legend has been filled in — a
|
||||||
|
// table that never set up chip colours gets the number on its own,
|
||||||
|
// which is what it had before.
|
||||||
|
const chips = Money.formatChipBreakdown(amount, legend);
|
||||||
|
if (chips) {
|
||||||
|
const note = document.createElement("span");
|
||||||
|
note.className = "payoutChips";
|
||||||
|
note.textContent = chips;
|
||||||
|
li.appendChild(note);
|
||||||
|
}
|
||||||
|
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", () => {
|
||||||
|
// Recomputed rather than reused from the render: the same numbers, but
|
||||||
|
// taken at the moment the dealer commits rather than when they last
|
||||||
|
// looked at the screen.
|
||||||
|
const paid = this.rakeSplit();
|
||||||
|
this.close();
|
||||||
|
this.onFinished(paid.payouts, { rake: paid.rake, pot: paid.pot });
|
||||||
|
});
|
||||||
|
actions.appendChild(applyBtn);
|
||||||
|
|
||||||
|
// This button cancels — it does not pay anybody. Under a heading reading
|
||||||
|
// "Final Payouts" with an amount beside a name, "Close" reads like dismissing
|
||||||
|
// something already done, and a dealer who taps it walks away believing the
|
||||||
|
// pot has been pushed. It hasn't: the chips are still in the middle and the
|
||||||
|
// hand isn't logged. So it says what it does.
|
||||||
|
const cancelBtn = document.createElement("button");
|
||||||
|
cancelBtn.className = "holdemBtn holdemBtnGhost";
|
||||||
|
cancelBtn.textContent = "Cancel — don't pay yet";
|
||||||
|
cancelBtn.addEventListener("click", () => this.close());
|
||||||
|
actions.appendChild(cancelBtn);
|
||||||
|
|
||||||
|
if (this.totals.size > 0) {
|
||||||
|
const note = document.createElement("p");
|
||||||
|
note.className = "fieldHint potWizardPendingNote";
|
||||||
|
note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand.";
|
||||||
|
wrap.appendChild(actions);
|
||||||
|
wrap.appendChild(note);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.appendChild(actions);
|
||||||
|
return wrap;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = PotWizard;
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
// 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,
|
||||||
|
|
||||||
|
// "even" is the common case — one pot, everyone matched. "uneven" is the one this
|
||||||
|
// screen used to send people back to the full table for: somebody all-in for less,
|
||||||
|
// so the money splits into a main pot and side pots.
|
||||||
|
mode: "even",
|
||||||
|
entries: [],
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
this.potInput = document.getElementById("potInput");
|
||||||
|
this.clearPotBtn = document.getElementById("clearPotBtn");
|
||||||
|
this.entries = [{ amount: "", folded: false }, { amount: "", folded: false }, { amount: "", folded: false }];
|
||||||
|
|
||||||
|
this.renderWayChoices();
|
||||||
|
this.renderChipChoices();
|
||||||
|
this.renderUnevenRows();
|
||||||
|
|
||||||
|
document.getElementById("modeEvenBtn").addEventListener("click", () => this.setMode("even"));
|
||||||
|
document.getElementById("modeUnevenBtn").addEventListener("click", () => this.setMode("uneven"));
|
||||||
|
document.getElementById("addPlayerBtn").addEventListener("click", () => {
|
||||||
|
this.entries.push({ amount: "", folded: false });
|
||||||
|
this.renderUnevenRows();
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setMode(mode) {
|
||||||
|
this.mode = mode;
|
||||||
|
document.getElementById("modeEvenBtn").classList.toggle("selected", mode === "even");
|
||||||
|
document.getElementById("modeUnevenBtn").classList.toggle("selected", mode === "uneven");
|
||||||
|
|
||||||
|
document.querySelectorAll(".evenOnly").forEach(el => { el.hidden = mode !== "even"; });
|
||||||
|
document.querySelectorAll(".unevenOnly").forEach(el => { el.hidden = mode !== "uneven"; });
|
||||||
|
document.getElementById("splitResult").hidden = mode !== "even";
|
||||||
|
document.getElementById("unevenResult").hidden = mode !== "uneven";
|
||||||
|
|
||||||
|
this.update();
|
||||||
|
},
|
||||||
|
|
||||||
|
renderUnevenRows() {
|
||||||
|
const host = document.getElementById("unevenRows");
|
||||||
|
const template = document.getElementById("unevenRowTemplate");
|
||||||
|
host.innerHTML = "";
|
||||||
|
|
||||||
|
this.entries.forEach((entry, index) => {
|
||||||
|
const row = template.content.firstElementChild.cloneNode(true);
|
||||||
|
row.querySelector(".unevenRowLabel").textContent = `P${index + 1}`;
|
||||||
|
|
||||||
|
const amount = row.querySelector(".unevenAmount");
|
||||||
|
amount.value = entry.amount;
|
||||||
|
amount.setAttribute("aria-label", `Player ${index + 1} amount`);
|
||||||
|
amount.addEventListener("input", () => {
|
||||||
|
entry.amount = amount.value.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1");
|
||||||
|
if (entry.amount !== amount.value) amount.value = entry.amount;
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Folded money stays in the pot as dead money, but its owner can't win any
|
||||||
|
// of it — which is exactly the distinction that makes side pots tricky.
|
||||||
|
const fold = row.querySelector(".unevenFoldBtn");
|
||||||
|
fold.classList.toggle("selected", entry.folded);
|
||||||
|
fold.setAttribute("aria-pressed", String(entry.folded));
|
||||||
|
fold.addEventListener("click", () => {
|
||||||
|
entry.folded = !entry.folded;
|
||||||
|
this.renderUnevenRows();
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = row.querySelector(".unevenRemoveBtn");
|
||||||
|
remove.disabled = this.entries.length <= 2;
|
||||||
|
remove.addEventListener("click", () => {
|
||||||
|
this.entries.splice(index, 1);
|
||||||
|
this.renderUnevenRows();
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
|
||||||
|
host.appendChild(row);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Chopping one of the pots listed above, without leaving the screen that worked it
|
||||||
|
// out. Starts closed and at as many ways as there are players who can win the pot,
|
||||||
|
// which is the common case; the stepper covers a chop between fewer of them, which
|
||||||
|
// is the other one.
|
||||||
|
//
|
||||||
|
// The per-pot ways live on the row rather than in `this.ways`, so opening a chop on
|
||||||
|
// the main pot doesn't change what the side pot below it is saying.
|
||||||
|
addChopControl(row, amount, contenderCount) {
|
||||||
|
let ways = contenderCount;
|
||||||
|
let open = false;
|
||||||
|
|
||||||
|
const toggle = document.createElement("button");
|
||||||
|
toggle.type = "button";
|
||||||
|
toggle.className = "unevenChopToggle";
|
||||||
|
|
||||||
|
const detail = document.createElement("div");
|
||||||
|
detail.className = "unevenChopDetail";
|
||||||
|
detail.hidden = true;
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
toggle.textContent = open ? "Hide chop" : "Chop";
|
||||||
|
toggle.setAttribute("aria-expanded", String(open));
|
||||||
|
detail.hidden = !open;
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const result = SplitCalculator.split(amount, ways, this.chip);
|
||||||
|
detail.innerHTML = "";
|
||||||
|
|
||||||
|
const stepper = document.createElement("div");
|
||||||
|
stepper.className = "unevenChopWays";
|
||||||
|
stepper.appendChild(this.makeStepButton("−", () => {
|
||||||
|
if (ways > 2) { ways -= 1; draw(); }
|
||||||
|
}));
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "unevenChopWaysLabel";
|
||||||
|
label.textContent = `${ways} ways`;
|
||||||
|
stepper.appendChild(label);
|
||||||
|
stepper.appendChild(this.makeStepButton("+", () => {
|
||||||
|
if (ways < Math.max(contenderCount, this.MAX_WAYS)) { ways += 1; draw(); }
|
||||||
|
}));
|
||||||
|
detail.appendChild(stepper);
|
||||||
|
|
||||||
|
const each = document.createElement("p");
|
||||||
|
each.className = "unevenChopEach";
|
||||||
|
each.textContent = `${Money.format(result.each)} each`;
|
||||||
|
detail.appendChild(each);
|
||||||
|
|
||||||
|
// Same wording as the main split, because it is the same rule and a dealer
|
||||||
|
// shouldn't have to notice they're reading a different screen.
|
||||||
|
if (result.valid && result.oddChips > 0) {
|
||||||
|
const odd = document.createElement("p");
|
||||||
|
odd.className = "unevenChopNote";
|
||||||
|
odd.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`;
|
||||||
|
detail.appendChild(odd);
|
||||||
|
}
|
||||||
|
if (result.valid && result.dust > 0) {
|
||||||
|
const dust = document.createElement("p");
|
||||||
|
dust.className = "unevenChopNote unevenChopDust";
|
||||||
|
dust.textContent =
|
||||||
|
`${Money.format(result.dust)} doesn't divide into ${Money.format(result.chip)} chips — ` +
|
||||||
|
`pick a smaller denomination.`;
|
||||||
|
detail.appendChild(dust);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle.addEventListener("click", () => { open = !open; draw(); });
|
||||||
|
draw();
|
||||||
|
row.appendChild(toggle);
|
||||||
|
row.appendChild(detail);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUneven() {
|
||||||
|
const players = this.entries
|
||||||
|
.map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded }))
|
||||||
|
.filter(p => p.contribution > 0);
|
||||||
|
|
||||||
|
const pots = PotCalculator.layers(players);
|
||||||
|
const returns = PotCalculator.uncalledReturns(players);
|
||||||
|
|
||||||
|
const host = document.getElementById("unevenPots");
|
||||||
|
const empty = document.getElementById("unevenEmpty");
|
||||||
|
host.innerHTML = "";
|
||||||
|
|
||||||
|
if (pots.length === 0 && returns.length === 0) {
|
||||||
|
empty.hidden = false;
|
||||||
|
empty.textContent = players.length === 0
|
||||||
|
? "Enter what each player put in."
|
||||||
|
: "Nobody left who can win — un-fold whoever should take it.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
empty.hidden = true;
|
||||||
|
|
||||||
|
const addRow = (label, amount, who, className, chopWays) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = `unevenPot ${className || ""}`;
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.className = "unevenPotLabel";
|
||||||
|
name.textContent = label;
|
||||||
|
const value = document.createElement("span");
|
||||||
|
value.className = "unevenPotAmount";
|
||||||
|
value.textContent = Money.format(amount);
|
||||||
|
const eligible = document.createElement("span");
|
||||||
|
eligible.className = "unevenPotWho";
|
||||||
|
eligible.textContent = who;
|
||||||
|
row.appendChild(name);
|
||||||
|
row.appendChild(value);
|
||||||
|
row.appendChild(eligible);
|
||||||
|
host.appendChild(row);
|
||||||
|
|
||||||
|
// Two players tying for a side pot is ordinary, and working out each one's
|
||||||
|
// share meant copying the amount into the other mode by hand — retyping a
|
||||||
|
// number the screen had just worked out, which is a transcription error
|
||||||
|
// waiting to happen with money on it. The chop happens here instead.
|
||||||
|
//
|
||||||
|
// Only offered where it means something: a pot with at least two people
|
||||||
|
// who can win it. Not the uncalled row, which goes back to one player, and
|
||||||
|
// not the total.
|
||||||
|
if (chopWays >= 2) this.addChopControl(row, amount, chopWays);
|
||||||
|
};
|
||||||
|
|
||||||
|
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", "), "", pot.eligible.length));
|
||||||
|
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
|
||||||
|
|
||||||
|
const total = players.reduce((sum, p) => sum + p.contribution, 0);
|
||||||
|
addRow("On the table", Money.round(total), "", "unevenPotTotal");
|
||||||
|
},
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (this.mode === "uneven") {
|
||||||
|
this.updateUneven();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
|||||||
|
// 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,
|
||||||
|
|
||||||
|
mode: null,
|
||||||
|
|
||||||
|
open(state, hooks, seatIndex) {
|
||||||
|
this.state = state;
|
||||||
|
this.hooks = hooks;
|
||||||
|
this.seatIndex = seatIndex;
|
||||||
|
// Always the action list, never whatever the sheet was showing when it last
|
||||||
|
// closed — a seat menu that opens on a list of chairs is a menu that lost the
|
||||||
|
// dealer's place.
|
||||||
|
this.mode = null;
|
||||||
|
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) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The most common interruption in a cash game, and until now it meant opening
|
||||||
|
// the editor and retyping a stack from memory.
|
||||||
|
actions.push({ label: "Add chips…", run: () => this.hooks.addChips(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) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Naming a blind by hand. Only offered while nothing but the blinds are in —
|
||||||
|
// once somebody has called, moving a blind would mean unpicking a bet that
|
||||||
|
// has already been answered, and that's a decision for the dealer's mouth,
|
||||||
|
// not a menu item. Which blind a seat already has is shown rather than
|
||||||
|
// offered again.
|
||||||
|
if (dealtIn && Betting.onlyBlindsIn(this.state)) {
|
||||||
|
const blinds = HoldemState.getBlindSeats(this.state);
|
||||||
|
if (blinds.sbIndex !== index) {
|
||||||
|
actions.push({ label: "Make small blind", run: () => this.hooks.assignBlind(index, "sb") });
|
||||||
|
}
|
||||||
|
if (blinds.bbIndex !== index) {
|
||||||
|
actions.push({ label: "Make big blind", run: () => this.hooks.assignBlind(index, "bb") });
|
||||||
|
}
|
||||||
|
if (this.state.blindOverride) {
|
||||||
|
actions.push({ label: "Blinds follow the button again", run: () => this.hooks.clearBlindOverride() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
|
||||||
|
// generated from a touch screen — so on the device this tool is mostly used on,
|
||||||
|
// the "drag to swap seats" the hint advertised did nothing at all. This is the
|
||||||
|
// same move, by the route a thumb can actually take.
|
||||||
|
if (this.state.seats.length > 1) {
|
||||||
|
actions.push({ label: "Move to…", run: () => this.openMovePicker() });
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
||||||
|
return actions;
|
||||||
|
},
|
||||||
|
|
||||||
|
// The chairs this player could move to. Every other chair: swapping into an empty
|
||||||
|
// one is how a player moves, and swapping with an occupied one is two players
|
||||||
|
// changing places, which is the other half of the same job.
|
||||||
|
destinationsFor(index) {
|
||||||
|
return this.state.seats
|
||||||
|
.map((player, seat) => ({ player, seat }))
|
||||||
|
.filter(entry => entry.seat !== index);
|
||||||
|
},
|
||||||
|
|
||||||
|
openMovePicker() {
|
||||||
|
this.mode = "move";
|
||||||
|
this.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const player = this.state.seats[this.seatIndex];
|
||||||
|
const title = document.getElementById("seatMenuTitle");
|
||||||
|
const subtitle = document.getElementById("seatMenuSubtitle");
|
||||||
|
const list = document.getElementById("seatMenuActions");
|
||||||
|
list.innerHTML = "";
|
||||||
|
|
||||||
|
if (this.mode === "move") {
|
||||||
|
title.textContent = `Move ${HoldemState.displayName(player, this.seatIndex)} to…`;
|
||||||
|
subtitle.textContent = "Their chips, cards and bet go with them. The button and the blinds stay on the chair.";
|
||||||
|
this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "seatMenuBtn";
|
||||||
|
// displayName falls back to "Seat 5" for a chair with nobody in it,
|
||||||
|
// which reads as "Seat 5 — Seat 5". An empty chair says it's empty.
|
||||||
|
const named = (other.name || "").trim();
|
||||||
|
btn.textContent = named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const from = this.seatIndex;
|
||||||
|
this.close();
|
||||||
|
this.hooks.swapSeats(from, seat);
|
||||||
|
});
|
||||||
|
list.appendChild(btn);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
title.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`);
|
||||||
|
subtitle.textContent = bits.join(" · ");
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
// "Move to…" stays on the sheet and swaps it for the seat list; every
|
||||||
|
// other action is the end of the interaction.
|
||||||
|
if (action.label === "Move to…") {
|
||||||
|
action.run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.close();
|
||||||
|
action.run();
|
||||||
|
});
|
||||||
|
list.appendChild(btn);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = SeatMenu;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Who hands cash to whom at the end of the night.
|
||||||
|
//
|
||||||
|
// The standings panel already works out what everyone is up or down. It then stopped,
|
||||||
|
// which leaves the dealer with six signed numbers at 2am and the actual job still to
|
||||||
|
// do: turning them into payments. That's the step people get wrong — not the poker.
|
||||||
|
//
|
||||||
|
// Everything is done in cents. A settlement that has to come out exactly right is the
|
||||||
|
// last place to let a float land on 41.99999999.
|
||||||
|
const SettleUp = {
|
||||||
|
// Largest debt against largest win, repeatedly. It's greedy rather than optimal,
|
||||||
|
// and for this problem that's the right trade: finding the true minimum number of
|
||||||
|
// transfers is NP-hard, while this is guaranteed to need at most one payment fewer
|
||||||
|
// than there are people, and each payment it does emit clears somebody out of the
|
||||||
|
// list entirely. A dealer wants a short list they can read down, not a proof.
|
||||||
|
//
|
||||||
|
// Payers are the outer loop so that everything one person owes is printed together
|
||||||
|
// — the list is read out loud, one player at a time.
|
||||||
|
plan(standings) {
|
||||||
|
const owed = [];
|
||||||
|
const owing = [];
|
||||||
|
|
||||||
|
(standings || []).forEach(row => {
|
||||||
|
const cents = Money.toCents(row.cashPosition);
|
||||||
|
if (cents > 0) owed.push({ name: row.name, cents });
|
||||||
|
else if (cents < 0) owing.push({ name: row.name, cents: -cents });
|
||||||
|
});
|
||||||
|
|
||||||
|
owed.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
|
||||||
|
owing.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const payments = [];
|
||||||
|
let creditor = 0;
|
||||||
|
owing.forEach(payer => {
|
||||||
|
while (payer.cents > 0 && creditor < owed.length) {
|
||||||
|
const taker = owed[creditor];
|
||||||
|
const amount = Math.min(payer.cents, taker.cents);
|
||||||
|
payments.push({ from: payer.name, to: taker.name, amount: Money.fromCents(amount) });
|
||||||
|
payer.cents -= amount;
|
||||||
|
taker.cents -= amount;
|
||||||
|
if (taker.cents === 0) creditor++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// What's left over when the chips on the table don't match the cash that came
|
||||||
|
// in. It is never a rounding artifact — the arithmetic is integer cents — so it
|
||||||
|
// is always the thing the bank check is already warning about: a rebuy nobody
|
||||||
|
// wrote down, or a miscount. Reported rather than quietly spread across the
|
||||||
|
// payments, because inventing a transfer to make the columns agree is how a
|
||||||
|
// real person ends up short.
|
||||||
|
const unpaid = Money.fromCents(owed.reduce((sum, row) => sum + row.cents, 0));
|
||||||
|
const unspent = Money.fromCents(owing.reduce((sum, row) => sum + row.cents, 0));
|
||||||
|
|
||||||
|
return {
|
||||||
|
payments,
|
||||||
|
// Only one of these can be non-zero: either there are more chips than cash
|
||||||
|
// or less, never both.
|
||||||
|
shortBy: unpaid,
|
||||||
|
overBy: unspent,
|
||||||
|
balanced: unpaid === 0 && unspent === 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = SettleUp;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Keyboard control for a dealer running this on a laptop.
|
||||||
|
//
|
||||||
|
// The phone has gestures; the desktop had a mouse and nothing else. These are the
|
||||||
|
// same actions the bar already offers, reachable without moving a hand to the
|
||||||
|
// trackpad between every decision — which is the whole point when the bar is being
|
||||||
|
// tapped thirty times a hand.
|
||||||
|
const Shortcuts = {
|
||||||
|
// Deliberately single letters with no modifier: a dealer's spare hand rests on
|
||||||
|
// the keyboard, and a chord is slower than reaching for the button would be.
|
||||||
|
KEYS: {
|
||||||
|
f: "fold",
|
||||||
|
c: "checkOrCall",
|
||||||
|
r: "raise",
|
||||||
|
a: "allIn",
|
||||||
|
" ": "advance",
|
||||||
|
enter: "advance",
|
||||||
|
u: "undo",
|
||||||
|
escape: "cancel",
|
||||||
|
},
|
||||||
|
|
||||||
|
// What each one does, in a dealer's words, for the legend in the sidebar.
|
||||||
|
//
|
||||||
|
// Lives beside KEYS because the two must not drift: F, C and R are printed on the
|
||||||
|
// buttons they drive, but A and U were on nothing at all — a dealer who learned
|
||||||
|
// the first three from the bar had no way to find out the other two existed. A
|
||||||
|
// shortcut with no line here is a shortcut nobody can find, and there's a test
|
||||||
|
// that says so.
|
||||||
|
LEGEND: [
|
||||||
|
{ keys: ["F"], action: "fold", what: "Fold" },
|
||||||
|
{ keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" },
|
||||||
|
{ keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" },
|
||||||
|
{ keys: ["A"], action: "allIn", what: "Put this player all-in" },
|
||||||
|
{ keys: ["U"], action: "undo", what: "Take back the last action" },
|
||||||
|
{ keys: ["Space", "Enter"], action: "advance", what: "Deal, or move to the next street" },
|
||||||
|
{ keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" },
|
||||||
|
],
|
||||||
|
|
||||||
|
mount(handlers) {
|
||||||
|
this.handlers = handlers;
|
||||||
|
document.addEventListener("keydown", e => this.onKey(e));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Nothing fires while the dealer is typing a name, an amount, or a note — the
|
||||||
|
// "c" in "Cass" must not fold the table.
|
||||||
|
isTyping() {
|
||||||
|
const el = document.activeElement;
|
||||||
|
if (!el) return false;
|
||||||
|
const tag = el.tagName;
|
||||||
|
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
||||||
|
},
|
||||||
|
|
||||||
|
// An overlay owns the keyboard while it's up. Asking the document which overlays
|
||||||
|
// are open, rather than listing them, means a sheet added later can't quietly
|
||||||
|
// start letting "f" fold the table behind it — the same reason Modal watches for
|
||||||
|
// them instead of being told about each one.
|
||||||
|
overlayOpen() {
|
||||||
|
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Enter and Space are how a keyboard presses the control it is already sitting on.
|
||||||
|
// Claiming them for "advance" didn't just block that press — it ran a different
|
||||||
|
// action instead. Measured: Tab to Resolve Pot, press Enter, and the table dealt
|
||||||
|
// the next hand. Space on the same button worked, because "advance" happened to
|
||||||
|
// decline that time and the browser's own activation got through. A control that
|
||||||
|
// does something else every other press is worse than one that does nothing.
|
||||||
|
//
|
||||||
|
// Only these two keys are handed back. The letter shortcuts stay live with a
|
||||||
|
// button focused, because a button does nothing with "f" and the dealer's hand
|
||||||
|
// shouldn't have to leave them.
|
||||||
|
focusedControlOwnsKey(key) {
|
||||||
|
if (key !== "enter" && key !== " ") return false;
|
||||||
|
const el = document.activeElement;
|
||||||
|
return !!(el && el.matches && el.matches('button, [role="button"], a[href], summary'));
|
||||||
|
},
|
||||||
|
|
||||||
|
onKey(e) {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
|
||||||
|
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase();
|
||||||
|
const action = this.KEYS[key];
|
||||||
|
if (!action) return;
|
||||||
|
|
||||||
|
if (this.focusedControlOwnsKey(key)) return;
|
||||||
|
|
||||||
|
// Escape is the exception: it should still close the raise pad even if focus
|
||||||
|
// happens to be sitting in its amount field.
|
||||||
|
if (this.isTyping() && !(action === "cancel")) return;
|
||||||
|
if (this.overlayOpen()) return;
|
||||||
|
|
||||||
|
const handled = this.handlers[action] && this.handlers[action]();
|
||||||
|
if (handled) e.preventDefault();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = Shortcuts;
|
||||||
|
}
|
||||||
@@ -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 Money.toCents(amount);
|
||||||
|
},
|
||||||
|
|
||||||
|
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 Money.fromCents(
|
||||||
|
Money.toCents(result.each) * result.players +
|
||||||
|
Money.toCents(result.chip) * result.oddChips +
|
||||||
|
Money.toCents(result.dust)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = SplitCalculator;
|
||||||
|
}
|
||||||
@@ -0,0 +1,940 @@
|
|||||||
|
// 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,
|
||||||
|
// Cash handed over tonight, which only moves when they buy chips.
|
||||||
|
buyIns: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
// Chips taken off the table on purpose, so the bank check can tell them
|
||||||
|
// apart from chips that have gone missing.
|
||||||
|
discarded: 0,
|
||||||
|
smallBlind: 1,
|
||||||
|
bigBlind: 2,
|
||||||
|
defaultStack: null,
|
||||||
|
street: "preflop",
|
||||||
|
currentBet: 0,
|
||||||
|
lastRaiseSize: 0,
|
||||||
|
actingSeatIndex: null,
|
||||||
|
// Whether the betting has actually been opened for this hand.
|
||||||
|
//
|
||||||
|
// Dealing and betting used to be one button: "Next Hand" moved the button
|
||||||
|
// and posted the blinds in the same breath. That meant the moment between
|
||||||
|
// hands — the one where people are still sitting down, standing up and
|
||||||
|
// arguing about whose turn it is — was spent with money already committed,
|
||||||
|
// and the blind badges recomputing off `inHand` while the posted chips
|
||||||
|
// stayed frozen from when the hand began. Seat somebody after pressing it
|
||||||
|
// and the table would show a big blind who had posted nothing.
|
||||||
|
//
|
||||||
|
// So the dealer says when. False means the table is being set up: nothing
|
||||||
|
// is committed, the badges are a preview, and seating or moving the button
|
||||||
|
// costs nothing.
|
||||||
|
bettingOpen: false,
|
||||||
|
// Set when the dealer names the blinds by hand instead of letting them
|
||||||
|
// follow the button — { sbIndex, bbIndex }. Home games do this constantly
|
||||||
|
// ("you had it last time", someone arriving late owing a blind), and the
|
||||||
|
// button order is the wrong answer often enough that it has to be
|
||||||
|
// overridable. Cleared at the start of every hand.
|
||||||
|
blindOverride: null,
|
||||||
|
// Who actually posted, the last time cards went out — { sbIndex, bbIndex }.
|
||||||
|
// The next hand's rotation is worked out from this rather than from the
|
||||||
|
// seating as it stands, because by then the people it needs to know about
|
||||||
|
// are exactly the ones who have got up and gone.
|
||||||
|
postedBlinds: null,
|
||||||
|
// Who last bet or raised, on this street and anywhere in the hand. Kept
|
||||||
|
// apart because the showdown rule needs both: normally the aggressor of the
|
||||||
|
// final betting round shows first, but a round nobody could bet in — chips
|
||||||
|
// already all-in — falls back to the last one who could.
|
||||||
|
streetAggressorIndex: null,
|
||||||
|
handAggressorIndex: null,
|
||||||
|
// Player ids that were dealt in the last hand actually dealt. What makes a
|
||||||
|
// seat recognisable as having *just* joined — new to the table, or back
|
||||||
|
// from sitting out — which is the whole question behind owing a blind.
|
||||||
|
dealtLastHand: [],
|
||||||
|
// Ids the dealer has said will buy in past the blinds this hand, by posting
|
||||||
|
// the big blind rather than waiting for it. Cleared every hand: it's a
|
||||||
|
// decision about one deal, not a standing arrangement.
|
||||||
|
postingIn: [],
|
||||||
|
// How many straddles are on for the hand about to be dealt. A straddle is
|
||||||
|
// announced hand by hand, so this is cleared with every other one-deal
|
||||||
|
// decision rather than standing until somebody turns it off.
|
||||||
|
straddleCount: 0,
|
||||||
|
// What the house takes, if it takes anything. Percent zero is a game that
|
||||||
|
// doesn't rake, which is nearly all of them and the default.
|
||||||
|
rake: { percent: 0, cap: null, noFlopNoDrop: true },
|
||||||
|
// What it has taken so far tonight. A session total, not a per-hand one:
|
||||||
|
// it is the number the box is checked against at the end.
|
||||||
|
rakeTaken: 0,
|
||||||
|
// Which seats actually put a straddle out when the hand was dealt, so the
|
||||||
|
// felt can badge them. Read off the deal rather than worked out again
|
||||||
|
// later, the same way the posted blinds are.
|
||||||
|
postedStraddles: [],
|
||||||
|
// Set when somebody leaves out of the blinds and the rotation has to hold
|
||||||
|
// to the seats to keep the big blind moving one player at a time. The
|
||||||
|
// button then sits on the chair they vacated, and no small blind is posted.
|
||||||
|
deadButton: false,
|
||||||
|
deadSmallBlind: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Where an unreadable save is kept instead of being written over.
|
||||||
|
RECOVERY_KEY: "casino.holdem.state.v1.unreadable",
|
||||||
|
|
||||||
|
// True once another tab has written to this table. Two tabs each hold their own
|
||||||
|
// copy in memory and each save writes the whole thing, so the second one to act
|
||||||
|
// silently throws the first one's work away — measured: tab A deals and takes a
|
||||||
|
// call, tab B renames a seat, and the deal, the blinds and the call are gone with
|
||||||
|
// nothing said in either tab.
|
||||||
|
//
|
||||||
|
// Watched rather than merged. Replacing the table under a dealer's hands mid-hand
|
||||||
|
// would be its own hazard; knowing there are two of them is what they need.
|
||||||
|
otherTabActive: false,
|
||||||
|
onOtherTab: null,
|
||||||
|
|
||||||
|
// The browser fires this in every *other* tab, never the one that wrote, so our
|
||||||
|
// own saves can't trip it.
|
||||||
|
watchOtherTabs() {
|
||||||
|
if (typeof window === "undefined" || this.tabWatcher) return;
|
||||||
|
this.tabWatcher = event => {
|
||||||
|
if (event.key !== this.STORAGE_KEY) return;
|
||||||
|
if (this.otherTabActive) return;
|
||||||
|
this.otherTabActive = true;
|
||||||
|
if (typeof this.onOtherTab === "function") this.onOtherTab();
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", this.tabWatcher);
|
||||||
|
},
|
||||||
|
|
||||||
|
// True when there was a saved table and it couldn't be read. Same pair as
|
||||||
|
// saveFailed above, for the same reason: the UI has to be able to say so.
|
||||||
|
loadFailed: false,
|
||||||
|
onLoadFailed: null,
|
||||||
|
|
||||||
|
// A saved table that won't parse becomes an empty table, which is the only thing
|
||||||
|
// it can become — but it used to become one silently, and then the first edit
|
||||||
|
// saved over the top of it. A dealer mid-session saw an empty felt with no idea
|
||||||
|
// whether they'd mis-tapped Reset or the browser had lost the night, and whatever
|
||||||
|
// a person might have picked out of the raw JSON was gone a tap later.
|
||||||
|
//
|
||||||
|
// So the unreadable value is moved aside first. It is not offered as a restore —
|
||||||
|
// half a JSON document isn't a table, and pretending otherwise would be worse than
|
||||||
|
// an empty one — but it stops being destroyed, and somebody who wants the
|
||||||
|
// standings out of it still can.
|
||||||
|
failedLoad(raw) {
|
||||||
|
if (!this.loadFailed) {
|
||||||
|
this.loadFailed = true;
|
||||||
|
try {
|
||||||
|
if (raw) localStorage.setItem(this.RECOVERY_KEY, raw);
|
||||||
|
} catch {
|
||||||
|
// No room to keep it. Nothing more to try, and the warning below is
|
||||||
|
// the part that actually matters.
|
||||||
|
}
|
||||||
|
if (typeof this.onLoadFailed === "function") this.onLoadFailed();
|
||||||
|
}
|
||||||
|
return this.createDefault();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Everything a table has to be put through before it can be trusted, whichever
|
||||||
|
// direction it arrived from.
|
||||||
|
//
|
||||||
|
// Pulled out of load() so a table read from a file goes through exactly the same
|
||||||
|
// checks as one read from storage. A second, looser path for imports is how a
|
||||||
|
// format grows two meanings of the same field.
|
||||||
|
//
|
||||||
|
// Returns null for anything that isn't a table at all; every other repair is
|
||||||
|
// silent and forward, the way a save from an older version is treated.
|
||||||
|
migrate(parsed) {
|
||||||
|
if (!parsed || !Array.isArray(parsed.seats)) return null;
|
||||||
|
{
|
||||||
|
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
||||||
|
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
||||||
|
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
|
||||||
|
|
||||||
|
// 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", "blindOverride", "postedBlinds",
|
||||||
|
"streetAggressorIndex", "handAggressorIndex"]
|
||||||
|
.forEach(key => {
|
||||||
|
if (parsed[key] === undefined) parsed[key] = defaults[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
// A table saved before the dead button existed has neither flag. False is
|
||||||
|
// the right guess for both: it means the rotation carries on the ordinary
|
||||||
|
// way, which is what that table was already doing.
|
||||||
|
parsed.deadButton = parsed.deadButton === true;
|
||||||
|
parsed.deadSmallBlind = parsed.deadSmallBlind === true;
|
||||||
|
// An old save has no record of who was dealt last hand. Empty is the
|
||||||
|
// honest answer — it reads as "this table hasn't played yet", so the first
|
||||||
|
// hand after reopening treats nobody as having just walked up, rather than
|
||||||
|
// asking the whole table to post.
|
||||||
|
if (!Array.isArray(parsed.dealtLastHand)) parsed.dealtLastHand = [];
|
||||||
|
if (!Array.isArray(parsed.postingIn)) parsed.postingIn = [];
|
||||||
|
// None on is the right reading of a save from before straddles existed:
|
||||||
|
// whatever is in front of those players is already in their committed
|
||||||
|
// chips, and nothing is owed on top of it.
|
||||||
|
if (!(Number(parsed.straddleCount) > 0)) parsed.straddleCount = 0;
|
||||||
|
// A table saved before the house took anything takes nothing, which is
|
||||||
|
// also the right answer for one that never will.
|
||||||
|
const rake = parsed.rake && typeof parsed.rake === "object" ? parsed.rake : {};
|
||||||
|
parsed.rake = {
|
||||||
|
percent: Math.min(100, Math.max(0, Number(rake.percent) || 0)),
|
||||||
|
cap: Number(rake.cap) > 0 ? Money.round(Number(rake.cap)) : null,
|
||||||
|
noFlopNoDrop: rake.noFlopNoDrop !== false,
|
||||||
|
};
|
||||||
|
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
|
||||||
|
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
|
||||||
|
|
||||||
|
// A table saved before dealing and betting were separate has no flag, and
|
||||||
|
// guessing false would show "deal the hand" over a hand already in
|
||||||
|
// progress. Anything already committed, or anyone already on the clock,
|
||||||
|
// means the betting was open when they closed the tab.
|
||||||
|
if (parsed.bettingOpen === undefined) {
|
||||||
|
parsed.bettingOpen =
|
||||||
|
parsed.actingSeatIndex !== null && parsed.actingSeatIndex !== undefined ||
|
||||||
|
parsed.seats.some(p =>
|
||||||
|
(Number(p.committed) || 0) > 0 || (Number(p.amountOverride) || 0) > 0);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
if (player.buyIns === undefined) player.buyIns = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
load() {
|
||||||
|
let raw = null;
|
||||||
|
try {
|
||||||
|
raw = localStorage.getItem(this.STORAGE_KEY);
|
||||||
|
if (!raw) return this.createDefault();
|
||||||
|
const migrated = this.migrate(JSON.parse(raw));
|
||||||
|
if (!migrated) return this.failedLoad(raw);
|
||||||
|
return migrated;
|
||||||
|
} catch {
|
||||||
|
// Malformed JSON — a truncated write, another tab, a browser hiccup.
|
||||||
|
return this.failedLoad(raw);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Taking the table off this device ------------------------------------
|
||||||
|
//
|
||||||
|
// A night lives in one browser's localStorage and nowhere else. A dealer whose
|
||||||
|
// phone dies, or who wants to carry on from the tablet after the break, loses the
|
||||||
|
// stacks, the buy-ins, where the button is and the whole hand log — and the CSV
|
||||||
|
// export can't bring any of that back, because it records what happened rather
|
||||||
|
// than where the table stands.
|
||||||
|
//
|
||||||
|
// So the table can be written out as a file and read back in. Same shape that goes
|
||||||
|
// into storage, wrapped in enough to recognise it later.
|
||||||
|
BACKUP_KIND: "casino.holdem.table",
|
||||||
|
BACKUP_VERSION: 1,
|
||||||
|
|
||||||
|
toBackup(state) {
|
||||||
|
return JSON.stringify({
|
||||||
|
kind: this.BACKUP_KIND,
|
||||||
|
version: this.BACKUP_VERSION,
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
table: state,
|
||||||
|
}, null, 2);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reads a file back. Returns { table } on success, or { error } with something a
|
||||||
|
// dealer can act on — never a half-loaded table, because the failure mode there is
|
||||||
|
// a night that looks restored and isn't.
|
||||||
|
fromBackup(text) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return { error: "That file isn't a saved table — it couldn't be read as JSON." };
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
return { error: "That file isn't a saved table." };
|
||||||
|
}
|
||||||
|
// A bare table, from someone who copied the storage value out by hand, is
|
||||||
|
// still a table. The envelope is a convenience, not a password.
|
||||||
|
const table = parsed.kind === this.BACKUP_KIND || parsed.table ? parsed.table : parsed;
|
||||||
|
if (parsed.kind && parsed.kind !== this.BACKUP_KIND) {
|
||||||
|
return { error: "That file is a save from something else, not a Hold'em table." };
|
||||||
|
}
|
||||||
|
const migrated = this.migrate(table);
|
||||||
|
if (!migrated) {
|
||||||
|
return { error: "That file doesn't have a table in it — no seats were found." };
|
||||||
|
}
|
||||||
|
if (parsed.version > this.BACKUP_VERSION) {
|
||||||
|
// Newer than this build understands. Loading it anyway risks reading a
|
||||||
|
// field that has changed meaning, and the dealer would find out from the
|
||||||
|
// money, so say so instead.
|
||||||
|
return { error: "That table was saved by a newer version of this tool. Update, then load it." };
|
||||||
|
}
|
||||||
|
return { table: migrated, savedAt: parsed.savedAt || null };
|
||||||
|
},
|
||||||
|
|
||||||
|
// What the file is called. Dated, because a dealer who saves twice in a night
|
||||||
|
// should not have to guess which is which.
|
||||||
|
backupFileName(now = new Date()) {
|
||||||
|
const pad = n => String(n).padStart(2, "0");
|
||||||
|
return `holdem-table-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
|
||||||
|
`-${pad(now.getHours())}${pad(now.getMinutes())}.json`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// True once a save has failed. The UI reads this to keep saying so, because a
|
||||||
|
// toast is a moment and this is a fact about the rest of the night.
|
||||||
|
saveFailed: false,
|
||||||
|
|
||||||
|
// Called the first time a save fails. Set by the UI.
|
||||||
|
onSaveFailed: null,
|
||||||
|
|
||||||
|
save(state) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// Storage full, or private browsing. The table still works for the rest of
|
||||||
|
// the session — it just won't survive a refresh, and nothing used to say
|
||||||
|
// so. That is the same shape of fault the bank check exists for: silent
|
||||||
|
// until it is too late to do anything about it. A dealer who finds out by
|
||||||
|
// refreshing has lost the night's standings, and the only warning they got
|
||||||
|
// was the table being empty.
|
||||||
|
//
|
||||||
|
// Announced once. A dealer tapping a bar thirty times a hand does not need
|
||||||
|
// thirty of these.
|
||||||
|
if (!this.saveFailed) {
|
||||||
|
this.saveFailed = true;
|
||||||
|
if (typeof this.onSaveFailed === "function") this.onSaveFailed(error);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// Chips already pushed into the middle count whether or not the player is still
|
||||||
|
// in the hand. Someone who storms off, gets taken out by the dealer, or is
|
||||||
|
// dealt out mid-hand leaves their money on the felt — it's dead money owed to
|
||||||
|
// whoever wins, and it is not theirs to take back. Reading `inHand` first is
|
||||||
|
// how those chips used to disappear out of the pot the instant a seat was
|
||||||
|
// switched off, quietly shrinking the payout by whatever they had posted.
|
||||||
|
computeContribution(player, chipLegend, basePotAmount) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// The table-wide default is the one thing that stays tied to being dealt in:
|
||||||
|
// it's a stand-in for "everyone put the same amount in", so applying it to
|
||||||
|
// empty chairs would invent a contribution from every seat nobody is using.
|
||||||
|
if (!player.inHand) return 0;
|
||||||
|
|
||||||
|
const base = Number(basePotAmount);
|
||||||
|
return Number.isNaN(base) ? 0 : Math.max(0, base);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Anyone whose chips are in the pot, whether or not they're still in the hand.
|
||||||
|
// A seat that's been switched off mid-hand is dead money: it counts towards the
|
||||||
|
// pot but can never be picked as a winner, exactly like a fold.
|
||||||
|
isDeadMoney(player) {
|
||||||
|
return !player.inHand;
|
||||||
|
},
|
||||||
|
|
||||||
|
// What starting the next hand would take off the table for good: exactly the
|
||||||
|
// fields startNextHand clears.
|
||||||
|
//
|
||||||
|
// Deliberately not totalPot(). The table-wide base pot is a standing ante — a
|
||||||
|
// stand-in for "everyone puts the same in every hand", used by a dealer who
|
||||||
|
// isn't tracking stacks — so it re-applies to the next hand rather than being
|
||||||
|
// lost with this one. Counting it would mean warning about money that isn't
|
||||||
|
// going anywhere, on every single hand, for exactly the dealers who chose the
|
||||||
|
// simplest way to run the table.
|
||||||
|
unresolvedContributions(state) {
|
||||||
|
const total = state.seats.reduce((sum, player) => {
|
||||||
|
const override = player.amountOverride;
|
||||||
|
let banked = 0;
|
||||||
|
|
||||||
|
if (override !== null && override !== undefined && String(override).trim() !== "") {
|
||||||
|
const parsed = Number(override);
|
||||||
|
if (!Number.isNaN(parsed)) banked = Math.max(0, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (banked === 0) {
|
||||||
|
banked = (state.chipLegend || []).reduce((chips, chip) => {
|
||||||
|
const count = Number(player.chipCounts[chip.id]) || 0;
|
||||||
|
return chips + count * Number(chip.value || 0);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum + banked + Math.max(0, Number(player.committed) || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return Money.round(total);
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
|
||||||
|
// The button passes to the next player, not the next chair. Moving it one seat at
|
||||||
|
// a time parks it on empty chairs — with four players spread around ten seats it
|
||||||
|
// sits on nobody most hands — and that isn't only untidy: position and blinds are
|
||||||
|
// supposed to come round to each player at the same rate, and they don't if the
|
||||||
|
// button is rotating past chairs nobody is sitting in.
|
||||||
|
//
|
||||||
|
// The fallback, now. nextButtonRotation is what a hand that was actually dealt
|
||||||
|
// rotates by; this is for the cases it has nothing to work from — a fresh table,
|
||||||
|
// a hand never dealt, a dealer pressing Next Hand twice to pass the button on.
|
||||||
|
advanceButton(state) {
|
||||||
|
for (let step = 1; step <= state.seatCount; step++) {
|
||||||
|
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||||
|
if (state.seats[index].inHand) {
|
||||||
|
state.dealerSeatIndex = index;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nobody is dealt in; move it along anyway so it doesn't look stuck.
|
||||||
|
state.dealerSeatIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Seats dealt into this hand, in blind order starting from the seat after the
|
||||||
|
// button. Blinds follow the players, not the chairs — the seats immediately left
|
||||||
|
// of the button are empty often enough that counting chairs posts them to nobody.
|
||||||
|
liveOrderFromButton(state) {
|
||||||
|
const order = [];
|
||||||
|
for (let step = 1; step <= state.seatCount; step++) {
|
||||||
|
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||||
|
if (state.seats[index].inHand) order.push(index);
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
},
|
||||||
|
|
||||||
|
// A button sitting on an empty chair is only cosmetic until you try to read
|
||||||
|
// position off it. On the very first hand of a fresh table it starts on seat 1
|
||||||
|
// whether or not anybody is sitting there, and a table that empties out around it
|
||||||
|
// leaves it stranded. Move it to a real player before a hand is dealt.
|
||||||
|
// Where the button will be when the hand is dealt — without moving it.
|
||||||
|
//
|
||||||
|
// Kept separate from ensureButtonOnPlayer because the deal preview has to be able
|
||||||
|
// to ask the question without answering it. It couldn't, and so the preview lied:
|
||||||
|
// with the button parked on an empty chair it promised one pair of blinds and
|
||||||
|
// dealing produced another, because beginHand relocates the button first and then
|
||||||
|
// works out who posts.
|
||||||
|
buttonSeatForDeal(state) {
|
||||||
|
const seated = state.seats[state.dealerSeatIndex];
|
||||||
|
if (seated && seated.inHand) return state.dealerSeatIndex;
|
||||||
|
// A button left on an empty chair on purpose is the dead button, and moving it
|
||||||
|
// to a player is precisely what it's there to stop. Only a button that has been
|
||||||
|
// stranded by accident gets rescued.
|
||||||
|
if (state.deadButton && this.deadPositionsApply(state)) return state.dealerSeatIndex;
|
||||||
|
for (let step = 0; step < state.seatCount; step++) {
|
||||||
|
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||||
|
if (state.seats[index].inHand) return index;
|
||||||
|
}
|
||||||
|
return state.dealerSeatIndex;
|
||||||
|
},
|
||||||
|
|
||||||
|
ensureButtonOnPlayer(state) {
|
||||||
|
state.dealerSeatIndex = this.buttonSeatForDeal(state);
|
||||||
|
},
|
||||||
|
|
||||||
|
// A hand-picked blind is only usable if that seat is actually in the hand — a
|
||||||
|
// dealer who names a blind and then deals that player out shouldn't have the
|
||||||
|
// table quietly post to nobody.
|
||||||
|
validBlindOverride(state) {
|
||||||
|
const o = state.blindOverride;
|
||||||
|
if (!o) return null;
|
||||||
|
const ok = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i] && state.seats[i].inHand;
|
||||||
|
if (!ok(o.sbIndex) || !ok(o.bbIndex) || o.sbIndex === o.bbIndex) return null;
|
||||||
|
return o;
|
||||||
|
},
|
||||||
|
|
||||||
|
// A dead button and a dead small blind are three-handed-and-up ideas. Down to two
|
||||||
|
// players the button *is* the small blind and the blinds simply alternate, so
|
||||||
|
// whatever the last rotation left behind stops applying.
|
||||||
|
deadPositionsApply(state) {
|
||||||
|
return state.seats.filter(p => p.inHand).length >= 3;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Who posts what. Heads-up is its own rule: with two players the button posts the
|
||||||
|
// small blind, which is the opposite of the usual order.
|
||||||
|
getBlindSeats(state) {
|
||||||
|
const live = this.liveOrderFromButton(state);
|
||||||
|
const dealerIndex = state.dealerSeatIndex;
|
||||||
|
|
||||||
|
// A blind the dealer named by hand wins over anything positional. Heads-up is
|
||||||
|
// still reported honestly so the betting order stays right — who posts what
|
||||||
|
// is the dealer's call, but two-handed play acting in reverse is a rule.
|
||||||
|
const override = this.validBlindOverride(state);
|
||||||
|
if (override) {
|
||||||
|
return {
|
||||||
|
dealerIndex,
|
||||||
|
sbIndex: override.sbIndex,
|
||||||
|
bbIndex: override.bbIndex,
|
||||||
|
headsUp: live.length === 2,
|
||||||
|
manual: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (live.length < 2) {
|
||||||
|
return { dealerIndex, sbIndex: null, bbIndex: null, headsUp: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (live.length === 2) {
|
||||||
|
const buttonIsPlaying = state.seats[dealerIndex].inHand;
|
||||||
|
const sbIndex = buttonIsPlaying ? dealerIndex : live[0];
|
||||||
|
const bbIndex = live.find(i => i !== sbIndex);
|
||||||
|
return { dealerIndex, sbIndex, bbIndex, headsUp: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The small blind is dead: whoever would have moved into it has left the table,
|
||||||
|
// and the seat stays empty for a hand rather than dragging the big blind
|
||||||
|
// forward an extra place. One player gets a free hand out of position for it;
|
||||||
|
// that's the price of nobody being charged two big blinds in an orbit.
|
||||||
|
if (state.deadSmallBlind && this.deadPositionsApply(state)) {
|
||||||
|
return { dealerIndex, sbIndex: null, bbIndex: live[0], headsUp: false, deadSmallBlind: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Seats about to be dealt a hand they haven't paid for.
|
||||||
|
//
|
||||||
|
// Somebody who walks up and sits down between the button and the big blind has had
|
||||||
|
// the blinds go past them. Dealing them in there is a free hand in late position —
|
||||||
|
// which is exactly what posting is for, and it comes out of everybody else's night
|
||||||
|
// a couple of dollars at a time. At a real table they either post the big blind to
|
||||||
|
// come straight in, or they wait for it to reach them.
|
||||||
|
//
|
||||||
|
// The seat that *is* the big blind this hand isn't owed anything: they're paying.
|
||||||
|
// Neither is anyone at a table that hasn't dealt a hand yet, where everybody is new
|
||||||
|
// and nobody has been skipped.
|
||||||
|
blindDebtors(state, bbIndex) {
|
||||||
|
const played = Array.isArray(state.dealtLastHand) && state.dealtLastHand.length > 0;
|
||||||
|
if (!played) return [];
|
||||||
|
return state.seats
|
||||||
|
.map((player, index) => ({ player, index }))
|
||||||
|
.filter(({ player, index }) =>
|
||||||
|
player.inHand &&
|
||||||
|
index !== bbIndex &&
|
||||||
|
!state.dealtLastHand.includes(player.id))
|
||||||
|
.map(({ index }) => index);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Where the button goes for the next hand, and whether anything is left dead.
|
||||||
|
//
|
||||||
|
// Ordinarily it follows the small blind round — the next player along, which is
|
||||||
|
// what it has always done. The case that needs care is somebody getting up out of
|
||||||
|
// the blinds. Passing the button to the next *player* then pulls the whole blind
|
||||||
|
// order forward a place: the player after the empty chair is never asked for a big
|
||||||
|
// blind at all, and the one after them is asked an orbit early. Holding the
|
||||||
|
// rotation to the seats instead is the dead button, and getting it wrong costs a
|
||||||
|
// real player a real big blind.
|
||||||
|
//
|
||||||
|
// Worked out from who actually posted last hand, not from the seating as it stands,
|
||||||
|
// because by now the people it needs to know about are the ones who have gone.
|
||||||
|
nextButtonRotation(state) {
|
||||||
|
const posted = state.postedBlinds;
|
||||||
|
const inRange = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i];
|
||||||
|
const here = i => inRange(i) && state.seats[i].inHand;
|
||||||
|
|
||||||
|
// Nothing to hold to: a fresh table, a hand that was never dealt, or one where
|
||||||
|
// the blinds were named by hand. And heads-up has its own rule — with two
|
||||||
|
// players the button is the small blind, so there is no position to leave dead.
|
||||||
|
if (!posted || !inRange(posted.sbIndex) || !inRange(posted.bbIndex) || !this.deadPositionsApply(state)) {
|
||||||
|
return { buttonIndex: null, deadButton: false, deadSmallBlind: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both still sitting there is the ordinary case, and this comes out as the next
|
||||||
|
// player along — the same answer as passing the button, arrived at from the
|
||||||
|
// blinds so that the unusual case doesn't need a separate path.
|
||||||
|
return {
|
||||||
|
buttonIndex: posted.sbIndex,
|
||||||
|
deadButton: !here(posted.sbIndex),
|
||||||
|
deadSmallBlind: !here(posted.bbIndex),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
startNextHand(state) {
|
||||||
|
const rotation = this.nextButtonRotation(state);
|
||||||
|
if (rotation.buttonIndex === null) {
|
||||||
|
this.advanceButton(state);
|
||||||
|
state.deadButton = false;
|
||||||
|
state.deadSmallBlind = false;
|
||||||
|
} else {
|
||||||
|
state.dealerSeatIndex = rotation.buttonIndex;
|
||||||
|
state.deadButton = rotation.deadButton;
|
||||||
|
state.deadSmallBlind = rotation.deadSmallBlind;
|
||||||
|
}
|
||||||
|
// Spent. Pressing Next Hand again without dealing is a dealer passing the
|
||||||
|
// button along, not a second rotation off the same hand.
|
||||||
|
state.postedBlinds = null;
|
||||||
|
// Cards aren't out and nothing is posted until the dealer says so.
|
||||||
|
state.bettingOpen = false;
|
||||||
|
state.actingSeatIndex = null;
|
||||||
|
state.street = "preflop";
|
||||||
|
state.currentBet = 0;
|
||||||
|
state.lastRaiseSize = 0;
|
||||||
|
// A hand-picked blind applies to the hand it was picked for. Carrying it into
|
||||||
|
// the next one would freeze the blinds on two players for the rest of the
|
||||||
|
// night, which is the opposite of what naming them once is for.
|
||||||
|
state.blindOverride = null;
|
||||||
|
// Posting to come in is a decision about one deal. Carrying it forward would
|
||||||
|
// charge somebody an extra big blind every hand for the rest of the night.
|
||||||
|
state.postingIn = [];
|
||||||
|
// So is a straddle. It is announced hand by hand at a table, and a straddle
|
||||||
|
// that stayed on would take chips off somebody who never said the word.
|
||||||
|
state.straddleCount = 0;
|
||||||
|
state.postedStraddles = [];
|
||||||
|
state.seats.forEach(p => {
|
||||||
|
p.folded = false;
|
||||||
|
p.cards = ["", ""];
|
||||||
|
p.chipCounts = {};
|
||||||
|
p.amountOverride = null;
|
||||||
|
p.committed = 0;
|
||||||
|
p.hasActed = false;
|
||||||
|
});
|
||||||
|
// Anyone who joined mid-hand is a full player again from here.
|
||||||
|
this.ensureButtonOnPlayer(state);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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, rake = 0) {
|
||||||
|
if (typeof state.handNumber !== "number") state.handNumber = 1;
|
||||||
|
if (!Array.isArray(state.handHistory)) state.handHistory = [];
|
||||||
|
|
||||||
|
// The pot as it was, house cut included — the number the table saw in the
|
||||||
|
// middle. What each player took is separately recorded and is net of it, so
|
||||||
|
// the two together say where every chip went.
|
||||||
|
const taken = Money.round(Math.max(0, Number(rake) || 0));
|
||||||
|
const pot = Money.round(payouts.reduce((sum, p) => sum + p.amount, 0) + taken);
|
||||||
|
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,
|
||||||
|
rake: taken,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cash bought in and chips currently in front don't come from the hand log at
|
||||||
|
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
|
||||||
|
const seatById = new Map(state.seats.map(p => [p.id, p]));
|
||||||
|
|
||||||
|
// Anyone who has money in the game belongs in the settle-up, whether or not
|
||||||
|
// they've been in a logged hand yet. Building the list from the hand log alone
|
||||||
|
// left a player who bought in and then sat through a few hands folding —
|
||||||
|
// or who just sat down — missing entirely, so the banker's own record didn't
|
||||||
|
// mention the cash they'd taken. They turn up at cash-out holding chips the
|
||||||
|
// list never accounted for.
|
||||||
|
state.seats.forEach((player, index) => {
|
||||||
|
if (totals.has(player.id)) return;
|
||||||
|
const hasMoneyInPlay =
|
||||||
|
(Number(player.buyIns) || 0) > 0 ||
|
||||||
|
(Number(player.stack) || 0) > 0 ||
|
||||||
|
(Number(player.amountOverride) || 0) > 0 ||
|
||||||
|
(Number(player.committed) || 0) > 0;
|
||||||
|
if (!hasMoneyInPlay) return;
|
||||||
|
totals.set(player.id, { id: player.id, name: this.displayName(player, index), in: 0, won: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(totals.values())
|
||||||
|
.map(t => {
|
||||||
|
const seat = seatById.get(t.id);
|
||||||
|
const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0;
|
||||||
|
const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0;
|
||||||
|
// Chips already pushed into the hand in progress are still this
|
||||||
|
// player's money until it's won. Leaving them out makes the cash
|
||||||
|
// position disagree with the hand log for as long as a hand is open —
|
||||||
|
// and blinds are posted the instant the previous one settles, so that
|
||||||
|
// window is almost always.
|
||||||
|
const inFrontOfThem = seat ? (Number(seat.amountOverride) || 0) + (Number(seat.committed) || 0) : 0;
|
||||||
|
|
||||||
|
// Whatever the dealer wrote against this seat — "IOU: $20 to Ann",
|
||||||
|
// "Venmo: @ann". It's carried here so the export can hand it over with
|
||||||
|
// the money it belongs to; the standings panel itself doesn't show it.
|
||||||
|
const notes = (seat && Array.isArray(seat.customFields) ? seat.customFields : [])
|
||||||
|
.filter(field => (field.label || "").trim() || (field.value || "").trim())
|
||||||
|
.map(field => `${(field.label || "").trim()}: ${(field.value || "").trim()}`.replace(/^: | :$/, ""))
|
||||||
|
.join("; ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
in: Money.round(t.in),
|
||||||
|
won: Money.round(t.won),
|
||||||
|
net: Money.round(t.won - t.in),
|
||||||
|
boughtIn,
|
||||||
|
chipsNow,
|
||||||
|
// Everything still theirs: the stack in front of them plus whatever
|
||||||
|
// is out in the middle for the hand in progress. This is the number
|
||||||
|
// a banker counts out when somebody leaves, and it was the one
|
||||||
|
// figure the standings never showed — the column called "Cash out"
|
||||||
|
// held the difference against their buy-in, so a player up $140 on
|
||||||
|
// a $340 stack read as "$140" at the exact moment cash changed
|
||||||
|
// hands.
|
||||||
|
chipsInFront: Money.round(chipsNow + inFrontOfThem),
|
||||||
|
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.net - a.net);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Two players change chairs.
|
||||||
|
//
|
||||||
|
// The whole seat moves — name, stack, cards, what they have in the pot — because
|
||||||
|
// that is what changing chairs is. What stays behind is everything that belongs to
|
||||||
|
// the *position*: the button, the blinds and the acting index all name a chair, and
|
||||||
|
// a player who moves into the big blind's chair is the big blind. That is the same
|
||||||
|
// answer a card room gives.
|
||||||
|
swapSeats(state, a, b) {
|
||||||
|
const seats = state.seats || [];
|
||||||
|
if (a === b) return false;
|
||||||
|
if (!seats[a] || !seats[b]) return false;
|
||||||
|
[seats[a], seats[b]] = [seats[b], seats[a]];
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// The one check a banker actually needs, and the one nothing here did.
|
||||||
|
//
|
||||||
|
// Every chip on the table is a promise to pay cash for it later. If the chips in
|
||||||
|
// play add up to more than the cash that came in, that promise can't be kept —
|
||||||
|
// and the way you find out is the last two players cashing out and the box being
|
||||||
|
// empty. It's always someone taking chips without paying, a rebuy that never got
|
||||||
|
// recorded, or a miscount, and it's silent until it's too late to work out which.
|
||||||
|
//
|
||||||
|
// Chips in play counts everything a player could hand back: what's in front of
|
||||||
|
// them and what's in the middle waiting to be won.
|
||||||
|
// ---- The house's cut ----
|
||||||
|
//
|
||||||
|
// A game that takes a rake or a time charge has chips leaving the table every hand,
|
||||||
|
// and until now the bank check called them missing: it added up the buy-ins,
|
||||||
|
// added up the chips on the felt, and told the dealer somebody had been paid for
|
||||||
|
// chips they never got. The house's cut is the one thing on this table that is
|
||||||
|
// supposed to go and not come back, so it is counted rather than mourned.
|
||||||
|
//
|
||||||
|
// Percentage with a cap, which is what nearly every room and home game runs. Zero
|
||||||
|
// percent is a game that doesn't rake, which is the default and the common case.
|
||||||
|
|
||||||
|
// What the house takes from a pot of this size.
|
||||||
|
//
|
||||||
|
// "No flop, no drop" is the rule almost everyone plays: a hand that ends before the
|
||||||
|
// flop pays nothing, because the players never got a hand out of it. It is on by
|
||||||
|
// default and can be turned off for a house that rakes every pot.
|
||||||
|
rakeOn(state, pot, sawFlop = true) {
|
||||||
|
const rake = state.rake || {};
|
||||||
|
const percent = Number(rake.percent) || 0;
|
||||||
|
const amount = Money.round(Number(pot) || 0);
|
||||||
|
if (percent <= 0 || amount <= 0) return 0;
|
||||||
|
if (rake.noFlopNoDrop !== false && !sawFlop) return 0;
|
||||||
|
|
||||||
|
const uncapped = (amount * percent) / 100;
|
||||||
|
const cap = Number(rake.cap);
|
||||||
|
const capped = cap > 0 ? Math.min(uncapped, cap) : uncapped;
|
||||||
|
// Never more than the pot, however the numbers were typed in.
|
||||||
|
return Money.downToChip(Math.min(capped, amount), state.chipLegend);
|
||||||
|
},
|
||||||
|
|
||||||
|
// The same payouts with the house's cut taken out of them.
|
||||||
|
//
|
||||||
|
// Off the top of the whole pot, then shared out in proportion — which for the hand
|
||||||
|
// a dealer actually deals, with one winner, is just "winner gets the pot less the
|
||||||
|
// rake". Split pots take it proportionally, and whatever the rounding leaves over
|
||||||
|
// goes back to the winners in turn rather than to the house: the house asked for a
|
||||||
|
// number, and it gets that number, not that number plus dust.
|
||||||
|
applyRake(payouts, rake) {
|
||||||
|
const total = payouts.reduce((sum, p) => sum + p.amount, 0);
|
||||||
|
const taken = Money.round(Math.min(Math.max(0, rake), total));
|
||||||
|
if (taken <= 0) return { payouts: payouts.map(p => ({ ...p })), rake: 0 };
|
||||||
|
|
||||||
|
const kept = Money.toCents(total) - Money.toCents(taken);
|
||||||
|
const shares = payouts.map(p => {
|
||||||
|
const exact = (Money.toCents(p.amount) * kept) / Money.toCents(total);
|
||||||
|
return { payout: p, cents: Math.floor(exact) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Floor everybody, then hand the leftover cents out one at a time, biggest
|
||||||
|
// share first — the same shape as the odd-chip rule the split calculator uses.
|
||||||
|
let spare = kept - shares.reduce((sum, s) => sum + s.cents, 0);
|
||||||
|
[...shares]
|
||||||
|
.sort((a, b) => b.payout.amount - a.payout.amount)
|
||||||
|
.forEach(share => { if (spare > 0) { share.cents += 1; spare -= 1; } });
|
||||||
|
|
||||||
|
return {
|
||||||
|
payouts: shares.map(share => ({ ...share.payout, amount: Money.fromCents(share.cents) })),
|
||||||
|
rake: taken,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
bankReconciliation(state) {
|
||||||
|
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
|
||||||
|
const chipsInPlay = state.seats.reduce(
|
||||||
|
(sum, p) =>
|
||||||
|
sum +
|
||||||
|
Math.max(0, Number(p.stack) || 0) +
|
||||||
|
Math.max(0, Number(p.committed) || 0) +
|
||||||
|
Math.max(0, Number(p.amountOverride) || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Chips the dealer deliberately took off the table — an abandoned pot they
|
||||||
|
// were asked about and confirmed. They are gone, so they can't be counted as
|
||||||
|
// in play, but they aren't missing either: the box knows exactly where they
|
||||||
|
// went. Without this the check blamed the tool's own confirmed action on
|
||||||
|
// "someone has paid for chips they haven't been given", which sends a dealer
|
||||||
|
// hunting for a miscount that never happened.
|
||||||
|
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
|
||||||
|
|
||||||
|
// Chips the house has taken. Off the table for good, like the discards, and
|
||||||
|
// for a better reason: the box is supposed to have them.
|
||||||
|
const raked = Math.max(0, Money.round(Number(state.rakeTaken) || 0));
|
||||||
|
|
||||||
|
const difference = Money.round(chipsInPlay + discarded + raked - cashIn);
|
||||||
|
return {
|
||||||
|
cashIn: Money.round(cashIn),
|
||||||
|
chipsInPlay: Money.round(chipsInPlay),
|
||||||
|
discarded,
|
||||||
|
raked,
|
||||||
|
difference,
|
||||||
|
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
||||||
|
balanced: Math.abs(difference) <= Money.EPSILON,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = HoldemState;
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ extern "C" { /* prevent C++ name mangling */
|
|||||||
* Internally it points to your engine state object. */
|
* Internally it points to your engine state object. */
|
||||||
typedef struct ChessEngine* EngineHandle;
|
typedef struct ChessEngine* EngineHandle;
|
||||||
|
|
||||||
|
/* Opaque per-game training accumulator (see the learned-weights ABI at the bottom). */
|
||||||
|
typedef struct Trainer* TrainerHandle;
|
||||||
|
|
||||||
/* Return codes. 0 == success; negative == error. Keep these values stable. */
|
/* Return codes. 0 == success; negative == error. Keep these values stable. */
|
||||||
enum {
|
enum {
|
||||||
CHESS_OK = 0,
|
CHESS_OK = 0,
|
||||||
@@ -79,6 +82,36 @@ CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len);
|
|||||||
/* Destroy an instance created by engine_create. Safe to call with NULL. */
|
/* Destroy an instance created by engine_create. Safe to call with NULL. */
|
||||||
CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine);
|
CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine);
|
||||||
|
|
||||||
|
/* ---- Learned-weights / training ABI ----
|
||||||
|
* The learned engine's weights live process-globally here. The host orchestrates games but
|
||||||
|
* owns no chess logic: it points the engine at the weights file, records each played
|
||||||
|
* position, and applies the game's result. */
|
||||||
|
|
||||||
|
/* Load the global learned weights from `path` and remember it for later saves. Idempotent;
|
||||||
|
* a missing/short file leaves the weights neutral. Call once before learned play/training. */
|
||||||
|
CHESS_API void CHESS_CALL learned_load(const char* path);
|
||||||
|
|
||||||
|
/* Copy the global weights out for visualization: 6*64 midgame + 6*64 endgame (PAWN..KING,
|
||||||
|
* squares 0..63) + feature weights. Returns the count written, or CHESS_ERR_BUFFER if
|
||||||
|
* out_len is too small (needs >= 776). */
|
||||||
|
CHESS_API int CHESS_CALL weights_snapshot(int* out, int out_len);
|
||||||
|
|
||||||
|
/* Create / destroy a per-game training accumulator. Safe to destroy NULL. */
|
||||||
|
CHESS_API TrainerHandle CHESS_CALL trainer_create(void);
|
||||||
|
CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle trainer);
|
||||||
|
|
||||||
|
/* Record one played position (post-move FEN) into the accumulator. */
|
||||||
|
CHESS_API void CHESS_CALL trainer_record(TrainerHandle trainer, const char* fen);
|
||||||
|
|
||||||
|
/* Apply a finished game's outcome to the global weights and save. Each touched cell (a piece
|
||||||
|
* on a square, or a feature) tracks a presence-weighted running average of the game's
|
||||||
|
* "advantage" — the game's outcome (scaled by `weight`, e.g. 0.5 for a material-imbalance
|
||||||
|
* draw) minus how much of that outcome the winner's average material edge already explains —
|
||||||
|
* so mg/eg/featW end up as a fractional win-rate (percent of games won with that piece there,
|
||||||
|
* minus percent lost), not an unbounded plus/minus tally, and a piece doesn't get credit
|
||||||
|
* merely for existing on the winning side of a material blowout. winner: 0 = white, 1 = black. */
|
||||||
|
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle trainer, int winner, double weight);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -20,10 +20,13 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iomanip>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <new>
|
#include <new>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -76,10 +79,79 @@ static chess::Move tt_move (uint64_t d) { return chess::Move(static_cast<uint16_
|
|||||||
static int tt_depth(uint64_t d) { return static_cast<int>(static_cast<uint8_t>(d >> 48)); }
|
static int tt_depth(uint64_t d) { return static_cast<int>(static_cast<uint8_t>(d >> 48)); }
|
||||||
static Bound tt_bound(uint64_t d) { return static_cast<Bound>(static_cast<uint8_t>(d >> 56)); }
|
static Bound tt_bound(uint64_t d) { return static_cast<Bound>(static_cast<uint8_t>(d >> 56)); }
|
||||||
|
|
||||||
/* Internal engine state. One ChessEngine = one game. The table is NOT here: it is the
|
/* Eval variant for an engine handle. CLASSIC = the hand-crafted evaluate(); LEARNED =
|
||||||
* shared g_tt above. */
|
* material + learned phase-split piece-square tables + learned feature weights. */
|
||||||
|
enum EvalVariant : int { EVAL_CLASSIC = 0, EVAL_LEARNED = 1 };
|
||||||
|
|
||||||
|
/* The learned feature knobs (beyond the piece-square tables). Each has one weight learned
|
||||||
|
* from game outcomes; its activation is computed by compute_features(). Mobility is per
|
||||||
|
* piece type. Order is fixed — it is the on-disk and snapshot layout after the two tables. */
|
||||||
|
enum Feature : int {
|
||||||
|
FEAT_MOB_N, FEAT_MOB_B, FEAT_MOB_R, FEAT_MOB_Q, /* legal-move counts, per piece type */
|
||||||
|
FEAT_PASSED, /* passed pawns, endgame-weighted */
|
||||||
|
FEAT_ISOLATED, /* isolated pawns */
|
||||||
|
FEAT_DOUBLED, /* doubled pawns */
|
||||||
|
FEAT_KING, /* king pawn-shelter, midgame-weighted */
|
||||||
|
FEATURE_NB
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Per-handle eval configuration, snapshotted from the global learned weights at
|
||||||
|
* engine_create so the search reads a stable copy. The tables are white-relative: a black
|
||||||
|
* piece indexes the rank-mirrored square (sq ^ 56). `mg`/`eg` are blended by game phase.
|
||||||
|
* Indexed by chess::PieceType (PAWN..KING). Only consulted when variant == EVAL_LEARNED. */
|
||||||
|
struct EvalParams {
|
||||||
|
int variant = EVAL_CLASSIC;
|
||||||
|
int mg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
int eg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
int featW[FEATURE_NB] = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Internal engine state. One ChessEngine = one game. The transposition table is NOT
|
||||||
|
* here: it is the shared g_tt above. */
|
||||||
struct ChessEngine {
|
struct ChessEngine {
|
||||||
int skill = 20; /* 1..20 from the UI; controls search depth */
|
int skill = 20; /* 1..20 from the UI; controls search depth */
|
||||||
|
EvalParams eval; /* which evaluation the search uses, plus any learned weights */
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The process-global learned weights: the single source of truth, loaded from disk once and
|
||||||
|
* updated in place by training. Engine handles snapshot it at creation; the visualization
|
||||||
|
* snapshots it on demand. Guarded by g_weightsMutex for updates/saves (eval reads its own
|
||||||
|
* per-handle copy, so it never touches this concurrently).
|
||||||
|
*
|
||||||
|
* mg/eg/featW are DERIVED, not accumulated: each is always exactly OUT_SCALE * (score/visits)
|
||||||
|
* for its cell (see trainer_apply), recomputed whenever that cell's stats change. visits is
|
||||||
|
* the presence-weighted count of games that touched the cell (how much to trust the average);
|
||||||
|
* score is the presence-weighted sum of each game's material-adjusted advantage (see
|
||||||
|
* trainer_apply) — so score/visits is literally "fraction of (presence-weighted) games won
|
||||||
|
* with this piece here, minus fraction lost," bounded to [-1,1] by construction. */
|
||||||
|
struct LearnedWeights {
|
||||||
|
int mg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
int eg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
int featW[FEATURE_NB] = {};
|
||||||
|
|
||||||
|
double mgVisits[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
double mgScore [chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
double egVisits[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
double egScore [chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
double featVisits[FEATURE_NB] = {};
|
||||||
|
double featScore [FEATURE_NB] = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
static LearnedWeights g_weights;
|
||||||
|
static std::mutex g_weightsMutex;
|
||||||
|
static std::string g_weightsPath;
|
||||||
|
|
||||||
|
/* Per-game training accumulator (one per learned CPU-vs-CPU game). Records, per ply, where
|
||||||
|
* each side's pieces sat (split into midgame/endgame by phase), each side's feature
|
||||||
|
* activations, and the white-minus-black material balance; trainer_apply turns the totals
|
||||||
|
* into a presence-weighted update to the running per-cell averages. Squares are white-relative
|
||||||
|
* (black indexes sq ^ 56), so a side's tally lines up with the shared white-relative table. */
|
||||||
|
struct Trainer {
|
||||||
|
double mgOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
double egOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
double featAcc[chess::COLOR_NB][FEATURE_NB] = {};
|
||||||
|
double matDiffAcc = 0.0; /* sum over plies of (white material - black material), cp */
|
||||||
|
int plies = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
static int copy_out(const char* src, char* out_buf, int out_len) {
|
static int copy_out(const char* src, char* out_buf, int out_len) {
|
||||||
@@ -108,6 +180,83 @@ static int parse_skill(const char* options, int fallback) {
|
|||||||
return v < 1 ? 1 : v > 20 ? 20 : v;
|
return v < 1 ? 1 : v > 20 ? 20 : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "variant=learned" in the options selects the learned eval; anything else is classic. */
|
||||||
|
static int parse_variant(const char* options) {
|
||||||
|
if (!options) return EVAL_CLASSIC;
|
||||||
|
const char* p = std::strstr(options, "variant=");
|
||||||
|
if (!p) return EVAL_CLASSIC;
|
||||||
|
return std::strncmp(p + 8, "learned", 7) == 0 ? EVAL_LEARNED : EVAL_CLASSIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On-disk format: 6*64 mg ints (PAWN..KING, squares 0..63), then 6*64 eg ints, then
|
||||||
|
* FEATURE_NB feature ints, then the backing visits/score doubles for each (mgVisits, mgScore,
|
||||||
|
* egVisits, egScore, featVisits, featScore) in the same shapes, all whitespace-separated. A
|
||||||
|
* missing file or short read leaves the rest neutral (0), so an absent weights file just means
|
||||||
|
* "train from a blank slate" — and a file written by the pre-fractional-learning format (which
|
||||||
|
* ends after the feature ints) loads its mg/eg/featW fine and simply starts every cell's
|
||||||
|
* visits/score history fresh from the next game that touches it. Caller holds g_weightsMutex. */
|
||||||
|
static void load_global_weights(const char* path) {
|
||||||
|
g_weights = LearnedWeights{}; /* reset to neutral before loading */
|
||||||
|
|
||||||
|
if (!path || !*path) return;
|
||||||
|
std::ifstream f(path);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq)
|
||||||
|
if (!(f >> g_weights.mg[pt][sq])) return;
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq)
|
||||||
|
if (!(f >> g_weights.eg[pt][sq])) return;
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i)
|
||||||
|
if (!(f >> g_weights.featW[i])) return;
|
||||||
|
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq)
|
||||||
|
if (!(f >> g_weights.mgVisits[pt][sq])) return;
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq)
|
||||||
|
if (!(f >> g_weights.mgScore[pt][sq])) return;
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq)
|
||||||
|
if (!(f >> g_weights.egVisits[pt][sq])) return;
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq)
|
||||||
|
if (!(f >> g_weights.egScore[pt][sq])) return;
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i)
|
||||||
|
if (!(f >> g_weights.featVisits[i])) return;
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i)
|
||||||
|
if (!(f >> g_weights.featScore[i])) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Persist g_weights to g_weightsPath in the format load_global_weights reads. Full round-trip
|
||||||
|
* precision on the doubles (visits/score are running sums that keep accumulating across many
|
||||||
|
* games, so truncating them would drift the derived mg/eg/featW over time). Caller holds the
|
||||||
|
* lock. */
|
||||||
|
static void save_global_weights() {
|
||||||
|
if (g_weightsPath.empty()) return;
|
||||||
|
std::ofstream f(g_weightsPath);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) f << g_weights.mg[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) f << g_weights.eg[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featW[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
|
||||||
|
|
||||||
|
f << std::setprecision(17);
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) f << g_weights.mgVisits[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) f << g_weights.mgScore[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) f << g_weights.egVisits[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) f << g_weights.egScore[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featVisits[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featScore[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
|
||||||
|
}
|
||||||
|
|
||||||
/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no
|
/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no
|
||||||
* quiescence yet, so deep fixed-depth runs get expensive quickly. */
|
* quiescence yet, so deep fixed-depth runs get expensive quickly. */
|
||||||
static int depth_for_skill(int skill) {
|
static int depth_for_skill(int skill) {
|
||||||
@@ -208,14 +357,32 @@ static int evaluatePawn(const chess::Position& pos, const chess::Color c, const
|
|||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int piece_value(chess::PieceType pt) {
|
||||||
|
switch (pt) {
|
||||||
|
case chess::PAWN: return 100;
|
||||||
|
case chess::KNIGHT: return 320;
|
||||||
|
case chess::BISHOP: return 330;
|
||||||
|
case chess::ROOK: return 500;
|
||||||
|
case chess::QUEEN: return 900;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static int castleIncentive(const chess::Position& pos, chess::Color c) {
|
static int castleIncentive(const chess::Position& pos, chess::Color c) {
|
||||||
if (!pos.pieces(chess::QUEEN))
|
chess::Bitboard pcs = pos.pieces();
|
||||||
return 0;
|
int total = 0;
|
||||||
|
while (pcs) {
|
||||||
|
chess::Square s = chess::pop_lsb(pcs);
|
||||||
|
chess::Piece pc = pos.piece_on(s);
|
||||||
|
chess::Color c = chess::color_of(pc);
|
||||||
|
total += piece_value(chess::type_of(pc));
|
||||||
|
}
|
||||||
|
|
||||||
chess::Square k = pos.king_square(c);
|
chess::Square k = pos.king_square(c);
|
||||||
bool castled = (c == chess::WHITE) ? (k == chess::G1 || k == chess::C1)
|
bool castled = (c == chess::WHITE) ? (k == chess::G1 || k == chess::C1)
|
||||||
: (k == chess::G8 || k == chess::C8);
|
: (k == chess::G8 || k == chess::C8);
|
||||||
|
|
||||||
return castled ? 600 : 0;
|
return castled ? (total / 10) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int evaluatePiece(const chess::Position& pos, const chess::Square& s, const chess::Piece& pc, const chess::Color& c) {
|
static int evaluatePiece(const chess::Position& pos, const chess::Square& s, const chess::Piece& pc, const chess::Color& c) {
|
||||||
@@ -261,10 +428,141 @@ static int evaluate(const chess::Position& pos) {
|
|||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Learned (phase-split tables + feature knobs) evaluation ---------------------------
|
||||||
|
* The model is a linear combination of features whose weights are learned from outcomes:
|
||||||
|
* eval = Σ pieces [ material + blend(mg, eg, phase) ] + Σ features featW[i]·activation[i]
|
||||||
|
* compute_features() is the single source of feature activations, used by BOTH the eval here
|
||||||
|
* and the trainer, so the two can never disagree. Constants below are the only tunables. */
|
||||||
|
|
||||||
|
/* Per-game-outcome learning constants. Squares and features both learn a presence/activation-
|
||||||
|
* weighted AVERAGE advantage in [-1,1] (see trainer_apply) — OUT_SCALE just converts that
|
||||||
|
* average into a centipawn-ish int for the eval/table to add directly. FEAT_SCALE normalizes
|
||||||
|
* each feature's raw per-ply activation to a comparable magnitude before it's used as a weight
|
||||||
|
* (so high-count mobility doesn't dwarf the small pawn-structure terms), same as before.
|
||||||
|
* MATERIAL_ELO_SCALE is the classic centipawn-to-win-probability curve
|
||||||
|
* (expected = 1/(1+10^(-cp/400))) used to compute how much of a result material already
|
||||||
|
* explains, so training credits placement/features only for the part of the result that
|
||||||
|
* material didn't already predict. */
|
||||||
|
static constexpr int SQ_OUT_SCALE = 250;
|
||||||
|
static constexpr int FEAT_OUT_SCALE = 500;
|
||||||
|
static constexpr double MATERIAL_ELO_SCALE = 400.0;
|
||||||
|
static constexpr double FEAT_SCALE[FEATURE_NB] = { 4, 6, 8, 14, 2, 1, 1, 2 };
|
||||||
|
|
||||||
|
/* Game phase in [0,1] from remaining non-pawn material (PeSTO weights N=B=1, R=2, Q=4; max
|
||||||
|
* 24 for both full sides): 0 = opening, 1 = bare kings. Drives the mg/eg table blend and
|
||||||
|
* the phase weighting of the passed-pawn (×phase) and king-safety (×(1−phase)) features. */
|
||||||
|
static double game_phase(const chess::Position& pos) {
|
||||||
|
int npm = chess::popcount(pos.pieces(chess::KNIGHT)) * 1
|
||||||
|
+ chess::popcount(pos.pieces(chess::BISHOP)) * 1
|
||||||
|
+ chess::popcount(pos.pieces(chess::ROOK)) * 2
|
||||||
|
+ chess::popcount(pos.pieces(chess::QUEEN)) * 4;
|
||||||
|
constexpr int MAX = 24;
|
||||||
|
if (npm >= MAX) return 0.0;
|
||||||
|
return double(MAX - npm) / MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blend a midgame and endgame value by phase, rounding per-piece (so training credits a
|
||||||
|
* square the same way the eval reads it). */
|
||||||
|
static int blend(int mg, int eg, double phase) {
|
||||||
|
return int(std::lround((1.0 - phase) * mg + phase * eg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fills `out[FEATURE_NB]` with one color's raw feature activations for a position. The piece-
|
||||||
|
* square tables handle "where pieces belong"; these capture context a static table can't:
|
||||||
|
* legal mobility (per piece type, so pins reduce it), passed pawns (endgame-weighted), pawn
|
||||||
|
* structure, and king shelter (midgame-weighted). Ported nowhere — this is the only copy. */
|
||||||
|
static void compute_features(chess::Position& pos, chess::Color c, double phase, double out[FEATURE_NB]) {
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i) out[i] = 0.0;
|
||||||
|
|
||||||
|
/* Mobility: legal moves for color c, bucketed by the moving piece's type. */
|
||||||
|
chess::MoveList moves;
|
||||||
|
pos.generate_legal_for(c, moves);
|
||||||
|
for (int i = 0; i < moves.size(); ++i) {
|
||||||
|
switch (chess::type_of(pos.piece_on(moves.moves[i].from()))) {
|
||||||
|
case chess::KNIGHT: out[FEAT_MOB_N] += 1; break;
|
||||||
|
case chess::BISHOP: out[FEAT_MOB_B] += 1; break;
|
||||||
|
case chess::ROOK: out[FEAT_MOB_R] += 1; break;
|
||||||
|
case chess::QUEEN: out[FEAT_MOB_Q] += 1; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pawn structure. */
|
||||||
|
chess::Bitboard pawns = pos.pieces(c, chess::PAWN);
|
||||||
|
chess::Bitboard bb = pawns;
|
||||||
|
while (bb) {
|
||||||
|
chess::Square s = chess::pop_lsb(bb);
|
||||||
|
|
||||||
|
if (!(front_span(c, s) & pos.pieces(~c, chess::PAWN))) { /* passed */
|
||||||
|
chess::Rank r = chess::rank_of(s);
|
||||||
|
int toPromotion = (c == chess::WHITE) ? (chess::RANK_8 - r) : (r - chess::RANK_1);
|
||||||
|
out[FEAT_PASSED] += (6 - toPromotion) * phase; /* 0..5 ranks advanced, late-game */
|
||||||
|
}
|
||||||
|
if (front_span_file_only(c, s) & pawns) /* doubled (friendly pawn ahead) */
|
||||||
|
out[FEAT_DOUBLED] += 1;
|
||||||
|
|
||||||
|
chess::File f = chess::file_of(s);
|
||||||
|
chess::Bitboard adjacent = 0;
|
||||||
|
if (f > chess::FILE_A) adjacent |= chess::file_bb(chess::File(f - 1));
|
||||||
|
if (f < chess::FILE_H) adjacent |= chess::file_bb(chess::File(f + 1));
|
||||||
|
if (!(adjacent & pawns)) /* isolated */
|
||||||
|
out[FEAT_ISOLATED] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* King safety: friendly pawns sheltering the king (its file + adjacent files, the two
|
||||||
|
* ranks in front), worth more in the midgame. */
|
||||||
|
chess::Square k = pos.king_square(c);
|
||||||
|
chess::File kf = chess::file_of(k);
|
||||||
|
chess::Rank kr = chess::rank_of(k);
|
||||||
|
chess::Bitboard kingFiles = chess::file_bb(kf);
|
||||||
|
if (kf > chess::FILE_A) kingFiles |= chess::file_bb(chess::File(kf - 1));
|
||||||
|
if (kf < chess::FILE_H) kingFiles |= chess::file_bb(chess::File(kf + 1));
|
||||||
|
chess::Bitboard shelterRanks = 0;
|
||||||
|
for (int d = 1; d <= 2; ++d) {
|
||||||
|
int rr = (c == chess::WHITE) ? (kr + d) : (kr - d);
|
||||||
|
if (rr >= 0 && rr <= 7) shelterRanks |= (0xFFULL << (8 * rr));
|
||||||
|
}
|
||||||
|
out[FEAT_KING] += chess::popcount(kingFiles & shelterRanks & pawns) * (1.0 - phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Learned eval (white-positive/absolute, like evaluate()): material + phase-blended piece-
|
||||||
|
* square tables + learned feature weights. Black pieces index the rank-mirrored square
|
||||||
|
* (s ^ 56) so both colors share one white-relative table. Non-const because mobility
|
||||||
|
* generates legal moves (which the position's move generator does via do/undo). */
|
||||||
|
static int evaluateLearned(chess::Position& pos, const EvalParams& ep) {
|
||||||
|
double phase = game_phase(pos);
|
||||||
|
int score = 0;
|
||||||
|
|
||||||
|
chess::Bitboard white = pos.pieces(chess::WHITE);
|
||||||
|
while (white) {
|
||||||
|
chess::Square s = chess::pop_lsb(white);
|
||||||
|
chess::PieceType pt = chess::type_of(pos.piece_on(s));
|
||||||
|
score += piece_value(pt) + blend(ep.mg[pt][s], ep.eg[pt][s], phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
chess::Bitboard black = pos.pieces(chess::BLACK);
|
||||||
|
while (black) {
|
||||||
|
chess::Square s = chess::pop_lsb(black);
|
||||||
|
chess::PieceType pt = chess::type_of(pos.piece_on(s));
|
||||||
|
score -= piece_value(pt) + blend(ep.mg[pt][s ^ 56], ep.eg[pt][s ^ 56], phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
double wFeat[FEATURE_NB], bFeat[FEATURE_NB];
|
||||||
|
compute_features(pos, chess::WHITE, phase, wFeat);
|
||||||
|
compute_features(pos, chess::BLACK, phase, bFeat);
|
||||||
|
|
||||||
|
double feature = 0.0;
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i)
|
||||||
|
feature += ep.featW[i] * (wFeat[i] - bFeat[i]) / FEAT_SCALE[i];
|
||||||
|
score += int(std::lround(feature));
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
/* evaluate() is white-positive (absolute). Negamax needs it relative to the side to
|
/* evaluate() is white-positive (absolute). Negamax needs it relative to the side to
|
||||||
* move, so flip the sign when black is to move. */
|
* move, so flip the sign when black is to move. */
|
||||||
static int evaluate_stm(const chess::Position& pos, bool whiteToMove) {
|
static int evaluate_stm(chess::Position& pos, bool whiteToMove, const EvalParams& ep) {
|
||||||
int s = evaluate(pos);
|
int s = (ep.variant == EVAL_LEARNED) ? evaluateLearned(pos, ep) : evaluate(pos);
|
||||||
return whiteToMove ? s : -s;
|
return whiteToMove ? s : -s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,17 +572,6 @@ static int evaluate_stm(const chess::Position& pos, bool whiteToMove) {
|
|||||||
static int score_to_tt(int s, int ply) { return s >= MATE_BOUND ? s + ply : s <= -MATE_BOUND ? s - ply : s; }
|
static int score_to_tt(int s, int ply) { return s >= MATE_BOUND ? s + ply : s <= -MATE_BOUND ? s - ply : s; }
|
||||||
static int score_from_tt(int s, int ply) { return s >= MATE_BOUND ? s - ply : s <= -MATE_BOUND ? s + ply : s; }
|
static int score_from_tt(int s, int ply) { return s >= MATE_BOUND ? s - ply : s <= -MATE_BOUND ? s + ply : s; }
|
||||||
|
|
||||||
static int piece_value(chess::PieceType pt) {
|
|
||||||
switch (pt) {
|
|
||||||
case chess::PAWN: return 100;
|
|
||||||
case chess::KNIGHT: return 320;
|
|
||||||
case chess::BISHOP: return 330;
|
|
||||||
case chess::ROOK: return 500;
|
|
||||||
case chess::QUEEN: return 900;
|
|
||||||
default: return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Heuristic for searching the most promising moves first, which makes alpha-beta prune far
|
/* Heuristic for searching the most promising moves first, which makes alpha-beta prune far
|
||||||
* more. Bands, highest first: the TT best move, then captures by MVV-LVA (most valuable
|
* more. Bands, highest first: the TT best move, then captures by MVV-LVA (most valuable
|
||||||
* victim, least valuable attacker), then the two killer moves for this ply (quiet moves that
|
* victim, least valuable attacker), then the two killer moves for this ply (quiet moves that
|
||||||
@@ -351,6 +638,15 @@ CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) {
|
|||||||
auto* e = new (std::nothrow) ChessEngine();
|
auto* e = new (std::nothrow) ChessEngine();
|
||||||
if (!e) return nullptr;
|
if (!e) return nullptr;
|
||||||
e->skill = parse_skill(options, e->skill);
|
e->skill = parse_skill(options, e->skill);
|
||||||
|
e->eval.variant = parse_variant(options);
|
||||||
|
if (e->eval.variant == EVAL_LEARNED) {
|
||||||
|
/* Snapshot the current global weights so the search reads a stable copy (training
|
||||||
|
* updates the global between games; the weights path is owned by learned_load). */
|
||||||
|
std::lock_guard<std::mutex> lock(g_weightsMutex);
|
||||||
|
std::memcpy(e->eval.mg, g_weights.mg, sizeof e->eval.mg);
|
||||||
|
std::memcpy(e->eval.eg, g_weights.eg, sizeof e->eval.eg);
|
||||||
|
std::memcpy(e->eval.featW, g_weights.featW, sizeof e->eval.featW);
|
||||||
|
}
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,8 +665,9 @@ CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine,
|
|||||||
static constexpr int MAX_PLY = 128; /* ply never exceeds maxDepth (<= 20) */
|
static constexpr int MAX_PLY = 128; /* ply never exceeds maxDepth (<= 20) */
|
||||||
|
|
||||||
struct SearchContext {
|
struct SearchContext {
|
||||||
uint64_t nodes = 0;
|
uint64_t nodes = 0;
|
||||||
chess::Move killers[MAX_PLY][2] = {}; /* [ply][slot]; MOVE_NONE until filled */
|
const EvalParams* eval = nullptr; /* eval config for this search; set by engine_best_move */
|
||||||
|
chess::Move killers[MAX_PLY][2] = {};/* [ply][slot]; MOVE_NONE until filled */
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Negamax alpha-beta over the shared transposition table. `maxDepth` is the searching
|
/* Negamax alpha-beta over the shared transposition table. `maxDepth` is the searching
|
||||||
@@ -387,7 +684,7 @@ static int negamax(chess::Position& pos, int maxDepth, int depth, int ply,
|
|||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
if (depth <= 0)
|
if (depth <= 0)
|
||||||
return evaluate_stm(pos, whiteToMove);
|
return evaluate_stm(pos, whiteToMove, *ctx.eval);
|
||||||
|
|
||||||
const uint64_t key = pos.key();
|
const uint64_t key = pos.key();
|
||||||
TTEntry& slot = g_tt.entries[key & g_tt.mask];
|
TTEntry& slot = g_tt.entries[key & g_tt.mask];
|
||||||
@@ -502,6 +799,7 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
|
|||||||
return CHESS_ERR_NO_MOVE;
|
return CHESS_ERR_NO_MOVE;
|
||||||
|
|
||||||
SearchContext ctx;
|
SearchContext ctx;
|
||||||
|
ctx.eval = &engine->eval;
|
||||||
int maxDepth = depth_for_skill(engine->skill);
|
int maxDepth = depth_for_skill(engine->skill);
|
||||||
chess::Move bestMove = moves.moves[0]; /* guaranteed-legal fallback */
|
chess::Move bestMove = moves.moves[0]; /* guaranteed-legal fallback */
|
||||||
|
|
||||||
@@ -547,4 +845,135 @@ CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine) {
|
|||||||
delete engine; /* delete nullptr is safe */
|
delete engine; /* delete nullptr is safe */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Learned-weights / training C ABI --------------------------------------------------
|
||||||
|
* The managed side orchestrates games but owns no chess logic: it tells the engine where
|
||||||
|
* to load/save the global weights, records each played position, and applies the result. */
|
||||||
|
|
||||||
|
CHESS_API void CHESS_CALL learned_load(const char* path) {
|
||||||
|
std::lock_guard<std::mutex> lock(g_weightsMutex);
|
||||||
|
g_weightsPath = path ? path : "";
|
||||||
|
load_global_weights(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
CHESS_API int CHESS_CALL weights_snapshot(int* out, int out_len) {
|
||||||
|
const int need = 6 * 64 * 2 + FEATURE_NB; /* mg + eg (PAWN..KING) + features = 776 */
|
||||||
|
if (!out || out_len < need) return CHESS_ERR_BUFFER;
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(g_weightsMutex);
|
||||||
|
int n = 0;
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) out[n++] = g_weights.mg[pt][sq];
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) out[n++] = g_weights.eg[pt][sq];
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i) out[n++] = g_weights.featW[i];
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
CHESS_API TrainerHandle CHESS_CALL trainer_create(void) {
|
||||||
|
return new (std::nothrow) Trainer();
|
||||||
|
}
|
||||||
|
|
||||||
|
CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
|
||||||
|
if (!t || !fen || !*fen) return;
|
||||||
|
ensure_initialized();
|
||||||
|
|
||||||
|
chess::Position pos = chess::Position::from_fen(fen);
|
||||||
|
double phase = game_phase(pos);
|
||||||
|
|
||||||
|
/* Per-square occupancy, split into midgame/endgame by phase, white-relative; and the
|
||||||
|
* white-minus-black material balance at this ply, used at trainer_apply time to strip
|
||||||
|
* "material already explains this" out of the training signal. */
|
||||||
|
chess::Bitboard occ = pos.pieces();
|
||||||
|
while (occ) {
|
||||||
|
chess::Square s = chess::pop_lsb(occ);
|
||||||
|
chess::Piece pc = pos.piece_on(s);
|
||||||
|
chess::Color c = chess::color_of(pc);
|
||||||
|
chess::PieceType pt = chess::type_of(pc);
|
||||||
|
int relSq = (c == chess::WHITE) ? int(s) : (int(s) ^ 56);
|
||||||
|
t->mgOcc[c][pt][relSq] += (1.0 - phase);
|
||||||
|
t->egOcc[c][pt][relSq] += phase;
|
||||||
|
t->matDiffAcc += (c == chess::WHITE ? 1 : -1) * piece_value(pt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-side feature activations. */
|
||||||
|
double w[FEATURE_NB], b[FEATURE_NB];
|
||||||
|
compute_features(pos, chess::WHITE, phase, w);
|
||||||
|
compute_features(pos, chess::BLACK, phase, b);
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i) {
|
||||||
|
t->featAcc[chess::WHITE][i] += w[i];
|
||||||
|
t->featAcc[chess::BLACK][i] += b[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
t->plies++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Applies a finished game's outcome as a presence-weighted running average per cell, instead
|
||||||
|
* of an unbounded incremental nudge:
|
||||||
|
*
|
||||||
|
* score[cell] / visits[cell] == "fraction of (presence-weighted) games won with this piece
|
||||||
|
* on this square, minus fraction lost"
|
||||||
|
*
|
||||||
|
* `weight` is the caller's confidence in the result (1.0 for a checkmate, 0.5 for a
|
||||||
|
* material-imbalance draw, as before). On top of that, the update is scaled by how much of the
|
||||||
|
* result ISN'T already explained by material: `expected` is the win probability the winner's
|
||||||
|
* average material edge over the game implies (the same centipawn->win-probability curve
|
||||||
|
* engines use for Elo estimates); `1 - expected` is the "surprise" left over once material is
|
||||||
|
* accounted for. A win where the winner was already up a rook the whole game is unsurprising
|
||||||
|
* (expected ~= 1, ~no update) — the eval already knows material matters via piece_value(), so
|
||||||
|
* square/feature tables shouldn't get credit for "a piece existed"; a win pulled off (or a draw
|
||||||
|
* held) while materially behind is highly informative and gets a correspondingly large update.
|
||||||
|
* This same magnitude applies to BOTH the winner's reward and the loser's punishment: by the
|
||||||
|
* symmetry of win probabilities (P(loser wins) = 1 - P(winner wins)), the loser's own advantage
|
||||||
|
* — actual(0) minus their expected win probability — has exactly the same magnitude, `1 -
|
||||||
|
* expected`, just negative. */
|
||||||
|
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle t, int winner, double weight) {
|
||||||
|
if (!t || t->plies <= 0) return;
|
||||||
|
|
||||||
|
double avgMaterialDiff = t->matDiffAcc / t->plies; /* white - black, cp */
|
||||||
|
double winnerMaterialDiff = (winner == chess::WHITE) ? avgMaterialDiff : -avgMaterialDiff;
|
||||||
|
double expected = 1.0 / (1.0 + std::pow(10.0, -winnerMaterialDiff / MATERIAL_ELO_SCALE));
|
||||||
|
double advantageMag = weight * (1.0 - expected);
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(g_weightsMutex);
|
||||||
|
|
||||||
|
/* pass 0 = winner (positive advantage); pass 1 = loser (negative advantage). */
|
||||||
|
for (int pass = 0; pass < 2; ++pass) {
|
||||||
|
chess::Color side = chess::Color((pass == 0 ? winner : (winner ^ 1)) & 1);
|
||||||
|
double advantage = pass == 0 ? advantageMag : -advantageMag;
|
||||||
|
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) {
|
||||||
|
double mgFrac = t->mgOcc[side][pt][sq] / t->plies; /* fraction of the game spent here, [0,1] */
|
||||||
|
if (mgFrac != 0.0) {
|
||||||
|
g_weights.mgVisits[pt][sq] += mgFrac;
|
||||||
|
g_weights.mgScore [pt][sq] += mgFrac * advantage;
|
||||||
|
g_weights.mg[pt][sq] = int(std::lround(
|
||||||
|
SQ_OUT_SCALE * g_weights.mgScore[pt][sq] / g_weights.mgVisits[pt][sq]));
|
||||||
|
}
|
||||||
|
double egFrac = t->egOcc[side][pt][sq] / t->plies;
|
||||||
|
if (egFrac != 0.0) {
|
||||||
|
g_weights.egVisits[pt][sq] += egFrac;
|
||||||
|
g_weights.egScore [pt][sq] += egFrac * advantage;
|
||||||
|
g_weights.eg[pt][sq] = int(std::lround(
|
||||||
|
SQ_OUT_SCALE * g_weights.egScore[pt][sq] / g_weights.egVisits[pt][sq]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i) {
|
||||||
|
double activation = (t->featAcc[side][i] / t->plies) / FEAT_SCALE[i]; /* per-ply average, normalized */
|
||||||
|
if (activation == 0.0) continue;
|
||||||
|
g_weights.featVisits[i] += std::abs(activation);
|
||||||
|
g_weights.featScore [i] += activation * advantage;
|
||||||
|
g_weights.featW[i] = int(std::lround(
|
||||||
|
FEAT_OUT_SCALE * g_weights.featScore[i] / g_weights.featVisits[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save_global_weights();
|
||||||
|
}
|
||||||
|
|
||||||
|
CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle t) {
|
||||||
|
delete t; /* delete nullptr is safe */
|
||||||
|
}
|
||||||
|
|
||||||
} /* extern "C" */
|
} /* extern "C" */
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ public:
|
|||||||
void do_move(Move m);
|
void do_move(Move m);
|
||||||
void undo_move(Move m);
|
void undo_move(Move m);
|
||||||
|
|
||||||
|
// Legal moves for a SPECIFIED color (for mobility eval of either side). When c is not
|
||||||
|
// the side to move, temporarily flips side-to-move (and clears the en-passant square,
|
||||||
|
// which belongs to the other side) so generate_legal runs for c, then restores. The
|
||||||
|
// Zobrist key is untouched and unused by move generation, so this leaves the position
|
||||||
|
// observably unchanged.
|
||||||
|
void generate_legal_for(Color c, MoveList& list) {
|
||||||
|
if (sideToMove == c) { generate_legal(list); return; }
|
||||||
|
Color savedSide = sideToMove;
|
||||||
|
Square savedEp = epSquare;
|
||||||
|
sideToMove = c;
|
||||||
|
epSquare = SQ_NONE;
|
||||||
|
generate_legal(list);
|
||||||
|
sideToMove = savedSide;
|
||||||
|
epSquare = savedEp;
|
||||||
|
}
|
||||||
|
|
||||||
// Seed prior-position keys (oldest first, excluding the current position) so
|
// Seed prior-position keys (oldest first, excluding the current position) so
|
||||||
// is_draw() can see game history the FEN doesn't carry. Call once, right after
|
// is_draw() can see game history the FEN doesn't carry. Call once, right after
|
||||||
// from_fen and before any do_move.
|
// from_fen and before any do_move.
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Hold'em dealer tests
|
||||||
|
|
||||||
|
Unit tests for the money logic behind `/Casino/TexasHoldem` — the betting engine, the
|
||||||
|
side-pot calculator, the split calculator and the table state.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test tests/holdem/*.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
No install step, no `package.json`, no dependencies. The browser modules are plain
|
||||||
|
script-tag files that each end with a `module.exports` guard, so Node can require them
|
||||||
|
as-is; `load.js` puts them on `globalThis` under the same names they use to find each
|
||||||
|
other in the page, in the load order from `TexasHoldem.cshtml`.
|
||||||
|
|
||||||
|
## What's covered
|
||||||
|
|
||||||
|
| File | Covers |
|
||||||
|
| --- | --- |
|
||||||
|
| `potCalculator.test.js` | Main and side pots, dead money, uncalled bets, odd-cent distribution |
|
||||||
|
| `betting.test.js` | Blinds, street completion, raises reopening the action, all-in run-outs |
|
||||||
|
| `state.test.js` | Button rotation, which seats post blinds, session standings |
|
||||||
|
| `splitCalculator.test.js` | Chopped pots, odd chips, unmakeable denominations |
|
||||||
|
| `invariants.test.js` | 1000 randomised hands per property (see below) |
|
||||||
|
|
||||||
|
## The layout audit
|
||||||
|
|
||||||
|
The unit tests above cover the money. `uiAudit.js` covers the thing you can only see
|
||||||
|
in a browser: whether a dealer can actually reach every control.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tests/holdem/runUiAudit.js # needs Playwright and the site running
|
||||||
|
```
|
||||||
|
|
||||||
|
It checks WCAG AA contrast, touch target sizes, touch-action and tap highlights, that fields stay selectable and are large
|
||||||
|
enough not to trigger iOS zoom, that nothing important is covered or off-screen with
|
||||||
|
no way to scroll to it, that no grid column has collapsed to nothing, and that no
|
||||||
|
text is painted outside its box. It runs over the table, all five overlays and Quick
|
||||||
|
Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
|
||||||
|
phones on their side — each viewport twice, once with a mouse and once with a thumb.
|
||||||
|
|
||||||
|
That last part, and two of those checks, are newer than this paragraph claiming them.
|
||||||
|
`runAll()` did not include the contrast check at all, and touch targets sat behind an
|
||||||
|
opt-in flag nothing passed, so "the audit is clean" meant nine checks rather than
|
||||||
|
eleven for a long run of rounds. Both were written and tested and simply never called.
|
||||||
|
The mouse/thumb pair matters for the same reason: the 44px rule stands itself down
|
||||||
|
where `hover: hover` says there is a pointer, and a headless desktop browser is a
|
||||||
|
pointer, so a single pass had never measured a touch target at any viewport. Between
|
||||||
|
them they found a 9px seat label at 3.39:1, a 37px-tall amount field and a 30px delete
|
||||||
|
button on Quick Split.
|
||||||
|
|
||||||
|
A check that decides it doesn't apply says so in the output rather than reporting no
|
||||||
|
offenders, which reads identically to a pass. The same goes for the two exemptions
|
||||||
|
that exist: a disabled control, which WCAG itself excuses, and a link inside a
|
||||||
|
sentence, whose size is set by the line it sits in.
|
||||||
|
|
||||||
|
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
|
||||||
|
this one needs a browser and a running site. Run it after any change that adds or
|
||||||
|
resizes a control. With no Playwright to hand, paste `uiAudit.js` into devtools and
|
||||||
|
call `LayoutAudit.runAll()`.
|
||||||
|
|
||||||
|
The contrast check is built so it cannot report a false positive: backgrounds
|
||||||
|
composite through alpha, and where a gradient is involved every colour stop is tried
|
||||||
|
and the most flattering one used. A finding means the text fails even under the
|
||||||
|
reading most generous to it. Two earlier attempts at this got it wrong in both
|
||||||
|
directions — one read only `background-color` and scored text on a gradient against
|
||||||
|
nothing, the other treated low-alpha stops as opaque — so `uiAudit.js` is checked
|
||||||
|
against known-answer cases before it is believed.
|
||||||
|
|
||||||
|
Touch targets are measured by probing outward with `elementFromPoint` rather than by
|
||||||
|
reading the element box, because the box lies in both directions: a small control can
|
||||||
|
be padded out with a transparent `::after` and respond well beyond its painted size,
|
||||||
|
and a checkbox inside a label is hit by tapping the label. The 44px rule is only
|
||||||
|
enforced where `hover: none` says there's no mouse.
|
||||||
|
|
||||||
|
It exists because these checks were being re-typed into a console every time
|
||||||
|
something changed, and each time they only covered whatever had just been touched.
|
||||||
|
That is how the standings table shipped with a player-name column zero pixels wide
|
||||||
|
and kept it for two rounds.
|
||||||
|
|
||||||
|
## Why the randomised suite exists
|
||||||
|
|
||||||
|
Every correctness bug found in this tool so far has been shape-dependent: blinds posted
|
||||||
|
to empty chairs, the button skipping past players, heads-up acting out of order, an
|
||||||
|
overbet announced as a side pot. None of them show up on a full six-handed table with
|
||||||
|
equal stacks, which is the table anyone writes a fixed example for.
|
||||||
|
|
||||||
|
`invariants.test.js` deals a thousand hands across random seat counts, random empty
|
||||||
|
chairs, random stack depths and random dealer input, and checks the things a dealer
|
||||||
|
would notice being wrong:
|
||||||
|
|
||||||
|
- chips are never created or destroyed during play
|
||||||
|
- the pot breakdown plus the uncalled returns always equal the money that went in
|
||||||
|
- paying the pot out settles it to the cent
|
||||||
|
- once uncalled chips are handed back, nobody is still betting above the whole table
|
||||||
|
- the button and both blinds always land on players who are actually in the hand
|
||||||
|
|
||||||
|
The generator is seeded, so a failure reproduces from the seed printed in the message
|
||||||
|
rather than being a flake to re-run.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Who the felt marks "ALL IN".
|
||||||
|
//
|
||||||
|
// The mark's whole value is that a dealer can scan the ring and see who can still be
|
||||||
|
// bet at, so the test that matters most is the last one: the seats it marks are
|
||||||
|
// exactly the seats the betting round refuses to ask for another chip.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||||
|
|
||||||
|
test("a player who moved all-in is marked", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||||
|
state.seats[3].stack = 12;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.allIn(state, state.actingSeatIndex);
|
||||||
|
|
||||||
|
assert.equal(Betting.isAllIn(state, state.seats[3]), true);
|
||||||
|
assert.equal(state.seats[3].stack, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a player with chips behind is not", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(Betting.isAllIn(state, state.seats[0]), false);
|
||||||
|
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a busted player who was dealt in but has put nothing in is not", () => {
|
||||||
|
// An empty stack alone isn't all-in — it's someone who shouldn't have been dealt
|
||||||
|
// in. Calling that "ALL IN" on the felt would be a lie about a live pot.
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||||
|
state.seats[2].stack = 0;
|
||||||
|
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a folded player with an empty stack is not", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||||
|
state.seats[1].committed = 5;
|
||||||
|
state.seats[1].stack = 0;
|
||||||
|
state.seats[1].folded = true;
|
||||||
|
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty chair is not", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||||
|
assert.equal(Betting.isAllIn(state, state.seats[4]), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a seat with no stack of its own falls back to the table's and isn't marked", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||||
|
state.defaultStack = 200;
|
||||||
|
state.seats[2].stack = null;
|
||||||
|
state.seats[2].committed = 10;
|
||||||
|
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the seats marked are exactly the ones the action passes over", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||||
|
state.seats[1].stack = 8;
|
||||||
|
state.seats[3].stack = 30;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
// Everyone in, two of them for less than the others can cover.
|
||||||
|
for (let step = 0; step < 4; step++) Betting.allIn(state, state.actingSeatIndex);
|
||||||
|
|
||||||
|
const marked = state.seats
|
||||||
|
.map((player, index) => (Betting.isAllIn(state, player) ? index : null))
|
||||||
|
.filter(index => index !== null);
|
||||||
|
const skipped = state.seats
|
||||||
|
.map((player, index) =>
|
||||||
|
player.inHand && !player.folded && Betting.nextToAct(state, index - 1) !== index ? index : null)
|
||||||
|
.filter(index => index !== null);
|
||||||
|
|
||||||
|
assert.deepEqual(marked, skipped);
|
||||||
|
assert.ok(marked.length > 0, "the scenario should actually produce all-in players");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nobody is marked once the next hand starts", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||||
|
state.seats[2].stack = 5;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.allIn(state, state.actingSeatIndex);
|
||||||
|
assert.ok(state.seats.some(player => Betting.isAllIn(state, player)));
|
||||||
|
|
||||||
|
// The short stack got there by winning nothing; give them chips back the way a
|
||||||
|
// dealer would before the next deal.
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
state.seats[2].stack = 50;
|
||||||
|
assert.equal(state.seats.filter(player => Betting.isAllIn(state, player)).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marking nobody doesn't move a chip", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||||
|
state.seats[3].stack = 12;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const before = chipsOnTable(state);
|
||||||
|
state.seats.forEach(player => Betting.isAllIn(state, player));
|
||||||
|
assert.equal(chipsOnTable(state), before);
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// The sums the contrast check runs on, tested away from a browser.
|
||||||
|
//
|
||||||
|
// The check itself needs a page to look at, but the arithmetic underneath it doesn't —
|
||||||
|
// and the arithmetic is the part that decides whether a real fault is reported or
|
||||||
|
// waved through. Faded text used to be filed as "not checked" rather than measured;
|
||||||
|
// this is the compositing that replaced that.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const LayoutAudit = require("./uiAudit");
|
||||||
|
|
||||||
|
const WHITE = { r: 255, g: 255, b: 255, a: 1 };
|
||||||
|
const BLACK = { r: 0, g: 0, b: 0, a: 1 };
|
||||||
|
const PAGE = { r: 28, g: 28, b: 28, a: 1 }; // the felt page behind everything
|
||||||
|
const PUCK = { r: 44, g: 44, b: 44, a: 1 }; // a seat's own background
|
||||||
|
|
||||||
|
describe("the ratio itself", () => {
|
||||||
|
test("black on white is the full 21:1, either way round", () => {
|
||||||
|
assert.ok(Math.abs(LayoutAudit.contrastRatio(BLACK, WHITE) - 21) < 0.01);
|
||||||
|
assert.ok(Math.abs(LayoutAudit.contrastRatio(WHITE, BLACK) - 21) < 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a colour against itself is 1:1", () => {
|
||||||
|
assert.ok(Math.abs(LayoutAudit.contrastRatio(PUCK, PUCK) - 1) < 0.001);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("text on a box that is being faded", () => {
|
||||||
|
// opacity on a box fades the box and its text together onto whatever is behind
|
||||||
|
// it — so a dimmed seat is not grey text on a dark puck, it is a faded puck and
|
||||||
|
// faded text both landing on the page.
|
||||||
|
const fade = alpha => ({ alpha, behind: PAGE });
|
||||||
|
|
||||||
|
test("at full strength nothing is composited", () => {
|
||||||
|
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 1, behind: PAGE });
|
||||||
|
assert.deepEqual(painted.fg, WHITE);
|
||||||
|
assert.deepEqual(painted.bg, PUCK);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fading moves both the text and its background, not just the text", () => {
|
||||||
|
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
|
||||||
|
assert.ok(painted.fg.r < WHITE.r, "the text dims");
|
||||||
|
assert.ok(painted.bg.r < PUCK.r, "and so does what it sits on");
|
||||||
|
assert.ok(painted.bg.r > PAGE.r, "but not all the way to the page");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("which is what stops a fade being reported as a fault it isn't", () => {
|
||||||
|
// Dimming light text on a dark puck over a dark page costs contrast, but far
|
||||||
|
// less than measuring the faded text against the *unfaded* puck would claim.
|
||||||
|
const honest = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
|
||||||
|
const wrong = { fg: LayoutAudit.over({ ...WHITE, a: 0.7 }, PUCK), bg: PUCK };
|
||||||
|
assert.ok(LayoutAudit.contrastRatio(honest.fg, honest.bg) >
|
||||||
|
LayoutAudit.contrastRatio(wrong.fg, wrong.bg));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a fade all the way out lands everything on the page behind", () => {
|
||||||
|
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0));
|
||||||
|
assert.deepEqual(painted.fg, PAGE);
|
||||||
|
assert.deepEqual(painted.bg, PAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with nothing known to be behind it, the fade is left alone", () => {
|
||||||
|
// Better to measure the unfaded pair than to invent a backdrop.
|
||||||
|
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 0.5, behind: null });
|
||||||
|
assert.deepEqual(painted.fg, WHITE);
|
||||||
|
assert.deepEqual(painted.bg, PUCK);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a fade makes small grey text worse, which is the fault it went looking for", () => {
|
||||||
|
// Deliberately not asserting a particular CSS colour here: the page's own greys are
|
||||||
|
// measured in the browser, against the real gradients, where they live. What this
|
||||||
|
// pins is the direction — that dimming a box costs its text contrast, which is why
|
||||||
|
// filing faded text as "not checked" hid faults rather than avoiding false ones.
|
||||||
|
test("the same grey scores lower once its box is dimmed", () => {
|
||||||
|
const grey = LayoutAudit.parseColour("rgb(138, 138, 138)");
|
||||||
|
const solid = LayoutAudit.asPainted(grey, PUCK, { alpha: 1, behind: PAGE });
|
||||||
|
const dimmed = LayoutAudit.asPainted(grey, PUCK, { alpha: 0.7, behind: PAGE });
|
||||||
|
assert.ok(LayoutAudit.contrastRatio(dimmed.fg, dimmed.bg) <
|
||||||
|
LayoutAudit.contrastRatio(solid.fg, solid.bg));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and a lighter grey scores higher at the same fade", () => {
|
||||||
|
const at = colour => {
|
||||||
|
const painted = LayoutAudit.asPainted(LayoutAudit.parseColour(colour), PUCK, { alpha: 0.7, behind: PAGE });
|
||||||
|
return LayoutAudit.contrastRatio(painted.fg, painted.bg);
|
||||||
|
};
|
||||||
|
assert.ok(at("rgb(174, 174, 174)") > at("rgb(138, 138, 138)"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reading a colour off the page", () => {
|
||||||
|
test("rgb and rgba both come back as numbers", () => {
|
||||||
|
assert.deepEqual(LayoutAudit.parseColour("rgb(1, 2, 3)"), { r: 1, g: 2, b: 3, a: 1 });
|
||||||
|
assert.deepEqual(LayoutAudit.parseColour("rgba(1, 2, 3, 0.5)"), { r: 1, g: 2, b: 3, a: 0.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a gradient gives up every stop, because text could be over any of them", () => {
|
||||||
|
const stops = LayoutAudit.gradientStops("linear-gradient(rgb(203, 170, 240), rgb(169, 127, 214))");
|
||||||
|
assert.equal(stops.length, 2);
|
||||||
|
assert.deepEqual(stops[0], { r: 203, g: 170, b: 240, a: 1 });
|
||||||
|
assert.deepEqual(stops[1], { r: 169, g: 127, b: 214, a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no background image, no stops", () => {
|
||||||
|
assert.deepEqual(LayoutAudit.gradientStops("none"), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// The banker's job, which is a different job from the dealer's: every chip on the
|
||||||
|
// table is a promise to pay cash for it later, and somebody has to be able to check
|
||||||
|
// that the promise can be kept before the last two players try to cash out.
|
||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Betting, HoldemState, Money, makeTable } = require("./load");
|
||||||
|
|
||||||
|
const seat = (state, index, name, cash, chips) => {
|
||||||
|
const p = state.seats[index];
|
||||||
|
p.name = name; p.inHand = true; p.buyIns = cash; p.stack = chips;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("everyone with money in the game is accounted for", () => {
|
||||||
|
test("a player who has bought in but played no logged hand still appears", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 0 });
|
||||||
|
seat(state, 0, "Ann", 100, 100);
|
||||||
|
seat(state, 1, "Ben", 100, 100);
|
||||||
|
seat(state, 2, "Cal", 200, 200); // just sat down
|
||||||
|
|
||||||
|
state.seats[0].amountOverride = 10;
|
||||||
|
state.seats[1].amountOverride = 10;
|
||||||
|
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 20 }]);
|
||||||
|
state.seats[0].amountOverride = null;
|
||||||
|
state.seats[1].amountOverride = null;
|
||||||
|
|
||||||
|
const names = HoldemState.sessionStandings(state).map(r => r.name);
|
||||||
|
assert.ok(names.includes("Cal"), `Cal handed over $200 and is missing: ${names.join(", ")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("their row reads as no hands played, not as a loss", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
|
||||||
|
seat(state, 0, "Ann", 100, 100);
|
||||||
|
seat(state, 1, "Ben", 200, 200);
|
||||||
|
|
||||||
|
const cal = HoldemState.sessionStandings(state).find(r => r.name === "Ben");
|
||||||
|
assert.equal(cal.in, 0);
|
||||||
|
assert.equal(cal.won, 0);
|
||||||
|
assert.equal(cal.net, 0);
|
||||||
|
assert.equal(cal.boughtIn, 200);
|
||||||
|
assert.equal(cal.cashPosition, 0, "level: they've bought chips and still hold them");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty chair is not dragged into the settle-up", () => {
|
||||||
|
const state = makeTable({ seatCount: 9, seated: [0], stack: 0 });
|
||||||
|
seat(state, 0, "Ann", 100, 100);
|
||||||
|
const rows = HoldemState.sessionStandings(state);
|
||||||
|
assert.equal(rows.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("somebody who has cashed out completely still shows what they took", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
|
||||||
|
seat(state, 0, "Ann", 100, 0); // handed every chip back
|
||||||
|
seat(state, 1, "Ben", 100, 200);
|
||||||
|
const ann = HoldemState.sessionStandings(state).find(r => r.name === "Ann");
|
||||||
|
assert.equal(ann.cashPosition, -100, "down the whole buy-in");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("does the cash box cover the chips on the table", () => {
|
||||||
|
test("a straight game balances", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 });
|
||||||
|
seat(state, 0, "Ann", 100, 60);
|
||||||
|
seat(state, 1, "Ben", 100, 140);
|
||||||
|
seat(state, 2, "Cal", 100, 100);
|
||||||
|
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.cashIn, 300);
|
||||||
|
assert.equal(bank.chipsInPlay, 300);
|
||||||
|
assert.equal(bank.difference, 0);
|
||||||
|
assert.equal(bank.balanced, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chips outrunning the cash box is caught before cash-out", () => {
|
||||||
|
// The 2am failure: the last players out can't be paid.
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 });
|
||||||
|
seat(state, 0, "Ann", 100, 100);
|
||||||
|
seat(state, 1, "Ben", 100, 100);
|
||||||
|
seat(state, 2, "Cal", 100, 260); // took chips for a rebuy nobody wrote down
|
||||||
|
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.cashIn, 300);
|
||||||
|
assert.equal(bank.chipsInPlay, 460);
|
||||||
|
assert.equal(bank.difference, 160);
|
||||||
|
assert.equal(bank.balanced, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cash taken for chips never handed over is caught too", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
|
||||||
|
seat(state, 0, "Ann", 200, 100); // paid 200, only got 100 in chips
|
||||||
|
seat(state, 1, "Ben", 100, 100);
|
||||||
|
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.difference, -100);
|
||||||
|
assert.equal(bank.balanced, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chips in the middle still count as chips in play", () => {
|
||||||
|
// Money in the pot is somebody's; it just hasn't been won yet. Leaving it out
|
||||||
|
// would report a shortfall every time a hand was open — which is nearly always.
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; });
|
||||||
|
|
||||||
|
const before = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(before.balanced, true);
|
||||||
|
|
||||||
|
Betting.beginHand(state); // blinds go in
|
||||||
|
Betting.raiseTo(state, 0, 25); // and a raise
|
||||||
|
Betting.sweepToHandTotals(state); // banked into the pot
|
||||||
|
|
||||||
|
const during = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(during.balanced, true, "an open pot is not a discrepancy");
|
||||||
|
assert.equal(during.chipsInPlay, before.chipsInPlay);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a whole session of play never knocks the books out", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; });
|
||||||
|
|
||||||
|
for (let hand = 0; hand < 6; hand++) {
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.liveSeats(state).forEach(s => {
|
||||||
|
if (Betting.toCall(state, s.player) > 0) Betting.call(state, s.index);
|
||||||
|
});
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
const pot = HoldemState.totalPot(state);
|
||||||
|
const winner = Betting.liveSeats(state)[0];
|
||||||
|
const payouts = [{ id: winner.player.id, label: "w", amount: pot }];
|
||||||
|
HoldemState.recordHand(state, payouts);
|
||||||
|
Betting.creditWinnings(state, payouts);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true, `books off after hand ${hand + 1}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rebuy keeps the books straight, a chip grab doesn't", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
|
||||||
|
seat(state, 0, "Ann", 100, 100);
|
||||||
|
seat(state, 1, "Ben", 100, 100);
|
||||||
|
|
||||||
|
// A proper rebuy: cash to the banker, chips to the player.
|
||||||
|
state.seats[0].buyIns += 100;
|
||||||
|
state.seats[0].stack += 100;
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
|
||||||
|
// Chips taken without paying.
|
||||||
|
state.seats[1].stack += 50;
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.balanced, false);
|
||||||
|
assert.equal(bank.difference, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nothing to reconcile before anybody has bought in", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [], stack: 0 });
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.cashIn, 0);
|
||||||
|
assert.equal(bank.chipsInPlay, 0);
|
||||||
|
assert.equal(bank.balanced, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("float dust is not a discrepancy", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
|
||||||
|
seat(state, 0, "Ann", 0.1 + 0.2, 0.3);
|
||||||
|
seat(state, 1, "Ben", 10, 10);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the number the banker counts out", () => {
|
||||||
|
// The standings had two signed columns and neither was an amount to pay. A player
|
||||||
|
// up $140 on a $340 stack read "$140" under a heading saying "Cash out", at the
|
||||||
|
// exact moment somebody was counting money onto the table.
|
||||||
|
test("chips in front is what they are holding, not what they are up", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 340 });
|
||||||
|
state.seats[0].name = "Ann";
|
||||||
|
state.seats[0].buyIns = 200;
|
||||||
|
state.seats[1].buyIns = 200;
|
||||||
|
state.seats[1].stack = 60;
|
||||||
|
|
||||||
|
const rows = HoldemState.sessionStandings(state);
|
||||||
|
const ann = rows.find(r => r.name === "Ann");
|
||||||
|
assert.equal(ann.chipsInFront, 340, "the cash to count out");
|
||||||
|
assert.equal(ann.cashPosition, 140, "and separately, that they are up $140");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a player who is down still has chips to be handed", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 60 });
|
||||||
|
state.seats[0].name = "Cal";
|
||||||
|
state.seats.forEach(p => { p.buyIns = 200; });
|
||||||
|
|
||||||
|
const cal = HoldemState.sessionStandings(state).find(r => r.name === "Cal");
|
||||||
|
assert.equal(cal.chipsInFront, 60, "they leave with $60 of chips");
|
||||||
|
assert.equal(cal.cashPosition, -140, "having lost $140 of the $200 they brought");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chips still out in the middle are counted as theirs", () => {
|
||||||
|
// Cashing out mid-hand isn't a thing, but the panel is read mid-hand constantly
|
||||||
|
// and a stack that drops by the size of a bet looks like money going missing.
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 200 });
|
||||||
|
state.seats[0].name = "Dee";
|
||||||
|
state.seats.forEach(p => { p.buyIns = 200; });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.raiseTo(state, 0, 50);
|
||||||
|
|
||||||
|
const dee = HoldemState.sessionStandings(state).find(r => r.name === "Dee");
|
||||||
|
assert.equal(dee.chipsInFront, 200, "stack plus what's in the middle for them");
|
||||||
|
assert.equal(dee.cashPosition, 0, "so they are still level");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chips and up/down always differ by exactly the buy-in", () => {
|
||||||
|
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 100 });
|
||||||
|
[340, 150, 60, 250].forEach((chips, i) => {
|
||||||
|
state.seats[i].stack = chips;
|
||||||
|
state.seats[i].buyIns = 200 + i * 50;
|
||||||
|
});
|
||||||
|
|
||||||
|
HoldemState.sessionStandings(state).forEach(row => {
|
||||||
|
assert.equal(Money.round(row.chipsInFront - row.boughtIn), row.cashPosition,
|
||||||
|
`${row.name}'s two columns should tell one story`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Betting, makeTable, chipsOnTable } = require("./load");
|
||||||
|
|
||||||
|
describe("Betting.beginHand", () => {
|
||||||
|
test("blinds are posted and the seat after the big blind acts first", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0, smallBlind: 1, bigBlind: 2 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[1].committed, 1, "small blind");
|
||||||
|
assert.equal(state.seats[2].committed, 2, "big blind");
|
||||||
|
assert.equal(state.currentBet, 2);
|
||||||
|
assert.equal(state.actingSeatIndex, 3, "under the gun");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("heads-up, the button posts the small blind and acts first pre-flop", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[2].committed, 1, "the button is the small blind");
|
||||||
|
assert.equal(state.seats[5].committed, 2);
|
||||||
|
assert.equal(state.actingSeatIndex, 2, "the small blind acts first pre-flop heads-up");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("heads-up, the big blind acts first once the flop is out", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 2);
|
||||||
|
Betting.check(state, 5);
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
|
||||||
|
assert.equal(state.street, "flop");
|
||||||
|
assert.equal(state.actingSeatIndex, 5, "the big blind is first to act after the flop");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a blind bigger than a stack puts that player all-in without stalling the action", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], dealerSeatIndex: 0, smallBlind: 50, bigBlind: 100 });
|
||||||
|
state.seats[0].stack = 10; // can't cover the small blind
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(state.seats[0].stack, 0, "all-in for what they had");
|
||||||
|
assert.equal(state.seats[0].committed, 10);
|
||||||
|
assert.notEqual(state.actingSeatIndex, 0, "a player with no chips has nothing to decide");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("posting blinds never invents or destroys chips", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||||
|
const before = chipsOnTable(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(chipsOnTable(state), before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Betting street completion", () => {
|
||||||
|
test("a street ends once everyone has matched the bet and had a turn", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
Betting.call(state, 3);
|
||||||
|
Betting.call(state, 0);
|
||||||
|
Betting.call(state, 1);
|
||||||
|
assert.equal(Betting.isStreetComplete(state), false, "the big blind still has an option");
|
||||||
|
|
||||||
|
Betting.check(state, 2);
|
||||||
|
assert.equal(Betting.isStreetComplete(state), true);
|
||||||
|
assert.equal(state.actingSeatIndex, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a raise reopens the action for everyone who had already called", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
Betting.call(state, 3);
|
||||||
|
Betting.call(state, 0);
|
||||||
|
assert.equal(state.seats[3].hasActed, true);
|
||||||
|
|
||||||
|
Betting.raiseTo(state, 1, 10);
|
||||||
|
assert.equal(state.seats[3].hasActed, false, "seat 3 owes a response again");
|
||||||
|
assert.equal(state.seats[0].hasActed, false);
|
||||||
|
assert.equal(state.currentBet, 10);
|
||||||
|
assert.equal(Betting.isStreetComplete(state), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the action skips a player who is all-in but still in the hand", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
state.seats[3].stack = 5;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
Betting.allIn(state, 3);
|
||||||
|
assert.equal(state.seats[3].stack, 0);
|
||||||
|
assert.notEqual(state.actingSeatIndex, 3);
|
||||||
|
// They are still live and can still win the pot.
|
||||||
|
assert.ok(Betting.liveSeats(state).some(s => s.index === 3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing a street sweeps chips into hand totals and resets the bet", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 3);
|
||||||
|
Betting.call(state, 0);
|
||||||
|
Betting.call(state, 1);
|
||||||
|
Betting.check(state, 2);
|
||||||
|
|
||||||
|
const before = chipsOnTable(state);
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
|
||||||
|
assert.equal(state.currentBet, 0);
|
||||||
|
assert.equal(state.street, "flop");
|
||||||
|
assert.equal(chipsOnTable(state), before, "sweeping moves chips, it doesn't change the total");
|
||||||
|
state.seats.forEach(p => assert.equal(p.committed, 0));
|
||||||
|
assert.equal(Betting.handTotal(state.seats[2]), 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Betting all-in run-outs", () => {
|
||||||
|
test("with everyone all-in there is nobody left to ask", () => {
|
||||||
|
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 20, dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.allIn(state, 2);
|
||||||
|
Betting.allIn(state, 0);
|
||||||
|
Betting.allIn(state, 1);
|
||||||
|
|
||||||
|
assert.equal(Betting.canStillBet(state), false);
|
||||||
|
assert.equal(Betting.isRunningOut(state), true, "the board just gets dealt out");
|
||||||
|
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
assert.equal(state.actingSeatIndex, null, "no phantom action on the flop");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one player with chips against an all-in cannot bet alone", () => {
|
||||||
|
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
state.seats[1].stack = 5;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.fold(state, 2);
|
||||||
|
Betting.allIn(state, 0);
|
||||||
|
Betting.allIn(state, 1);
|
||||||
|
|
||||||
|
assert.equal(Betting.canStillBet(state), false);
|
||||||
|
assert.equal(Betting.isHandOver(state), false, "two players are still live for the showdown");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("everyone folding to one player ends the hand", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.fold(state, 3);
|
||||||
|
Betting.fold(state, 0);
|
||||||
|
Betting.fold(state, 1);
|
||||||
|
|
||||||
|
assert.equal(Betting.isHandOver(state), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Betting money handling", () => {
|
||||||
|
test("a player can never put in more than they have", () => {
|
||||||
|
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 30, dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const moved = Betting.putIn(state, 0, 500);
|
||||||
|
assert.equal(moved, 30);
|
||||||
|
assert.equal(state.seats[0].stack, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("raise-to is what a dealer says out loud, not raise-by", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.raiseTo(state, 3, 10);
|
||||||
|
assert.equal(state.seats[3].committed, 10, "their street total is 10, not 10 on top");
|
||||||
|
assert.equal(state.seats[3].stack, 90);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the minimum raise has to match the last one", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(Betting.minRaiseTo(state), 4, "a big blind of 2 makes the first raise 4");
|
||||||
|
|
||||||
|
Betting.raiseTo(state, 3, 10);
|
||||||
|
assert.equal(Betting.minRaiseTo(state), 18, "an 8-chip raise means the next one is to 18");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("winnings land back in front of the player", () => {
|
||||||
|
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.creditWinnings(state, [{ id: state.seats[0].id, amount: 42.5 }]);
|
||||||
|
assert.equal(state.seats[0].stack, 142.5, "the button posts nothing three-handed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fractional blinds don't drift into floating-point dust", () => {
|
||||||
|
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 10, smallBlind: 0.1, bigBlind: 0.2 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 0); // button limps
|
||||||
|
Betting.call(state, 1); // small blind completes
|
||||||
|
Betting.check(state, 2); // big blind is happy
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
|
||||||
|
assert.equal(chipsOnTable(state), 30, "0.1 + 0.2 arithmetic didn't lose a chip");
|
||||||
|
assert.equal(Betting.potSoFar(state), 0.6);
|
||||||
|
state.seats.forEach(p => assert.equal(p.stack, 9.8));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Dealing around a player who busted and hasn't bought back in.
|
||||||
|
//
|
||||||
|
// A home game produces one of these about once an hour. The table used to deal them
|
||||||
|
// in anyway, promise a blind from a seat with nothing to post it with, take it from
|
||||||
|
// nobody, and then sit on a hand that could never finish.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||||
|
|
||||||
|
const postedBy = state =>
|
||||||
|
state.seats
|
||||||
|
.map((player, index) => (player.committed > 0 ? { seat: index + 1, amount: player.committed } : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
test("a seat with no chips and nothing in the pot is broke", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||||
|
state.seats[2].stack = 0;
|
||||||
|
assert.deepEqual(Betting.brokeSeats(state), [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an all-in player is not broke — they have money riding on the hand", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||||
|
state.seats[2].stack = 12;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.allIn(state, 2);
|
||||||
|
|
||||||
|
assert.equal(Betting.stackOf(state, state.seats[2]), 0);
|
||||||
|
assert.equal(Betting.isAllIn(state, state.seats[2]), true);
|
||||||
|
assert.deepEqual(Betting.brokeSeats(state), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a seat with no stack recorded falls back to the table's and isn't broke", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||||
|
state.defaultStack = 200;
|
||||||
|
state.seats[2].stack = null;
|
||||||
|
assert.deepEqual(Betting.brokeSeats(state), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a chair nobody is sitting in isn't broke, it's empty", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||||
|
assert.deepEqual(Betting.brokeSeats(state), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dealing sits the broke seat out and leaves everything else about it alone", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||||
|
state.seats[2].stack = 0;
|
||||||
|
state.seats[2].name = "Cal";
|
||||||
|
state.seats[2].buyIns = 100;
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[2].inHand, false);
|
||||||
|
assert.equal(state.seats[2].folded, false, "sat out, not folded — they didn't muck anything");
|
||||||
|
assert.equal(state.seats[2].name, "Cal");
|
||||||
|
assert.equal(state.seats[2].buyIns, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the blinds skip the broke seat and land on players who can post", () => {
|
||||||
|
// Button on seat 1, so the blinds would be seats 2 and 3. Seat 2 is busted.
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||||
|
state.seats[1].stack = 0;
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.deepEqual(postedBy(state), [
|
||||||
|
{ seat: 3, amount: state.smallBlind },
|
||||||
|
{ seat: 4, amount: state.bigBlind },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the preview promises what the deal produces", () => {
|
||||||
|
// The fault this whole step exists to prevent: the preview once read a table the
|
||||||
|
// deal was about to change underneath it.
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||||
|
state.seats[1].stack = 0;
|
||||||
|
state.bettingOpen = false;
|
||||||
|
|
||||||
|
const preview = Betting.blindPreview(state);
|
||||||
|
assert.deepEqual(preview.satOut, [1]);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const posted = postedBy(state);
|
||||||
|
assert.equal(posted.find(p => p.amount === state.smallBlind).seat, preview.sbIndex + 1);
|
||||||
|
assert.equal(posted.find(p => p.amount === state.bigBlind).seat, preview.bbIndex + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asking for the preview doesn't sit anybody out on its own", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||||
|
state.seats[1].stack = 0;
|
||||||
|
|
||||||
|
Betting.blindPreview(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[1].inHand, true, "the preview is a view, not a change");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with only one player left holding chips there is nothing to preview", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||||
|
state.seats[1].stack = 0;
|
||||||
|
state.seats[2].stack = 0;
|
||||||
|
|
||||||
|
assert.equal(Betting.blindPreview(state), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no chips move when the broke seats are dealt around", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||||
|
state.seats[2].stack = 0;
|
||||||
|
const before = chipsOnTable(state);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(chipsOnTable(state), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the board isn't running out before the hand is dealt", () => {
|
||||||
|
// Two busted players and one with chips looks exactly like a table of all-ins to
|
||||||
|
// the run-out test, and the bar was announcing one over an empty felt.
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||||
|
state.seats[1].stack = 0;
|
||||||
|
state.seats[2].stack = 0;
|
||||||
|
state.bettingOpen = false;
|
||||||
|
|
||||||
|
assert.equal(Betting.isRunningOut(state), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buying back in puts them in the next hand", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||||
|
state.seats[2].stack = 0;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(state.seats[2].inHand, false);
|
||||||
|
|
||||||
|
// What the dealer does when they hand over cash: chips, and back in.
|
||||||
|
state.seats[2].stack = 100;
|
||||||
|
state.seats[2].inHand = true;
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[2].inHand, true);
|
||||||
|
assert.equal(Betting.liveSeats(state).length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the table dealt around them counts the right number of players", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||||
|
state.seats[3].stack = 0;
|
||||||
|
|
||||||
|
assert.equal(Betting.liveSeats(state).length, 4);
|
||||||
|
assert.equal(Betting.liveSeats(Betting.tableAsDealt(state)).length, 3);
|
||||||
|
assert.equal(state.seats[3].inHand, true, "the view didn't touch the table");
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Whether handing a seat chips also puts them back in the game.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { Betting, makeTable } = require("./load");
|
||||||
|
|
||||||
|
test("a busted player who has been sat out comes back in", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||||
|
state.seats[1].stack = 0;
|
||||||
|
Betting.beginHand(state); // the deal sits them out
|
||||||
|
|
||||||
|
assert.equal(state.seats[1].inHand, false);
|
||||||
|
assert.equal(Betting.buyInDealsThemIn(state.seats[1]), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a chair being bought into for the first time comes in", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||||
|
state.seats[4].name = "Eve";
|
||||||
|
state.seats[4].stack = null;
|
||||||
|
|
||||||
|
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a seat sat out while holding chips stays out", () => {
|
||||||
|
// A dealer sat them out on purpose and is topping them up. Dealing them back in
|
||||||
|
// would be the tool guessing at something it wasn't told.
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||||
|
state.seats[2].inHand = false;
|
||||||
|
|
||||||
|
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("somebody already in the hand is left exactly where they are", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||||
|
assert.equal(Betting.buyInDealsThemIn(state.seats[0]), false);
|
||||||
|
|
||||||
|
// Including a player who is in but out of chips — they're all-in, not sat out.
|
||||||
|
state.seats[2].stack = 0;
|
||||||
|
assert.equal(state.seats[2].inHand, true);
|
||||||
|
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a blank stack field reads as nothing recorded, not as a number", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||||
|
state.seats[3].name = "Dee";
|
||||||
|
state.seats[3].stack = "";
|
||||||
|
|
||||||
|
assert.equal(Betting.buyInDealsThemIn(state.seats[3]), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it doesn't ask the table what a missing stack would have been", () => {
|
||||||
|
// stackOf substitutes the table's starting stack for a seat that has none, which
|
||||||
|
// would make a seat with nothing recorded look like it was holding chips.
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||||
|
state.defaultStack = 500;
|
||||||
|
state.seats[4].name = "Eve";
|
||||||
|
state.seats[4].stack = null;
|
||||||
|
|
||||||
|
assert.equal(Betting.stackOf(state, state.seats[4]), 500);
|
||||||
|
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asking doesn't change anything", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||||
|
state.seats[2].stack = 0;
|
||||||
|
state.seats[2].inHand = false;
|
||||||
|
const before = JSON.stringify(state);
|
||||||
|
|
||||||
|
Betting.buyInDealsThemIn(state.seats[2]);
|
||||||
|
|
||||||
|
assert.equal(JSON.stringify(state), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nothing to decide about a seat that isn't there", () => {
|
||||||
|
assert.equal(Betting.buyInDealsThemIn(null), false);
|
||||||
|
assert.equal(Betting.buyInDealsThemIn(undefined), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Calling the clock on a player.
|
||||||
|
//
|
||||||
|
// The bar already had a stopwatch saying how long somebody had been thinking. This is
|
||||||
|
// what a dealer does about it: a procedure with a shape — a set time, a countdown said
|
||||||
|
// out loud, and a ruling at the end that depends on what the player was facing.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { ActionBar, Betting, makeTable } = require("./load");
|
||||||
|
|
||||||
|
// The bar rebuilds the DOM when a clock starts; none of the rules below need it.
|
||||||
|
const onTable = (state) => {
|
||||||
|
ActionBar.state = state;
|
||||||
|
ActionBar.render = () => {};
|
||||||
|
ActionBar.stopClock();
|
||||||
|
return ActionBar;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dealt = () => {
|
||||||
|
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("starting one", () => {
|
||||||
|
test("it runs on the player who is actually on the clock", () => {
|
||||||
|
const state = dealt();
|
||||||
|
const bar = onTable(state);
|
||||||
|
assert.equal(bar.clockRunning(), false, "nothing runs until it is called");
|
||||||
|
bar.callClock();
|
||||||
|
assert.equal(bar.clockRunning(), true);
|
||||||
|
assert.equal(bar.clockSeatIndex, state.actingSeatIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with nobody to act there is nothing to call", () => {
|
||||||
|
const state = dealt();
|
||||||
|
state.actingSeatIndex = null;
|
||||||
|
const bar = onTable(state);
|
||||||
|
bar.callClock();
|
||||||
|
assert.equal(bar.clockRunning(), false);
|
||||||
|
assert.equal(bar.clockLeft(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the dealer can put it away again", () => {
|
||||||
|
const bar = onTable(dealt());
|
||||||
|
bar.callClock();
|
||||||
|
bar.stopClock();
|
||||||
|
assert.equal(bar.clockRunning(), false);
|
||||||
|
assert.equal(bar.clockLeft(), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the count", () => {
|
||||||
|
const at = (bar, seconds) => bar.clockLeft(bar.clockStartedAt + seconds * 1000);
|
||||||
|
|
||||||
|
test("is the player's own time and then a countdown", () => {
|
||||||
|
const bar = onTable(dealt());
|
||||||
|
bar.callClock();
|
||||||
|
assert.equal(at(bar, 0), bar.CLOCK_THINK_S + bar.CLOCK_COUNTDOWN_S);
|
||||||
|
assert.equal(at(bar, 30), bar.CLOCK_COUNTDOWN_S, "thirty gone, the countdown starts");
|
||||||
|
assert.equal(at(bar, 35), 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops at zero rather than going negative", () => {
|
||||||
|
const bar = onTable(dealt());
|
||||||
|
bar.callClock();
|
||||||
|
assert.equal(at(bar, 40), 0);
|
||||||
|
assert.equal(at(bar, 600), 0, "ten minutes later it is still just up");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what happens at zero", () => {
|
||||||
|
test("facing a bet, the hand is dead", () => {
|
||||||
|
const state = dealt();
|
||||||
|
const bar = onTable(state);
|
||||||
|
// Whoever is up pre-flop is facing the big blind.
|
||||||
|
const ruling = bar.clockRuling();
|
||||||
|
assert.equal(ruling.verb, "fold");
|
||||||
|
assert.match(ruling.label, /hand is dead/);
|
||||||
|
assert.match(ruling.say, /facing \$2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("facing nothing, it is checked — slowness doesn't fold a free hand", () => {
|
||||||
|
const state = dealt();
|
||||||
|
// Everyone calls round to the big blind, who has nothing to call.
|
||||||
|
[3, 4, 5, 0, 1].forEach(seat => Betting.call(state, seat));
|
||||||
|
const bar = onTable(state);
|
||||||
|
assert.equal(state.actingSeatIndex, 2, "the big blind is up");
|
||||||
|
const ruling = bar.clockRuling();
|
||||||
|
assert.equal(ruling.verb, "check");
|
||||||
|
assert.match(ruling.label, /check for/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the ruling names the player, because the dealer has to say it out loud", () => {
|
||||||
|
const state = dealt();
|
||||||
|
state.seats[state.actingSeatIndex].name = "Priya";
|
||||||
|
const ruling = onTable(state).clockRuling();
|
||||||
|
assert.match(ruling.label, /Priya/);
|
||||||
|
assert.match(ruling.say, /Priya/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with nobody to act there is no ruling to make", () => {
|
||||||
|
const state = dealt();
|
||||||
|
state.actingSeatIndex = null;
|
||||||
|
assert.equal(onTable(state).clockRuling(), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what ends it", () => {
|
||||||
|
test("the player acting", () => {
|
||||||
|
const state = dealt();
|
||||||
|
const bar = onTable(state);
|
||||||
|
bar.callClock();
|
||||||
|
const wasOn = state.actingSeatIndex;
|
||||||
|
Betting.call(state, wasOn);
|
||||||
|
assert.notEqual(state.actingSeatIndex, wasOn, "the action has moved");
|
||||||
|
assert.equal(bar.clockRunning(), false, "so the clock is no longer this player's");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and it doesn't follow the action to the next player", () => {
|
||||||
|
// A clock is called on one decision. Left running it would put a countdown on
|
||||||
|
// somebody nobody asked about.
|
||||||
|
const state = dealt();
|
||||||
|
const bar = onTable(state);
|
||||||
|
bar.callClock();
|
||||||
|
Betting.call(state, state.actingSeatIndex);
|
||||||
|
assert.equal(bar.clockLeft(), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// The chips to actually push, for an amount.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { Money } = require("./load");
|
||||||
|
|
||||||
|
// A standard house set.
|
||||||
|
const LEGEND = [
|
||||||
|
{ value: 1, color: "#fff" },
|
||||||
|
{ value: 5, color: "#c0392b" },
|
||||||
|
{ value: 25, color: "#27ae60" },
|
||||||
|
{ value: 100, color: "#2c3e50" },
|
||||||
|
{ value: 500, color: "#8e44ad" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const say = (amount, legend = LEGEND) => Money.formatChipBreakdown(amount, legend);
|
||||||
|
|
||||||
|
test("a payout comes back as chips, largest first", () => {
|
||||||
|
assert.equal(say(132), "1×$100, 1×$25, 1×$5, 2×$1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it uses the fewest chips on a normal set", () => {
|
||||||
|
const { chips } = Money.chipBreakdown(500, LEGEND);
|
||||||
|
assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["1×500"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a denomination that doesn't fit is skipped rather than listed as none", () => {
|
||||||
|
assert.equal(say(7), "1×$5, 2×$1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nothing to pay is nothing to say", () => {
|
||||||
|
assert.equal(say(0), "");
|
||||||
|
assert.equal(say(-5), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no legend means no advice, not a wrong answer", () => {
|
||||||
|
// A table that never set chip colours up gets the amount on its own.
|
||||||
|
assert.equal(say(132, []), "");
|
||||||
|
assert.equal(say(132, null), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legend with no values in it yet is the same as none", () => {
|
||||||
|
assert.equal(say(132, [{ value: "", color: "#fff" }, { value: 0, color: "#000" }]), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("what the smallest chip can't make is reported, not rounded away", () => {
|
||||||
|
// 50c in a game whose smallest chip is a dollar is the dealer's problem to solve,
|
||||||
|
// and they can only solve it if they're told.
|
||||||
|
const { chips, remainder } = Money.chipBreakdown(10.5, LEGEND);
|
||||||
|
assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["2×5"]);
|
||||||
|
assert.equal(remainder, 0.5);
|
||||||
|
assert.equal(say(10.5), "2×$5, $0.50 no chip covers");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cent chips make cent amounts exactly", () => {
|
||||||
|
const cents = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }];
|
||||||
|
assert.equal(say(0.41, cents), "1×$0.25, 3×$0.05, 1×$0.01");
|
||||||
|
assert.equal(Money.chipBreakdown(0.41, cents).remainder, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the chips add up to the amount", () => {
|
||||||
|
for (const amount of [1, 7, 33, 132, 499, 500, 1234, 9999]) {
|
||||||
|
const { chips, remainder } = Money.chipBreakdown(amount, LEGEND);
|
||||||
|
const total = chips.reduce((sum, c) => sum + c.count * c.value, 0) + remainder;
|
||||||
|
assert.equal(Money.round(total), amount, `${amount} should be accounted for`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legend in any order still pays largest first", () => {
|
||||||
|
const jumbled = [{ value: 25 }, { value: 1 }, { value: 500 }, { value: 5 }, { value: 100 }];
|
||||||
|
assert.equal(say(631, jumbled), "1×$500, 1×$100, 1×$25, 1×$5, 1×$1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the colour comes along, so the felt can say it in chips", () => {
|
||||||
|
const { chips } = Money.chipBreakdown(25, LEGEND);
|
||||||
|
assert.equal(chips[0].color, "#27ae60");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legend that exists but can't reach the amount does say so", () => {
|
||||||
|
// Different from having no legend at all: this table has told us its chips, and
|
||||||
|
// the answer is that they don't go that low.
|
||||||
|
assert.equal(say(3, [{ value: 5 }, { value: 25 }]), "$3 no chip covers");
|
||||||
|
});
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
// The button when somebody gets up out of the blinds.
|
||||||
|
//
|
||||||
|
// Passing the button to the next *player* is right until a chair between the button
|
||||||
|
// and the blinds empties. Then it pulls the whole blind order forward a place: the
|
||||||
|
// player after the empty chair is never asked for a big blind, and the one after them
|
||||||
|
// is asked an orbit early. The dead button rule holds the rotation to the seats for
|
||||||
|
// one hand so the big blind still advances exactly one player.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { HoldemState, Betting, makeTable } = require("./load");
|
||||||
|
|
||||||
|
// Four players in six chairs: Ann 0, Ben 1, Cal 2, Dee 3.
|
||||||
|
const NAMES = ["Ann", "Ben", "Cal", "Dee"];
|
||||||
|
const table = (dealerSeatIndex = 0) => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex });
|
||||||
|
NAMES.forEach((name, i) => { state.seats[i].name = name; });
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nameOf = (state, index) =>
|
||||||
|
index === null || index === undefined ? null : (state.seats[index].name || `empty seat ${index + 1}`);
|
||||||
|
|
||||||
|
const blinds = state => {
|
||||||
|
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||||
|
return { sb: nameOf(state, sbIndex), bb: nameOf(state, bbIndex) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const leave = (state, index) => {
|
||||||
|
state.seats[index].name = "";
|
||||||
|
state.seats[index].inHand = false;
|
||||||
|
state.seats[index].stack = 0;
|
||||||
|
state.seats[index].buyIns = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("somebody gets up out of the blinds", () => {
|
||||||
|
test("the small blind leaves: dead button, and the big blind still moves one player", () => {
|
||||||
|
const state = table(1); // button Ben, small Cal, big Dee
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.deepEqual(blinds(state), { sb: "Cal", bb: "Dee" });
|
||||||
|
|
||||||
|
leave(state, 2); // Cal takes their chips and goes
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.dealerSeatIndex, 2, "the button holds Cal's empty chair");
|
||||||
|
assert.equal(state.seats[2].inHand, false, "which is the point — it's on nobody");
|
||||||
|
assert.deepEqual(blinds(state), { sb: "Dee", bb: "Ann" },
|
||||||
|
"Dee moves from big to small and Ann posts the big blind, one player along");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and Ann is not skipped, which is the money in it", () => {
|
||||||
|
// The old rotation passed the button to the next player, which made the blinds
|
||||||
|
// Ann/Ben — Ann never posting a big blind at all, and Ben posting one an orbit
|
||||||
|
// early. That is a real big blind out of a real player's stack.
|
||||||
|
const state = table(1);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
leave(state, 2);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
assert.equal(blinds(state).bb, "Ann", "Ann owes the big blind and still owes it");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the big blind leaves: no small blind is posted this hand", () => {
|
||||||
|
const state = table(1); // button Ben, small Cal, big Dee
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
leave(state, 3); // Dee goes
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
const { sbIndex, bbIndex, deadSmallBlind } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(state.dealerSeatIndex, 2, "the button reaches Cal as it was always going to");
|
||||||
|
assert.equal(sbIndex, null, "the small blind is dead");
|
||||||
|
assert.equal(deadSmallBlind, true, "and says so, rather than looking like a table with no blinds");
|
||||||
|
assert.equal(nameOf(state, bbIndex), "Ann", "the big blind still moves on one player");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a dead small blind still deals a hand, with one blind in the middle", () => {
|
||||||
|
const state = table(1);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
leave(state, 3);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.bettingOpen, true, "the hand goes ahead");
|
||||||
|
const posted = state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
|
||||||
|
assert.equal(posted, state.bigBlind, "just the big blind, and nothing conjured to stand in for the small");
|
||||||
|
assert.equal(state.currentBet, state.bigBlind);
|
||||||
|
assert.ok(state.actingSeatIndex !== null, "and somebody is on the clock");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("somebody leaving from anywhere else changes nothing", () => {
|
||||||
|
const state = table(1); // button Ben, small Cal, big Dee
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
leave(state, 0); // Ann, who was under the gun
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.dealerSeatIndex, 2, "the button passes to Cal exactly as before");
|
||||||
|
assert.equal(state.deadButton, false);
|
||||||
|
assert.equal(state.deadSmallBlind, false);
|
||||||
|
assert.deepEqual(blinds(state), { sb: "Dee", bb: "Ben" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the ordinary rotation is untouched", () => {
|
||||||
|
test("a full table passes the button one player at a time", () => {
|
||||||
|
const state = table(0);
|
||||||
|
const seen = [];
|
||||||
|
for (let hand = 0; hand < 8; hand++) {
|
||||||
|
Betting.beginHand(state);
|
||||||
|
seen.push(blinds(state).bb);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
}
|
||||||
|
assert.deepEqual(seen, ["Cal", "Dee", "Ann", "Ben", "Cal", "Dee", "Ann", "Ben"],
|
||||||
|
"two clean orbits, everybody paying one big blind per orbit");
|
||||||
|
assert.equal(state.deadButton, false);
|
||||||
|
assert.equal(state.deadSmallBlind, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("players spread thinly around a big table don't create dead positions", () => {
|
||||||
|
// Three players in nine chairs. The chairs between them were never in the
|
||||||
|
// rotation, so holding to seats there would post blinds to nobody hand after
|
||||||
|
// hand — which is why the button follows players, not chairs, by default.
|
||||||
|
const state = makeTable({ seatCount: 9, seated: [0, 4, 7], stack: 200, dealerSeatIndex: 0 });
|
||||||
|
["Ann", "", "", "", "Ben", "", "", "Cal"].forEach((n, i) => { state.seats[i].name = n; });
|
||||||
|
|
||||||
|
const seen = [];
|
||||||
|
for (let hand = 0; hand < 6; hand++) {
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.ok(state.seats[sbIndex].inHand, "a real player posts the small blind");
|
||||||
|
assert.ok(state.seats[bbIndex].inHand, "and a real player the big");
|
||||||
|
seen.push(nameOf(state, bbIndex));
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
}
|
||||||
|
assert.deepEqual(seen, ["Cal", "Ann", "Ben", "Cal", "Ann", "Ben"], "one big blind each per orbit");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pressing next hand twice without dealing just passes the button along", () => {
|
||||||
|
const state = table(0);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
assert.equal(state.dealerSeatIndex, 1);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
assert.equal(state.dealerSeatIndex, 2, "and doesn't rotate off the same hand twice");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the rule knows where it stops", () => {
|
||||||
|
test("down to two players, heads-up takes over and nothing is left dead", () => {
|
||||||
|
const state = table(1);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
leave(state, 2);
|
||||||
|
leave(state, 3); // both blinds walk at once
|
||||||
|
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
const { sbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(headsUp, true);
|
||||||
|
assert.equal(sbIndex, state.dealerSeatIndex, "heads-up the button posts the small blind");
|
||||||
|
assert.ok(state.seats[state.dealerSeatIndex].inHand, "and the button is on a player again");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a button stranded by accident is still rescued", () => {
|
||||||
|
// Not the dead button: nobody left out of the blinds, the chair the button is
|
||||||
|
// on just emptied. That one does want putting back on a player.
|
||||||
|
const state = table(3);
|
||||||
|
state.seats[3].inHand = false;
|
||||||
|
HoldemState.ensureButtonOnPlayer(state);
|
||||||
|
assert.ok(state.seats[state.dealerSeatIndex].inHand);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moving the button by hand clears the dead positions", () => {
|
||||||
|
const state = table(1);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
leave(state, 2);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
assert.equal(state.deadButton, true);
|
||||||
|
|
||||||
|
// What the seat menu's "Give the button" does.
|
||||||
|
state.dealerSeatIndex = 0;
|
||||||
|
state.deadButton = false;
|
||||||
|
state.deadSmallBlind = false;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
const { sbIndex } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.ok(state.seats[sbIndex].inHand, "a real small blind again, where the dealer put it");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("naming a blind by hand overrules a dead one", () => {
|
||||||
|
const state = table(1);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
leave(state, 3);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
assert.equal(state.deadSmallBlind, true);
|
||||||
|
|
||||||
|
state.blindOverride = { sbIndex: 1, bbIndex: 0 };
|
||||||
|
const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(manual, true);
|
||||||
|
assert.equal(sbIndex, 1, "the dealer's word beats the rule");
|
||||||
|
assert.equal(bbIndex, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a table saved before any of this reads as nothing dead", () => {
|
||||||
|
const state = table(0);
|
||||||
|
const parsed = JSON.parse(JSON.stringify(state));
|
||||||
|
delete parsed.deadButton;
|
||||||
|
delete parsed.deadSmallBlind;
|
||||||
|
delete parsed.postedBlinds;
|
||||||
|
|
||||||
|
const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: key => (store.has(key) ? store.get(key) : null),
|
||||||
|
setItem: (key, value) => store.set(key, value),
|
||||||
|
removeItem: key => store.delete(key),
|
||||||
|
};
|
||||||
|
|
||||||
|
const loaded = HoldemState.load();
|
||||||
|
assert.equal(loaded.deadButton, false);
|
||||||
|
assert.equal(loaded.deadSmallBlind, false);
|
||||||
|
assert.equal(loaded.postedBlinds, null);
|
||||||
|
|
||||||
|
HoldemState.startNextHand(loaded);
|
||||||
|
assert.ok(loaded.seats[loaded.dealerSeatIndex].inHand, "and rotates the ordinary way");
|
||||||
|
delete globalThis.localStorage;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chips are not invented or lost by any of it", () => {
|
||||||
|
test("posting one blind instead of two moves chips and doesn't make them", () => {
|
||||||
|
// Measured across the deal, which is the step that touches stacks. A hand with
|
||||||
|
// a dead small blind has one blind in the middle instead of two — the danger is
|
||||||
|
// a null small blind being treated as a zero-cost posting somewhere, or the big
|
||||||
|
// blind quietly covering for it.
|
||||||
|
const state = table(1);
|
||||||
|
const chips = s => s.seats.reduce(
|
||||||
|
(sum, p) => sum + Betting.stackOf(s, p) + Betting.handTotal(p), 0);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
leave(state, 3); // the big blind goes, small blind dies
|
||||||
|
|
||||||
|
for (let hand = 0; hand < 4; hand++) {
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
const before = chips(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(chips(state), before, `the deal drifted the count on hand ${hand + 1}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a dead small blind takes exactly one blind out of one stack", () => {
|
||||||
|
const state = table(1);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
leave(state, 3);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
const stacks = () => state.seats.map(p => Betting.stackOf(state, p));
|
||||||
|
const before = stacks();
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const after = stacks();
|
||||||
|
|
||||||
|
const paid = before.map((v, i) => v - after[i]).filter(d => d !== 0);
|
||||||
|
assert.deepEqual(paid, [state.bigBlind], "one player, one big blind, nobody else touched");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// Dealing and betting are two decisions, and fusing them meant the table guessed.
|
||||||
|
//
|
||||||
|
// The moment between hands is when people sit down, stand up and argue about whose
|
||||||
|
// turn it is. With blinds posted the instant the previous hand ended, the blind
|
||||||
|
// badges recomputed off `inHand` while the committed chips stayed frozen — so seating
|
||||||
|
// somebody produced a big blind who had posted nothing.
|
||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||||
|
|
||||||
|
const postedBy = state => state.seats
|
||||||
|
.map((p, i) => (Betting.committed(p) > 0 ? i : null))
|
||||||
|
.filter(i => i !== null);
|
||||||
|
|
||||||
|
describe("the table waits to be dealt", () => {
|
||||||
|
test("finishing a hand leaves the next one un-dealt", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(state.bettingOpen, true);
|
||||||
|
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.bettingOpen, false, "the next hand hasn't been dealt");
|
||||||
|
assert.equal(state.actingSeatIndex, null, "nobody is on the clock");
|
||||||
|
assert.deepEqual(postedBy(state), [], "and no blind has been committed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no money moves until the dealer deals", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
const start = chipsOnTable(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
assert.equal(chipsOnTable(state), start);
|
||||||
|
assert.equal(HoldemState.totalPot(state), 0);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(state.bettingOpen, true);
|
||||||
|
assert.equal(postedBy(state).length, 2, "now the blinds are in");
|
||||||
|
assert.equal(chipsOnTable(state), start, "and nothing was created doing it");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setting the table costs nothing", () => {
|
||||||
|
test("seating a player before the deal changes who posts", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
const before = Betting.blindPreview(state);
|
||||||
|
state.seats[3].inHand = true;
|
||||||
|
state.seats[3].stack = 100;
|
||||||
|
const after = Betting.blindPreview(state);
|
||||||
|
|
||||||
|
assert.notDeepEqual(
|
||||||
|
[before.sbIndex, before.bbIndex],
|
||||||
|
[after.sbIndex, after.bbIndex],
|
||||||
|
"the preview follows the new table"
|
||||||
|
);
|
||||||
|
assert.deepEqual(postedBy(state), [], "and still nothing is committed");
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.deepEqual(postedBy(state).sort(), [after.sbIndex, after.bbIndex].sort(),
|
||||||
|
"the money lands where the preview said it would");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removing a player before the deal can't strand their blind", () => {
|
||||||
|
// The old failure: take out the big blind after the hand had begun and their
|
||||||
|
// chips stayed in the pot while the badge moved to somebody who'd posted
|
||||||
|
// nothing. Before the deal there is nothing to strand.
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
const doomed = Betting.blindPreview(state).bbIndex;
|
||||||
|
state.seats[doomed].inHand = false;
|
||||||
|
|
||||||
|
assert.deepEqual(postedBy(state), [], "nobody has posted, so nothing is stranded");
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const posted = postedBy(state);
|
||||||
|
assert.ok(!posted.includes(doomed), "the player who left posts nothing");
|
||||||
|
posted.forEach(i => assert.ok(state.seats[i].inHand, "only players in the hand post"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moving the button before the deal just moves it", () => {
|
||||||
|
const state = makeTable({ seatCount: 8, seated: [0, 2, 4, 6], dealerSeatIndex: 0 });
|
||||||
|
const start = chipsOnTable(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
[2, 4, 6, 0].forEach(seat => {
|
||||||
|
state.dealerSeatIndex = seat;
|
||||||
|
assert.deepEqual(postedBy(state), [], `passing the button to seat ${seat + 1} posted something`);
|
||||||
|
assert.equal(chipsOnTable(state), start);
|
||||||
|
});
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(chipsOnTable(state), start, "dealing moves chips between stacks, not into thin air");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("what the preview promises is what gets posted", () => {
|
||||||
|
const state = makeTable({ seatCount: 9, seated: [1, 3, 6, 8], dealerSeatIndex: 1 });
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
const preview = Betting.blindPreview(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(Betting.committed(state.seats[preview.sbIndex]), preview.smallBlind);
|
||||||
|
assert.equal(Betting.committed(state.seats[preview.bbIndex]), preview.bigBlind);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("there's nothing to preview without two players", () => {
|
||||||
|
const alone = makeTable({ seatCount: 6, seated: [2] });
|
||||||
|
HoldemState.startNextHand(alone);
|
||||||
|
assert.equal(Betting.blindPreview(alone), null);
|
||||||
|
|
||||||
|
const empty = makeTable({ seatCount: 6, seated: [] });
|
||||||
|
HoldemState.startNextHand(empty);
|
||||||
|
assert.equal(Betting.blindPreview(empty), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a hand-picked blind shows up in the preview", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||||
|
|
||||||
|
const preview = Betting.blindPreview(state);
|
||||||
|
assert.equal(preview.manual, true);
|
||||||
|
assert.equal(preview.sbIndex, 3);
|
||||||
|
assert.equal(preview.bbIndex, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a table saved mid-hand comes back mid-hand", () => {
|
||||||
|
const loadWith = saved => {
|
||||||
|
const original = globalThis.localStorage;
|
||||||
|
globalThis.localStorage = { getItem: () => JSON.stringify(saved), setItem: () => {}, removeItem: () => {} };
|
||||||
|
const state = HoldemState.load();
|
||||||
|
globalThis.localStorage = original;
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("chips already committed mean the betting was open", () => {
|
||||||
|
const state = loadWith({
|
||||||
|
seats: [{ id: "a", name: "Ann", inHand: true, committed: 2 }, { id: "b", name: "Ben", inHand: true, committed: 0 }],
|
||||||
|
handNumber: 3,
|
||||||
|
});
|
||||||
|
assert.equal(state.bettingOpen, true, "otherwise it would offer to deal over a live hand");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("somebody on the clock means the betting was open", () => {
|
||||||
|
const state = loadWith({
|
||||||
|
seats: [{ id: "a", inHand: true }, { id: "b", inHand: true }],
|
||||||
|
actingSeatIndex: 1, handNumber: 2,
|
||||||
|
});
|
||||||
|
assert.equal(state.bettingOpen, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a quiet table comes back waiting to be dealt", () => {
|
||||||
|
const state = loadWith({
|
||||||
|
seats: [{ id: "a", inHand: true }, { id: "b", inHand: true }],
|
||||||
|
actingSeatIndex: null, handNumber: 5,
|
||||||
|
});
|
||||||
|
assert.equal(state.bettingOpen, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the preview tells the truth about the button", () => {
|
||||||
|
test("a button parked on an empty chair is answered for where it will be", () => {
|
||||||
|
// The preview used to read the button where it sat, while dealing relocated it
|
||||||
|
// first — so it promised one pair of blinds and produced another.
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
state.bettingOpen = false;
|
||||||
|
assert.equal(state.seats[0].inHand, false, "the button is on an empty chair");
|
||||||
|
|
||||||
|
const preview = Betting.blindPreview(state);
|
||||||
|
assert.equal(preview.buttonMoves, true, "and the preview knows it will move");
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(Betting.committed(state.seats[preview.sbIndex]), preview.smallBlind);
|
||||||
|
assert.equal(Betting.committed(state.seats[preview.bbIndex]), preview.bigBlind);
|
||||||
|
assert.equal(state.dealerSeatIndex, preview.buttonIndex, "and it moved where it said");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asking where the button will be doesn't move it", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
state.bettingOpen = false;
|
||||||
|
|
||||||
|
Betting.blindPreview(state);
|
||||||
|
Betting.blindPreview(state);
|
||||||
|
|
||||||
|
assert.equal(state.dealerSeatIndex, 0, "the preview is a question, not an action");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a button already on a player is left alone", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
state.bettingOpen = false;
|
||||||
|
const preview = Betting.blindPreview(state);
|
||||||
|
assert.equal(preview.buttonMoves, false);
|
||||||
|
assert.equal(preview.buttonIndex, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buttonSeatForDeal copes with a table nobody is sitting at", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [] });
|
||||||
|
assert.equal(HoldemState.buttonSeatForDeal(state), state.dealerSeatIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Chips the dealer took off the table on purpose.
|
||||||
|
//
|
||||||
|
// Starting the next hand with money still in the middle destroys it — the tool asks
|
||||||
|
// first and says so afterwards. What it didn't do was remember, so the bank check saw
|
||||||
|
// only that chips were gone and reported them as missing.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { HoldemState, Money, makeTable } = require("./load");
|
||||||
|
|
||||||
|
const table = () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
|
||||||
|
state.seats.slice(0, 4).forEach(player => { player.buyIns = 200; });
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("a table with nothing thrown away balances as before", () => {
|
||||||
|
const bank = HoldemState.bankReconciliation(table());
|
||||||
|
|
||||||
|
assert.equal(bank.balanced, true);
|
||||||
|
assert.equal(bank.discarded, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chips that went missing are still reported", () => {
|
||||||
|
// The check exists for this: cash in the box with no chips to match it.
|
||||||
|
const state = table();
|
||||||
|
state.seats[0].stack = 150;
|
||||||
|
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.balanced, false);
|
||||||
|
assert.equal(bank.difference, -50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an abandoned pot is accounted for rather than counted as missing", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[1].stack = 199;
|
||||||
|
state.seats[2].stack = 198;
|
||||||
|
state.seats[3].stack = 198;
|
||||||
|
state.discarded = 5;
|
||||||
|
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.chipsInPlay, 795);
|
||||||
|
assert.equal(bank.discarded, 5);
|
||||||
|
assert.equal(bank.balanced, true, "the box knows exactly where that $5 went");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throwing a pot away doesn't hide a real shortfall underneath it", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[1].stack = 199;
|
||||||
|
state.seats[2].stack = 198;
|
||||||
|
state.seats[3].stack = 148; // $50 genuinely unaccounted for as well
|
||||||
|
state.discarded = 5;
|
||||||
|
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.balanced, false);
|
||||||
|
assert.equal(bank.difference, -50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a nonsense discarded value can't move the answer", () => {
|
||||||
|
const state = table();
|
||||||
|
state.discarded = -100;
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
|
||||||
|
|
||||||
|
state.discarded = "not a number";
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a table saved before this existed reads as nothing thrown away", () => {
|
||||||
|
const saved = JSON.stringify(table());
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
delete parsed.discarded;
|
||||||
|
|
||||||
|
const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: key => (store.has(key) ? store.get(key) : null),
|
||||||
|
setItem: (key, value) => store.set(key, value),
|
||||||
|
removeItem: key => store.delete(key),
|
||||||
|
};
|
||||||
|
|
||||||
|
const loaded = HoldemState.load();
|
||||||
|
assert.equal(loaded.discarded, 0);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(loaded).balanced, true);
|
||||||
|
delete globalThis.localStorage;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the amounts still add up the way the panel shows them", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[1].stack = 199;
|
||||||
|
state.seats[2].stack = 198;
|
||||||
|
state.seats[3].stack = 198;
|
||||||
|
state.discarded = 5;
|
||||||
|
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(Money.round(bank.chipsInPlay + bank.discarded), bank.cashIn);
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// The things the dealer wrote down, in the file they hand over.
|
||||||
|
//
|
||||||
|
// The export already carried the hands, the standings and the settle-up. It didn't
|
||||||
|
// carry either of the two places a dealer types context — the per-seat fields and the
|
||||||
|
// table's own notes — so the one place an IOU was written down was the one place it
|
||||||
|
// couldn't leave.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { HandHistoryExport, HoldemState, makeTable } = require("./load");
|
||||||
|
|
||||||
|
const tableWithNotes = () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
|
||||||
|
["Ann", "Ben", "Cal"].forEach((name, i) => {
|
||||||
|
state.seats[i].name = name;
|
||||||
|
state.seats[i].buyIns = 200;
|
||||||
|
});
|
||||||
|
state.seats[0].customFields = [
|
||||||
|
{ label: "Seat rent", value: "paid to 2am" },
|
||||||
|
{ label: "Venmo", value: "@ann" },
|
||||||
|
];
|
||||||
|
state.seats[1].customFields = [{ label: "IOU", value: "$20 to Ann" }];
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("a seat's own fields ride along with its standings row", () => {
|
||||||
|
const state = tableWithNotes();
|
||||||
|
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
|
||||||
|
|
||||||
|
assert.match(csv, /Seat rent: paid to 2am; Venmo: @ann/);
|
||||||
|
assert.match(csv, /IOU: \$20 to Ann/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the money columns keep their order, with notes on the end", () => {
|
||||||
|
const state = tableWithNotes();
|
||||||
|
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
|
||||||
|
|
||||||
|
assert.match(csv, /Player,Paid In,Won,Net,Bought In,Chips Now,Up\/Down,Notes/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a seat with nothing written against it gets an empty cell, not the word undefined", () => {
|
||||||
|
const state = tableWithNotes();
|
||||||
|
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
|
||||||
|
const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,"));
|
||||||
|
|
||||||
|
assert.ok(calRow, "Cal should have a row");
|
||||||
|
assert.ok(calRow.endsWith(","), `expected an empty last cell, got ${calRow}`);
|
||||||
|
assert.ok(!/undefined|null/.test(calRow));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a blank label or value doesn't produce a stray colon", () => {
|
||||||
|
const state = tableWithNotes();
|
||||||
|
state.seats[2].customFields = [{ label: "Note", value: "" }, { label: "", value: "" }];
|
||||||
|
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
|
||||||
|
const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,"));
|
||||||
|
|
||||||
|
assert.match(calRow, /Note/);
|
||||||
|
assert.ok(!/;\s*;/.test(calRow), "an entirely blank field shouldn't take a slot");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the table's notes are in the file", () => {
|
||||||
|
const state = tableWithNotes();
|
||||||
|
state.notes = "High-hand promo until midnight.";
|
||||||
|
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: state.notes });
|
||||||
|
|
||||||
|
assert.match(csv, /Table Notes/);
|
||||||
|
assert.match(csv, /High-hand promo until midnight\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a multi-line note survives as one cell", () => {
|
||||||
|
// csvField quotes anything with a line break in it, so the note doesn't turn into
|
||||||
|
// rows a spreadsheet reads as more players.
|
||||||
|
const state = tableWithNotes();
|
||||||
|
const notes = "Line one\nLine two, with a comma\nLine \"three\"";
|
||||||
|
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes });
|
||||||
|
|
||||||
|
assert.match(csv, /"Line one\nLine two, with a comma\nLine ""three"""/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no notes means no section, not an empty heading", () => {
|
||||||
|
const state = tableWithNotes();
|
||||||
|
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: " " });
|
||||||
|
|
||||||
|
assert.ok(!/Table Notes/.test(csv));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the export still works with no standings at all", () => {
|
||||||
|
assert.doesNotThrow(() => HandHistoryExport.toCsv([], [], { notes: "just a note" }));
|
||||||
|
const csv = HandHistoryExport.toCsv([], [], { notes: "just a note" });
|
||||||
|
assert.match(csv, /Table Notes/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asking for the standings doesn't change the seats", () => {
|
||||||
|
const state = tableWithNotes();
|
||||||
|
const before = JSON.stringify(state.seats);
|
||||||
|
HoldemState.sessionStandings(state);
|
||||||
|
assert.equal(JSON.stringify(state.seats), before);
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// How tall the felt is allowed to be.
|
||||||
|
//
|
||||||
|
// The bar is fixed to the bottom of the screen and grows with the hand, so the table
|
||||||
|
// has to be told what is left. Everything below is the arithmetic that decides it —
|
||||||
|
// the part that can be wrong without anyone noticing until a seat is behind the Fold
|
||||||
|
// button.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { ActionBar } = require("./load");
|
||||||
|
|
||||||
|
// window height, bar height, and how far down the page the felt starts.
|
||||||
|
const room = (windowHeight, barHeight, top) => ActionBar.feltRoomFor(windowHeight, barHeight, top);
|
||||||
|
|
||||||
|
describe("the room left for the felt", () => {
|
||||||
|
test("is what the window has after the bar, the header and the bottom seat", () => {
|
||||||
|
const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
|
||||||
|
assert.equal(room(900, 127, 146), 900 - 127 - 146 - reserved);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shrinks when the bar grows mid-hand", () => {
|
||||||
|
// Side pots make the bar taller exactly when the dealer most needs to see who
|
||||||
|
// is still in.
|
||||||
|
const quiet = room(900, 117, 146);
|
||||||
|
const sidePots = room(900, 172, 146);
|
||||||
|
assert.equal(quiet - sidePots, 55);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves the bottom seat's overhang out of it", () => {
|
||||||
|
// The seat pucks hang below the felt's edge; the felt has to stop short by that
|
||||||
|
// much or the ring ends up in the bar even though the felt doesn't.
|
||||||
|
assert.ok(ActionBar.SEAT_OVERHANG_PX > 30, "half a puck");
|
||||||
|
assert.ok(room(900, 127, 146) < 900 - 127 - 146);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a window too short to give what was asked", () => {
|
||||||
|
// A phone on its side: 390px tall, 127px of that is the bar and 119px is
|
||||||
|
// everything above the felt. What's left is under 100.
|
||||||
|
const landscape = () => room(390, 127, 119);
|
||||||
|
|
||||||
|
test("gets the measurement, not a comfortable number", () => {
|
||||||
|
// The floor used to be 220px, which this window hasn't got. Holding out for it
|
||||||
|
// put the bottom row of seats 26px inside the action bar — the exact fault the
|
||||||
|
// measurement exists to prevent.
|
||||||
|
assert.ok(landscape() < 150, `${landscape()}px`);
|
||||||
|
assert.ok(landscape() + 119 + 127 <= 390, "the felt still ends above the bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still draws a table rather than a line", () => {
|
||||||
|
assert.equal(ActionBar.feltRoomFor(200, 160, 100), ActionBar.FELT_FLOOR_PX);
|
||||||
|
assert.equal(ActionBar.feltRoomFor(0, 0, 0), ActionBar.FELT_FLOOR_PX);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never returns something a browser can't lay out", () => {
|
||||||
|
[[390, 300, 200], [100, 90, 90], [-10, 0, 0]].forEach(([h, bar, top]) => {
|
||||||
|
const answer = ActionBar.feltRoomFor(h, bar, top);
|
||||||
|
assert.ok(Number.isFinite(answer) && answer > 0, `${h}/${bar}/${top} gave ${answer}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a window with room to spare", () => {
|
||||||
|
test("hands over everything that's left, floor or no floor", () => {
|
||||||
|
const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
|
||||||
|
assert.equal(room(1000, 127, 146), 1000 - 127 - 146 - reserved);
|
||||||
|
assert.equal(room(1366, 120, 206), 1366 - 120 - 206 - reserved);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gives a whole number of pixels", () => {
|
||||||
|
assert.equal(room(844.4, 127.6, 223.2) % 1, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Finding one hand in a night of them.
|
||||||
|
//
|
||||||
|
// What the log is actually opened for is a single hand — the one somebody at the rail
|
||||||
|
// is arguing about. A night runs to sixty or more, five at a time in the panel, and
|
||||||
|
// there was no way to ask for one: the dealer scrolled and read every row.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { Render } = require("./load");
|
||||||
|
|
||||||
|
const hand = (handNumber, pot, payouts, ledger = []) => ({
|
||||||
|
handNumber, pot, timestamp: Date.now(), payouts, ledger,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A night as the tool records it: labels carry the cards the wizard was shown.
|
||||||
|
const NIGHT = [
|
||||||
|
hand(1, 40, [{ label: "Ann (A♠ K♠)", amount: 40 }], [{ name: "Ann", in: 20 }, { name: "Ben", in: 20 }]),
|
||||||
|
hand(2, 190, [{ label: "Fay", amount: 190 }], [{ name: "Fay", in: 95 }, { name: "Gus", in: 95 }]),
|
||||||
|
hand(12, 19, [{ label: "Ben", amount: 19 }], [{ name: "Ben", in: 10 }, { name: "Ann", in: 9 }]),
|
||||||
|
hand(120, 76, [{ label: "Cal", amount: 38 }, { label: "Dee", amount: 38 }], [{ name: "Cal" }, { name: "Dee" }]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const found = query => NIGHT.filter(h => Render.handMatchesFilter(h, query)).map(h => h.handNumber);
|
||||||
|
|
||||||
|
describe("no filter", () => {
|
||||||
|
test("shows the whole night", () => {
|
||||||
|
assert.deepEqual(found(""), [1, 2, 12, 120]);
|
||||||
|
assert.deepEqual(found(" "), [1, 2, 12, 120]);
|
||||||
|
assert.deepEqual(found(null), [1, 2, 12, 120]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("by name", () => {
|
||||||
|
test("finds the hand somebody won", () => {
|
||||||
|
assert.deepEqual(found("Fay"), [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("case doesn't matter — nobody types a capital at 2am", () => {
|
||||||
|
assert.deepEqual(found("fay"), [2]);
|
||||||
|
assert.deepEqual(found("FAY"), [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a name inside a label with cards on it still matches", () => {
|
||||||
|
// The wizard writes the label as "Ann (A♠ K♠)", so the name is a substring
|
||||||
|
// rather than the whole cell.
|
||||||
|
assert.deepEqual(found("ann"), [1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches what the row shows, not everyone who was in the hand", () => {
|
||||||
|
// Gus is in hand 2's ledger — he paid for it — but the row names the winner,
|
||||||
|
// and searching text the panel doesn't display is a filter that looks broken
|
||||||
|
// even when it's right. Measured on a seven-handed night where nobody folded,
|
||||||
|
// matching the ledger returned 60 of 60 for every name at the table.
|
||||||
|
assert.deepEqual(found("gus"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a split pot is found by either winner", () => {
|
||||||
|
assert.deepEqual(found("cal"), [120]);
|
||||||
|
assert.deepEqual(found("dee"), [120]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a name nobody has finds nothing rather than everything", () => {
|
||||||
|
assert.deepEqual(found("zed"), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("by amount", () => {
|
||||||
|
test("finds a pot", () => {
|
||||||
|
assert.deepEqual(found("190"), [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a dollar sign and commas are ignored", () => {
|
||||||
|
assert.deepEqual(found("$190"), [2]);
|
||||||
|
assert.deepEqual(found("$1,90"), [2], "typed badly, still found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finds a payout that isn't the whole pot", () => {
|
||||||
|
assert.deepEqual(found("38"), [120], "half of a chopped $76");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a partial amount narrows rather than missing", () => {
|
||||||
|
// Half remembering "something around forty" should still get there.
|
||||||
|
assert.deepEqual(found("4"), [1], "$40");
|
||||||
|
assert.deepEqual(found("19"), [2, 12], "the $190 pot and the $19 one");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a bare number is an exact hand number, not a prefix of one", () => {
|
||||||
|
// Amounts match on their leading digits, because half a remembered figure is
|
||||||
|
// still a lead. Hand numbers don't: typing 12 to mean the hand is a dealer who
|
||||||
|
// knows which hand they want, and dragging 120 and 128 along with it is noise.
|
||||||
|
// "#12" is there for when they do want the prefix.
|
||||||
|
assert.ok(!found("12").includes(120));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("by hand number", () => {
|
||||||
|
test("a bare number matches the hand number as well as amounts", () => {
|
||||||
|
assert.ok(found("12").includes(12));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a hash pins it to the hand number and nothing else", () => {
|
||||||
|
assert.deepEqual(found("#12"), [12, 120], "12 and 120 both start with 12");
|
||||||
|
assert.deepEqual(found("#2"), [2], "and not the $190 pot of hand 2's neighbours");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a hash with a number nobody played finds nothing", () => {
|
||||||
|
assert.deepEqual(found("#7"), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("odd input can't break it", () => {
|
||||||
|
test("punctuation on its own doesn't match everything", () => {
|
||||||
|
assert.deepEqual(found("$"), [], "no digits and no letters left to match on");
|
||||||
|
assert.deepEqual(found("#"), [1, 2, 12, 120], "an empty hand number is a prefix of them all");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an entry with pieces missing doesn't throw", () => {
|
||||||
|
const broken = { handNumber: 5, pot: 10 };
|
||||||
|
assert.doesNotThrow(() => Render.handMatchesFilter(broken, "ann"));
|
||||||
|
assert.equal(Render.handMatchesFilter(broken, "ann"), false);
|
||||||
|
assert.equal(Render.handMatchesFilter(broken, "10"), true, "the pot is still findable");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// The hand-history panel is only rebuilt when its signature changes, which takes a
|
||||||
|
// full re-render from ~30ms to ~3ms. The whole saving depends on that signature
|
||||||
|
// noticing every way the log can actually change — a stale panel would have the
|
||||||
|
// dealer reading last hand's payouts while settling this one.
|
||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Render, HoldemState, makeTable } = require("./load");
|
||||||
|
|
||||||
|
// historySignature() reads Render.state, so point it at a table for each case.
|
||||||
|
const signatureOf = state => {
|
||||||
|
Render.state = state;
|
||||||
|
return Render.historySignature();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableWithHands = (count) => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
state.seats[0].amountOverride = 2;
|
||||||
|
state.seats[1].amountOverride = 2;
|
||||||
|
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("the history panel knows when to redraw", () => {
|
||||||
|
test("nothing changes while a hand is being played", () => {
|
||||||
|
const state = tableWithHands(3);
|
||||||
|
const before = signatureOf(state);
|
||||||
|
|
||||||
|
// A whole street of betting touches no part of the log.
|
||||||
|
state.seats[0].committed = 5;
|
||||||
|
state.seats[1].folded = true;
|
||||||
|
state.actingSeatIndex = 2;
|
||||||
|
state.street = "flop";
|
||||||
|
|
||||||
|
assert.equal(signatureOf(state), before, "betting must not force a rebuild");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settling a hand changes it", () => {
|
||||||
|
const state = tableWithHands(3);
|
||||||
|
const before = signatureOf(state);
|
||||||
|
HoldemState.recordHand(state, [{ id: state.seats[1].id, label: "Ben", amount: 12 }]);
|
||||||
|
assert.notEqual(signatureOf(state), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing the history changes it", () => {
|
||||||
|
const state = tableWithHands(5);
|
||||||
|
const before = signatureOf(state);
|
||||||
|
HoldemState.clearHistory(state);
|
||||||
|
assert.notEqual(signatureOf(state), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("undoing back to a shorter log changes it", () => {
|
||||||
|
const state = tableWithHands(2);
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(state));
|
||||||
|
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 30 }]);
|
||||||
|
const afterRecord = signatureOf(state);
|
||||||
|
|
||||||
|
// The undo path swaps the state's contents in place.
|
||||||
|
Object.keys(state).forEach(k => delete state[k]);
|
||||||
|
Object.assign(state, snapshot);
|
||||||
|
|
||||||
|
assert.notEqual(signatureOf(state), afterRecord, "an undone hand must disappear from the panel");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two hands that differ only in payout are told apart", () => {
|
||||||
|
const a = tableWithHands(1);
|
||||||
|
const b = tableWithHands(1);
|
||||||
|
// Same count and hand number; different pot.
|
||||||
|
b.handHistory[0].pot = a.handHistory[0].pot + 25;
|
||||||
|
assert.notEqual(signatureOf(a), signatureOf(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the hand counter moving on changes it", () => {
|
||||||
|
// The counter badge is drawn by the same function, so a new hand number has
|
||||||
|
// to invalidate even when the log itself is untouched.
|
||||||
|
const state = tableWithHands(2);
|
||||||
|
const before = signatureOf(state);
|
||||||
|
state.handNumber += 1;
|
||||||
|
assert.notEqual(signatureOf(state), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty log has a stable signature of its own", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1] });
|
||||||
|
const first = signatureOf(state);
|
||||||
|
assert.equal(signatureOf(state), first);
|
||||||
|
|
||||||
|
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]);
|
||||||
|
assert.notEqual(signatureOf(state), first, "the first hand ever must draw the panel");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the hand-time cache", () => {
|
||||||
|
test("the same timestamp is only formatted once, and formats the same", () => {
|
||||||
|
Render.handTimeCache.clear();
|
||||||
|
const stamp = Date.UTC(2026, 8, 1, 15, 4);
|
||||||
|
const first = Render.formatHandTime(stamp);
|
||||||
|
assert.equal(Render.handTimeCache.size, 1);
|
||||||
|
|
||||||
|
const second = Render.formatHandTime(stamp);
|
||||||
|
assert.equal(second, first);
|
||||||
|
assert.equal(Render.handTimeCache.size, 1, "not re-formatted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("different timestamps still format differently", () => {
|
||||||
|
Render.handTimeCache.clear();
|
||||||
|
const a = Render.formatHandTime(Date.UTC(2026, 8, 1, 15, 4));
|
||||||
|
const b = Render.formatHandTime(Date.UTC(2026, 8, 1, 18, 47));
|
||||||
|
assert.notEqual(a, b);
|
||||||
|
assert.equal(Render.handTimeCache.size, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a nonsense timestamp doesn't throw", () => {
|
||||||
|
Render.handTimeCache.clear();
|
||||||
|
assert.doesNotThrow(() => Render.formatHandTime(undefined));
|
||||||
|
assert.doesNotThrow(() => Render.formatHandTime("not a date"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// Randomised whole-hand play against the invariants a dealer would notice being broken.
|
||||||
|
//
|
||||||
|
// These ran once, by hand, in a browser console while the betting engine was being
|
||||||
|
// fixed, and then went away with the tab. The bugs they caught (blinds posted to empty
|
||||||
|
// chairs, the button skipping players, heads-up acting out of order) are all cases you
|
||||||
|
// only hit on a particular table shape, so a fixed set of examples misses them. Keeping
|
||||||
|
// the generator means the next change gets checked against the same thousand tables.
|
||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Betting, PotCalculator, HoldemState, Money, makeTable, chipsOnTable, contributions } = require("./load");
|
||||||
|
|
||||||
|
// Deterministic PRNG: a failure has to be reproducible from the seed printed with it,
|
||||||
|
// otherwise a randomised suite is just a flaky one.
|
||||||
|
function rng(seed) {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (s * 1664525 + 1013904223) >>> 0;
|
||||||
|
return s / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plays one hand to completion with random but legal-ish dealer input.
|
||||||
|
function playHand(random) {
|
||||||
|
const seatCount = 2 + Math.floor(random() * 9);
|
||||||
|
const seatIndexes = [...Array(seatCount).keys()];
|
||||||
|
const seated = seatIndexes.filter(() => random() < 0.7);
|
||||||
|
if (seated.length < 2) seated.push(...seatIndexes.filter(i => !seated.includes(i)).slice(0, 2 - seated.length));
|
||||||
|
|
||||||
|
const bigBlind = [2, 5, 0.5][Math.floor(random() * 3)];
|
||||||
|
const state = makeTable({
|
||||||
|
seatCount,
|
||||||
|
seated,
|
||||||
|
stack: 0,
|
||||||
|
smallBlind: bigBlind / 2,
|
||||||
|
bigBlind,
|
||||||
|
dealerSeatIndex: Math.floor(random() * seatCount),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Varied stack depths, so short stacks and side pots actually happen.
|
||||||
|
seated.forEach(i => {
|
||||||
|
state.seats[i].stack = Money.round(bigBlind * (1 + Math.floor(random() * 60)));
|
||||||
|
});
|
||||||
|
|
||||||
|
const startingChips = chipsOnTable(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
let guard = 0;
|
||||||
|
while (!Betting.isHandOver(state) && state.street !== "done" && guard++ < 400) {
|
||||||
|
if (state.actingSeatIndex === null) {
|
||||||
|
if (state.street === "river" || !Betting.canStillBet(state)) break;
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = state.actingSeatIndex;
|
||||||
|
const player = state.seats[index];
|
||||||
|
const toCall = Betting.toCall(state, player);
|
||||||
|
const roll = random();
|
||||||
|
|
||||||
|
if (roll < 0.15) Betting.fold(state, index);
|
||||||
|
else if (roll < 0.25) Betting.allIn(state, index);
|
||||||
|
else if (toCall > 0) Betting.call(state, index);
|
||||||
|
else if (roll < 0.75) Betting.check(state, index);
|
||||||
|
else Betting.raiseTo(state, index, Betting.minRaiseTo(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
return { state, startingChips, seated };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("randomised hands hold the dealer's invariants", () => {
|
||||||
|
const HANDS = 1000;
|
||||||
|
|
||||||
|
test(`${HANDS} hands never create or destroy a chip`, () => {
|
||||||
|
const random = rng(20260901);
|
||||||
|
for (let hand = 0; hand < HANDS; hand++) {
|
||||||
|
const { state, startingChips } = playHand(random);
|
||||||
|
assert.equal(
|
||||||
|
chipsOnTable(state),
|
||||||
|
startingChips,
|
||||||
|
`hand ${hand}: chips on the table changed during play`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${HANDS} hands: pots plus uncalled returns always equal the money in`, () => {
|
||||||
|
const random = rng(778899);
|
||||||
|
let sidePotHands = 0;
|
||||||
|
let returnHands = 0;
|
||||||
|
|
||||||
|
for (let hand = 0; hand < HANDS; hand++) {
|
||||||
|
const { state } = playHand(random);
|
||||||
|
const players = contributions(state);
|
||||||
|
const inThePot = players.reduce((sum, p) => sum + p.contribution, 0);
|
||||||
|
if (inThePot <= 0) continue;
|
||||||
|
|
||||||
|
// Fold flags can leave nobody live, which the calculator deliberately
|
||||||
|
// refuses to guess at — that's a dealer correction, not an invariant.
|
||||||
|
if (players.every(p => p.folded)) continue;
|
||||||
|
|
||||||
|
const layered = PotCalculator.layers(players).reduce((s, l) => s + l.amount, 0);
|
||||||
|
const returned = PotCalculator.uncalledReturns(players).reduce((s, r) => s + r.amount, 0);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
Money.toCents(layered + returned),
|
||||||
|
Money.toCents(inThePot),
|
||||||
|
`hand ${hand}: pot breakdown doesn't add up to what went in`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (PotCalculator.layers(players).length > 1) sidePotHands++;
|
||||||
|
if (returned > 0) returnHands++;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(sidePotHands > 20, `expected side pots to occur; saw ${sidePotHands}`);
|
||||||
|
assert.ok(returnHands > 20, `expected uncalled bets to occur; saw ${returnHands}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${HANDS} hands: what's left after returns is money somebody actually matched`, () => {
|
||||||
|
// The rule the pot breakdown rests on: you are only called up to what somebody
|
||||||
|
// else was willing to put in. Once the uncalled chips are handed back, no
|
||||||
|
// player should still be sitting above every other player at the table.
|
||||||
|
//
|
||||||
|
// A single-eligible pot is *not* the thing to look for here — that's legitimate
|
||||||
|
// when a player called and then folded, leaving dead money one contender
|
||||||
|
// collects. The bug this guards against is subtler: chips nobody ever matched
|
||||||
|
// being dressed up as a pot to play for.
|
||||||
|
const random = rng(13579);
|
||||||
|
for (let hand = 0; hand < HANDS; hand++) {
|
||||||
|
const { state } = playHand(random);
|
||||||
|
const players = contributions(state);
|
||||||
|
if (players.every(p => p.folded)) continue;
|
||||||
|
|
||||||
|
const returns = PotCalculator.uncalledReturns(players);
|
||||||
|
assert.ok(returns.length <= 1, `hand ${hand}: only the biggest bet can go uncalled`);
|
||||||
|
|
||||||
|
returns.forEach(r => {
|
||||||
|
const player = players.find(p => p.id === r.id);
|
||||||
|
assert.ok(r.amount <= player.contribution + 0.001, `hand ${hand}: returned more than was put in`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const returned = new Map(returns.map(r => [r.id, r.amount]));
|
||||||
|
const capped = players.map(p => Money.toCents(p.contribution - (returned.get(p.id) || 0)));
|
||||||
|
|
||||||
|
capped.forEach((cents, index) => {
|
||||||
|
const others = capped.filter((_, i) => i !== index);
|
||||||
|
const mostAnyoneElsePaid = others.length ? Math.max(...others) : 0;
|
||||||
|
assert.ok(
|
||||||
|
cents <= mostAnyoneElsePaid,
|
||||||
|
`hand ${hand}: a player is still ${Money.fromCents(cents - mostAnyoneElsePaid)} ` +
|
||||||
|
`above everyone else after returns — that money was never matched`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${HANDS} hands: paying the pot out settles it exactly`, () => {
|
||||||
|
const random = rng(24680);
|
||||||
|
for (let hand = 0; hand < HANDS; hand++) {
|
||||||
|
const { state } = playHand(random);
|
||||||
|
const players = contributions(state);
|
||||||
|
if (players.every(p => p.folded)) continue;
|
||||||
|
|
||||||
|
// Take the uncalled money off first, the way the dealer would.
|
||||||
|
const returned = new Map(PotCalculator.uncalledReturns(players).map(r => [r.id, r.amount]));
|
||||||
|
const contested = players.map(p => ({
|
||||||
|
...p,
|
||||||
|
contribution: Money.round(p.contribution - (returned.get(p.id) || 0)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let entries = PotCalculator.buildEntries(contested);
|
||||||
|
const potBefore = PotCalculator.totalPot(entries);
|
||||||
|
let paidOut = 0;
|
||||||
|
let rounds = 0;
|
||||||
|
|
||||||
|
while (PotCalculator.getContenders(entries).length > 0 && rounds++ < 30) {
|
||||||
|
const contenders = PotCalculator.getContenders(entries);
|
||||||
|
const winner = contenders[Math.floor(random() * contenders.length)];
|
||||||
|
const result = PotCalculator.resolveRound(entries, [winner.id]);
|
||||||
|
paidOut += result.perWinner.reduce((s, w) => s + w.amount, 0);
|
||||||
|
entries = result.updatedEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
Money.toCents(paidOut),
|
||||||
|
Money.toCents(potBefore),
|
||||||
|
`hand ${hand}: paying out the pot left ${potBefore - paidOut} unaccounted for`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${HANDS} hands: the button and blinds land on real players`, () => {
|
||||||
|
const random = rng(112233);
|
||||||
|
for (let hand = 0; hand < HANDS; hand++) {
|
||||||
|
const seatCount = 2 + Math.floor(random() * 9);
|
||||||
|
const seated = [...Array(seatCount).keys()].filter(() => random() < 0.6);
|
||||||
|
if (seated.length < 2) continue;
|
||||||
|
|
||||||
|
const state = makeTable({ seatCount, seated, dealerSeatIndex: Math.floor(random() * seatCount) });
|
||||||
|
HoldemState.advanceButton(state);
|
||||||
|
assert.ok(state.seats[state.dealerSeatIndex].inHand, `hand ${hand}: button on an empty chair`);
|
||||||
|
|
||||||
|
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.ok(state.seats[sbIndex].inHand, `hand ${hand}: small blind on an empty chair`);
|
||||||
|
assert.ok(state.seats[bbIndex].inHand, `hand ${hand}: big blind on an empty chair`);
|
||||||
|
assert.notEqual(sbIndex, bbIndex, `hand ${hand}: both blinds on one player`);
|
||||||
|
|
||||||
|
if (seated.length === 2) {
|
||||||
|
assert.ok(headsUp, `hand ${hand}: two players should be heads-up`);
|
||||||
|
assert.equal(sbIndex, state.dealerSeatIndex, `hand ${hand}: heads-up button posts the small blind`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Loads the dealer's browser modules into Node so they can be tested directly.
|
||||||
|
//
|
||||||
|
// The modules are plain script-tag files: each one declares a `const` namespace and
|
||||||
|
// ends with a `module.exports` guard. In the browser they find each other as globals,
|
||||||
|
// so the same names have to exist on `globalThis` here or `Betting` can't reach
|
||||||
|
// `HoldemState`. Load order matches the <script> order in TexasHoldem.cshtml.
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
|
||||||
|
|
||||||
|
function load(name) {
|
||||||
|
const mod = require(path.join(SRC, `${name}.js`));
|
||||||
|
globalThis[name.charAt(0).toUpperCase() + name.slice(1)] = mod;
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Money = load("money");
|
||||||
|
const PotCalculator = load("potCalculator");
|
||||||
|
const SplitCalculator = load("splitCalculator");
|
||||||
|
const Betting = load("betting");
|
||||||
|
const SettleUp = load("settleUp");
|
||||||
|
const HandHistoryExport = load("handHistoryExport");
|
||||||
|
|
||||||
|
// render.js exports { Render } rather than the namespace directly.
|
||||||
|
const Render = require(path.join(SRC, "render.js")).Render;
|
||||||
|
globalThis.Render = Render;
|
||||||
|
|
||||||
|
// state.js exports `HoldemState`, which doesn't match its filename.
|
||||||
|
const HoldemState = require(path.join(SRC, "state.js"));
|
||||||
|
globalThis.HoldemState = HoldemState;
|
||||||
|
|
||||||
|
// actionBar.js after state, since its own module-level code reaches for neither but
|
||||||
|
// its methods reach for both. Loaded last so nothing it touches is still undefined.
|
||||||
|
const ActionBar = load("actionBar");
|
||||||
|
|
||||||
|
// ---- Table builders ----------------------------------------------------------
|
||||||
|
|
||||||
|
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
|
||||||
|
// in with `stack` chips each. Everything else is the app's own defaults, so a test
|
||||||
|
// that forgets to set a field gets whatever a real dealer would get.
|
||||||
|
function makeTable({ seatCount = 6, seated = null, stack = 100, smallBlind = 1, bigBlind = 2, dealerSeatIndex = 0 } = {}) {
|
||||||
|
const state = HoldemState.createDefault();
|
||||||
|
HoldemState.resizeSeats(state, seatCount);
|
||||||
|
state.smallBlind = smallBlind;
|
||||||
|
state.bigBlind = bigBlind;
|
||||||
|
state.dealerSeatIndex = dealerSeatIndex;
|
||||||
|
|
||||||
|
const sitting = seated || state.seats.map((_, i) => i);
|
||||||
|
state.seats.forEach((player, index) => {
|
||||||
|
player.inHand = sitting.includes(index);
|
||||||
|
player.stack = sitting.includes(index) ? stack : null;
|
||||||
|
player.name = `P${index + 1}`;
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total chips the table is accountable for: what's in front of everyone, plus what
|
||||||
|
// they've pushed in this street, plus what's already banked into the pot.
|
||||||
|
function chipsOnTable(state) {
|
||||||
|
return Money.round(
|
||||||
|
state.seats.reduce(
|
||||||
|
(sum, p) => sum + (Number(p.stack) || 0) + (Number(p.committed) || 0) + (Number(p.amountOverride) || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contributions in the shape PotCalculator wants them.
|
||||||
|
function contributions(state) {
|
||||||
|
return state.seats
|
||||||
|
.filter(p => p.inHand)
|
||||||
|
.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
contribution: Betting.handTotal(p),
|
||||||
|
folded: p.folded,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
Money,
|
||||||
|
Render,
|
||||||
|
HandHistoryExport,
|
||||||
|
PotCalculator,
|
||||||
|
SplitCalculator,
|
||||||
|
SettleUp,
|
||||||
|
Betting,
|
||||||
|
HoldemState,
|
||||||
|
ActionBar,
|
||||||
|
makeTable,
|
||||||
|
chipsOnTable,
|
||||||
|
contributions,
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// What happens when the saved table can't be read.
|
||||||
|
//
|
||||||
|
// An unreadable save can only become an empty table. What it must not do is become
|
||||||
|
// one silently and then get written over by the first edit.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { HoldemState } = require("./load");
|
||||||
|
|
||||||
|
function withStored(value, { readOnly = false } = {}) {
|
||||||
|
const store = new Map();
|
||||||
|
if (value !== undefined) store.set(HoldemState.STORAGE_KEY, value);
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: key => (store.has(key) ? store.get(key) : null),
|
||||||
|
setItem(key, val) {
|
||||||
|
if (readOnly) throw new Error("QuotaExceededError");
|
||||||
|
store.set(key, val);
|
||||||
|
},
|
||||||
|
removeItem: key => store.delete(key),
|
||||||
|
};
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
HoldemState.loadFailed = false;
|
||||||
|
HoldemState.onLoadFailed = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
delete globalThis.localStorage;
|
||||||
|
HoldemState.loadFailed = false;
|
||||||
|
HoldemState.onLoadFailed = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nothing stored is a new table, not a failure", () => {
|
||||||
|
withStored(undefined);
|
||||||
|
const state = HoldemState.load();
|
||||||
|
|
||||||
|
assert.equal(HoldemState.loadFailed, false);
|
||||||
|
assert.ok(Array.isArray(state.seats));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a good save loads and reports nothing", () => {
|
||||||
|
const store = withStored(undefined);
|
||||||
|
const original = HoldemState.createDefault();
|
||||||
|
original.handNumber = 34;
|
||||||
|
HoldemState.save(original);
|
||||||
|
|
||||||
|
const state = HoldemState.load();
|
||||||
|
assert.equal(HoldemState.loadFailed, false);
|
||||||
|
assert.equal(state.handNumber, 34);
|
||||||
|
assert.equal(store.has(HoldemState.RECOVERY_KEY), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a truncated save is reported rather than silently becoming an empty table", () => {
|
||||||
|
const good = JSON.stringify(HoldemState.createDefault());
|
||||||
|
withStored(good.slice(0, Math.floor(good.length * 0.7)));
|
||||||
|
|
||||||
|
const state = HoldemState.load();
|
||||||
|
assert.equal(HoldemState.loadFailed, true);
|
||||||
|
assert.ok(Array.isArray(state.seats), "it still hands back a usable table");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("valid JSON of the wrong shape counts as unreadable too", () => {
|
||||||
|
withStored(JSON.stringify({ hello: "world" }));
|
||||||
|
|
||||||
|
HoldemState.load();
|
||||||
|
assert.equal(HoldemState.loadFailed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the unreadable copy is kept instead of being written over", () => {
|
||||||
|
// This is the half that matters: whatever a person could have picked out of the
|
||||||
|
// raw JSON used to be gone the moment the dealer touched a seat.
|
||||||
|
const broken = '{"seats":[{"name":"Ann","stack":250},{"name":"Ben"';
|
||||||
|
const store = withStored(broken);
|
||||||
|
|
||||||
|
HoldemState.load();
|
||||||
|
|
||||||
|
assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a later save doesn't touch the copy that was set aside", () => {
|
||||||
|
const broken = '{"seats":[{"name":"Ann"';
|
||||||
|
const store = withStored(broken);
|
||||||
|
|
||||||
|
const state = HoldemState.load();
|
||||||
|
state.handNumber = 2;
|
||||||
|
HoldemState.save(state);
|
||||||
|
|
||||||
|
assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
|
||||||
|
assert.notEqual(store.get(HoldemState.STORAGE_KEY), broken);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the listener fires once, not on every read", () => {
|
||||||
|
withStored("{not json");
|
||||||
|
let told = 0;
|
||||||
|
HoldemState.onLoadFailed = () => { told++; };
|
||||||
|
|
||||||
|
HoldemState.load();
|
||||||
|
HoldemState.load();
|
||||||
|
HoldemState.load();
|
||||||
|
|
||||||
|
assert.equal(told, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no room to keep the copy is not a crash", () => {
|
||||||
|
// Storage that refuses writes is exactly the storage most likely to hand back a
|
||||||
|
// half-written value in the first place.
|
||||||
|
withStored("{not json", { readOnly: true });
|
||||||
|
|
||||||
|
let state;
|
||||||
|
assert.doesNotThrow(() => { state = HoldemState.load(); });
|
||||||
|
assert.ok(Array.isArray(state.seats));
|
||||||
|
assert.equal(HoldemState.loadFailed, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
// Home games are messy. People walk up mid-hand, storm off mid-hand, argue about
|
||||||
|
// whose blind it is, and pass the button round three times before anyone deals.
|
||||||
|
// These are the cases the positional rules quietly get wrong.
|
||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Betting, HoldemState, PotCalculator, Money, makeTable, chipsOnTable, contributions } = require("./load");
|
||||||
|
|
||||||
|
const potOf = state => HoldemState.totalPot(state);
|
||||||
|
|
||||||
|
describe("a player leaves in the middle of a hand", () => {
|
||||||
|
test("their chips stay in the pot", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
const before = potOf(state);
|
||||||
|
assert.equal(before, 8);
|
||||||
|
|
||||||
|
state.seats[1].inHand = false; // dealer takes them out
|
||||||
|
|
||||||
|
assert.equal(potOf(state), before, "the pot must not shrink when a player is taken out");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("they cannot be paid out of a pot they walked away from", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
state.seats[1].inHand = false;
|
||||||
|
|
||||||
|
const players = state.seats
|
||||||
|
.filter(p => HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount) > 0)
|
||||||
|
.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||||
|
folded: p.folded || HoldemState.isDeadMoney(p),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const layers = PotCalculator.layers(players);
|
||||||
|
assert.ok(!layers[0].eligible.includes(state.seats[1].id), "a departed player can't win");
|
||||||
|
assert.equal(layers.reduce((s, l) => s + l.amount, 0), 6, "but their money is still being played for");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("taking out the player who is to act moves the action on", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const acting = state.actingSeatIndex;
|
||||||
|
|
||||||
|
state.seats[acting].inHand = false;
|
||||||
|
Betting.passAction(state, acting);
|
||||||
|
|
||||||
|
assert.notEqual(state.actingSeatIndex, acting, "the table can't wait on somebody who left");
|
||||||
|
assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty chair still contributes nothing, base pot or not", () => {
|
||||||
|
// The table-wide default must not be read as an ante from seats nobody is in.
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], dealerSeatIndex: 0 });
|
||||||
|
state.basePotAmount = 25;
|
||||||
|
assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, 25), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a player joins in the middle of a hand", () => {
|
||||||
|
test("they sit the hand out rather than joining a pot they never paid into", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
// Walks up and is dealt in — the app marks them folded for this hand.
|
||||||
|
state.seats[4].inHand = true;
|
||||||
|
state.seats[4].stack = 100;
|
||||||
|
state.seats[4].folded = true;
|
||||||
|
|
||||||
|
assert.ok(!Betting.liveSeats(state).some(s => s.index === 4), "not live this hand");
|
||||||
|
assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, state.basePotAmount), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("they are a full player again on the next hand", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
state.seats[4].inHand = true;
|
||||||
|
state.seats[4].stack = 100;
|
||||||
|
state.seats[4].folded = true;
|
||||||
|
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[4].folded, false);
|
||||||
|
assert.ok(Betting.liveSeats(state).some(s => s.index === 4), "dealt in like anyone else");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("passing the button around before the cards come out", () => {
|
||||||
|
test("moving the button re-posts the blinds and keeps the chip count straight", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
const startingChips = chipsOnTable(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[1].committed, 1);
|
||||||
|
assert.equal(state.seats[2].committed, 2);
|
||||||
|
|
||||||
|
state.dealerSeatIndex = 2;
|
||||||
|
Betting.repostBlinds(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[1].committed, 0, "the old small blind got their chip back");
|
||||||
|
assert.equal(state.seats[3].committed, 1, "and the new one has posted");
|
||||||
|
assert.equal(state.seats[0].committed, 2);
|
||||||
|
assert.equal(chipsOnTable(state), startingChips, "no chips created or lost in the shuffle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passing it round many times still balances", () => {
|
||||||
|
const state = makeTable({ seatCount: 8, seated: [0, 2, 3, 5, 7], dealerSeatIndex: 0 });
|
||||||
|
const startingChips = chipsOnTable(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
[2, 5, 7, 3, 0, 5].forEach(seat => {
|
||||||
|
state.dealerSeatIndex = seat;
|
||||||
|
Betting.repostBlinds(state);
|
||||||
|
assert.equal(chipsOnTable(state), startingChips, `chips drifted after passing to seat ${seat}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(Betting.committed(state.seats[sbIndex]), 1);
|
||||||
|
assert.equal(Betting.committed(state.seats[bbIndex]), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("once somebody has acted, the blinds are settled", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(Betting.onlyBlindsIn(state), true, "nothing but blinds so far");
|
||||||
|
|
||||||
|
Betting.call(state, 3);
|
||||||
|
assert.equal(Betting.onlyBlindsIn(state), false, "a call means the blinds are set where they are");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("naming the blinds by hand", () => {
|
||||||
|
test("a named blind wins over the button order", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||||
|
|
||||||
|
const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(sbIndex, 3);
|
||||||
|
assert.equal(bbIndex, 0);
|
||||||
|
assert.equal(manual, true);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(state.seats[3].committed, 1);
|
||||||
|
assert.equal(state.seats[0].committed, 2);
|
||||||
|
assert.equal(state.seats[1].committed, 0, "the positional small blind posts nothing");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a named blind on a player who isn't dealt in is ignored", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
state.blindOverride = { sbIndex: 4, bbIndex: 0 }; // seat 4 is an empty chair
|
||||||
|
|
||||||
|
const { sbIndex, manual } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.ok(!manual, "falls back to the button order rather than posting to nobody");
|
||||||
|
assert.ok(state.seats[sbIndex].inHand);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("both blinds on one player is refused", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
state.blindOverride = { sbIndex: 1, bbIndex: 1 };
|
||||||
|
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.notEqual(sbIndex, bbIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("naming blinds heads-up still acts in the right order", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
|
||||||
|
state.blindOverride = { sbIndex: 5, bbIndex: 2 }; // deliberately the reverse
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(state.seats[5].committed, 1);
|
||||||
|
assert.equal(state.seats[2].committed, 2);
|
||||||
|
assert.equal(state.actingSeatIndex, 5, "the small blind acts first pre-flop heads-up");
|
||||||
|
|
||||||
|
Betting.call(state, 5);
|
||||||
|
Betting.check(state, 2);
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
assert.equal(state.actingSeatIndex, 2, "and the big blind leads after the flop");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the naming only lasts the hand it was made for", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
assert.equal(state.blindOverride, null, "blinds go back to following the button");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the first hand on a fresh table", () => {
|
||||||
|
test("the button never starts on an empty chair", () => {
|
||||||
|
const state = HoldemState.createDefault();
|
||||||
|
HoldemState.resizeSeats(state, 9);
|
||||||
|
[4, 6, 7].forEach(i => { state.seats[i].inHand = true; state.seats[i].stack = 100; });
|
||||||
|
assert.equal(state.dealerSeatIndex, 0, "a fresh table starts the button on seat 1");
|
||||||
|
assert.equal(state.seats[0].inHand, false, "which nobody is sitting in");
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.ok(state.seats[state.dealerSeatIndex].inHand, "the button moved to a real player");
|
||||||
|
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.ok(state.seats[sbIndex].inHand);
|
||||||
|
assert.ok(state.seats[bbIndex].inHand);
|
||||||
|
assert.notEqual(sbIndex, bbIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a table that empties out around the button recovers", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 3 });
|
||||||
|
state.seats[3].inHand = false; // the player holding the button leaves
|
||||||
|
HoldemState.ensureButtonOnPlayer(state);
|
||||||
|
assert.ok(state.seats[state.dealerSeatIndex].inHand);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two players left after everyone else walks is heads-up, blinds reversed", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
[2, 3].forEach(i => { state.seats[i].inHand = false; });
|
||||||
|
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
const { sbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(headsUp, true);
|
||||||
|
assert.equal(sbIndex, state.dealerSeatIndex, "heads-up the button posts the small blind");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chips survive a messy table", () => {
|
||||||
|
test("joining, leaving and passing the button never changes the chip count", () => {
|
||||||
|
const state = makeTable({ seatCount: 8, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||||
|
state.seats[6].stack = 100; // sitting down but not dealt in yet
|
||||||
|
const startingChips = chipsOnTable(state);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
state.dealerSeatIndex = 2;
|
||||||
|
Betting.repostBlinds(state);
|
||||||
|
|
||||||
|
state.seats[6].inHand = true; // walks up mid-hand
|
||||||
|
state.seats[6].folded = true;
|
||||||
|
assert.equal(chipsOnTable(state), startingChips);
|
||||||
|
|
||||||
|
Betting.call(state, state.actingSeatIndex);
|
||||||
|
state.seats[1].inHand = false; // and someone storms off
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
assert.equal(chipsOnTable(state), startingChips, "every chip is still accounted for");
|
||||||
|
assert.equal(
|
||||||
|
Money.toCents(potOf(state) + state.seats.reduce((s, p) => s + (Number(p.stack) || 0), 0)),
|
||||||
|
Money.toCents(startingChips),
|
||||||
|
"and the pot plus the stacks is the whole table"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Money, HandHistoryExport } = require("./load");
|
||||||
|
|
||||||
|
describe("Money.format", () => {
|
||||||
|
test("whole amounts drop the pointless cents", () => {
|
||||||
|
assert.equal(Money.format(12), "$12");
|
||||||
|
assert.equal(Money.format(450), "$450");
|
||||||
|
assert.equal(Money.format(1250000), "$1,250,000");
|
||||||
|
assert.equal(Money.format(0), "$0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("amounts that carry cents still show them", () => {
|
||||||
|
assert.equal(Money.format(12.5), "$12.50");
|
||||||
|
assert.equal(Money.format(0.25), "$0.25");
|
||||||
|
assert.equal(Money.format(3.01), "$3.01");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a quarter-blind game reads exactly as it did", () => {
|
||||||
|
// The case that would be ruined by dropping cents unconditionally.
|
||||||
|
[0.25, 0.5, 1.75, 12.05].forEach(v => {
|
||||||
|
assert.match(Money.format(v), /\.\d\d$/, `${v} lost its cents`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("floating-point dust doesn't invent cents", () => {
|
||||||
|
assert.equal(Money.format(0.1 + 0.2), "$0.30");
|
||||||
|
assert.equal(Money.format(1.0000000001), "$1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("signed amounts put the sign out front, both scales", () => {
|
||||||
|
assert.equal(Money.formatSigned(87500), "+$87,500");
|
||||||
|
assert.equal(Money.formatSigned(-87500), "-$87,500");
|
||||||
|
assert.equal(Money.formatSigned(-12.5), "-$12.50");
|
||||||
|
assert.equal(Money.formatSigned(0), "$0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rubbish in doesn't produce rubbish out", () => {
|
||||||
|
assert.equal(Money.format(null), "$0");
|
||||||
|
assert.equal(Money.format(undefined), "$0");
|
||||||
|
assert.equal(Money.format("nonsense"), "$0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Money.forSheet", () => {
|
||||||
|
test("a bare number a spreadsheet will add up", () => {
|
||||||
|
assert.equal(Money.forSheet(1250000), "1250000.00");
|
||||||
|
assert.equal(Money.forSheet(12.5), "12.50");
|
||||||
|
assert.equal(Money.forSheet(-87500), "-87500.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no currency symbol and no thousands separators", () => {
|
||||||
|
const written = Money.forSheet(1234567.89);
|
||||||
|
assert.ok(!written.includes("$"), "a $ makes the cell text");
|
||||||
|
assert.ok(!written.includes(","), "a comma makes the cell text");
|
||||||
|
assert.equal(Number(written), 1234567.89, "and it parses straight back to a number");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the exported CSV", () => {
|
||||||
|
const history = [
|
||||||
|
{ handNumber: 2, timestamp: 0, pot: 1250000, payouts: [{ id: "a", label: "Alexandra", amount: 1250000 }] },
|
||||||
|
{ handNumber: 1, timestamp: 0, pot: 24.5, payouts: [{ id: "b", label: "Bob", amount: 24.5 }] },
|
||||||
|
];
|
||||||
|
const standings = [
|
||||||
|
{ id: "a", name: "Alexandra", in: 1250000, won: 1250000, net: 0, boughtIn: 3200000, chipsNow: 3200000, cashPosition: 0 },
|
||||||
|
{ id: "b", name: "Bob", in: 24.5, won: 0, net: -24.5, boughtIn: 100, chipsNow: 75.5, cashPosition: -24.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Splitting on commas isn't good enough: the timestamp column legitimately holds
|
||||||
|
// one ("1/1/1970, 5:00:00 PM") and is quoted for exactly that reason.
|
||||||
|
const splitRow = line => {
|
||||||
|
const fields = [];
|
||||||
|
let field = "";
|
||||||
|
let quoted = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const c = line[i];
|
||||||
|
if (quoted) {
|
||||||
|
if (c === '"' && line[i + 1] === '"') { field += '"'; i++; }
|
||||||
|
else if (c === '"') quoted = false;
|
||||||
|
else field += c;
|
||||||
|
} else if (c === '"') quoted = true;
|
||||||
|
else if (c === ",") { fields.push(field); field = ""; }
|
||||||
|
else field += c;
|
||||||
|
}
|
||||||
|
fields.push(field);
|
||||||
|
return fields;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("every money cell parses as a number", () => {
|
||||||
|
const csv = HandHistoryExport.toCsv(history, standings);
|
||||||
|
const lines = csv.split("\r\n");
|
||||||
|
|
||||||
|
lines.slice(1, 3).forEach(line => {
|
||||||
|
const cell = splitRow(line)[2]; // Pot
|
||||||
|
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `pot cell "${cell}" isn't a number`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Standings rows: the money columns have to be numeric. Named rather than
|
||||||
|
// "everything after the first", which is what this used to say — the row now
|
||||||
|
// ends with a Notes column carrying whatever the dealer typed against the seat,
|
||||||
|
// and that is text by definition. The point of this test is that a column
|
||||||
|
// somebody totals adds up, so it checks the columns somebody totals.
|
||||||
|
const standingsStart = lines.findIndex(l => l.startsWith("Player,"));
|
||||||
|
const header = splitRow(lines[standingsStart]);
|
||||||
|
const moneyColumns = ["Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]
|
||||||
|
.map(name => {
|
||||||
|
const index = header.indexOf(name);
|
||||||
|
assert.ok(index > 0, `the standings header should still have a ${name} column`);
|
||||||
|
return index;
|
||||||
|
});
|
||||||
|
|
||||||
|
lines.slice(standingsStart + 1).filter(Boolean).forEach(line => {
|
||||||
|
const cells = splitRow(line);
|
||||||
|
moneyColumns.forEach(index => {
|
||||||
|
const cell = cells[index];
|
||||||
|
assert.ok(cell !== "" && !Number.isNaN(Number(cell)),
|
||||||
|
`standings cell "${cell}" in column ${header[index]} isn't a number`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a million-dollar pot doesn't need quoting any more", () => {
|
||||||
|
const csv = HandHistoryExport.toCsv(history, []);
|
||||||
|
assert.ok(csv.includes("1250000.00"), "written plainly");
|
||||||
|
assert.ok(!csv.includes('"$1,250,000'), "not as a quoted, unsummable string");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("names and payout descriptions stay human", () => {
|
||||||
|
const csv = HandHistoryExport.toCsv(history, standings);
|
||||||
|
assert.ok(csv.includes("Alexandra"), "the name column is still a name");
|
||||||
|
assert.ok(csv.includes("Alexandra: $1,250,000"), "the payout column still reads like a sentence");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a name with a comma is still quoted properly", () => {
|
||||||
|
const csv = HandHistoryExport.toCsv([], [
|
||||||
|
{ id: "x", name: "Smith, John", in: 0, won: 0, net: 0, boughtIn: 0, chipsNow: 0, cashPosition: 0 },
|
||||||
|
]);
|
||||||
|
assert.ok(csv.includes('"Smith, John"'), "the CSV escaping still applies where it's needed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Noticing that the same table is open twice.
|
||||||
|
//
|
||||||
|
// Two tabs each hold their own copy in memory and each save writes the whole thing,
|
||||||
|
// so the second one to act throws the first one's work away. This doesn't merge them
|
||||||
|
// — replacing a table under a dealer's hands mid-hand would be its own hazard — it
|
||||||
|
// notices, which is what lets them decide.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { HoldemState } = require("./load");
|
||||||
|
|
||||||
|
function withWindow() {
|
||||||
|
const listeners = [];
|
||||||
|
globalThis.window = {
|
||||||
|
addEventListener(type, fn) { if (type === "storage") listeners.push(fn); },
|
||||||
|
removeEventListener(type, fn) {
|
||||||
|
const i = listeners.indexOf(fn);
|
||||||
|
if (i >= 0) listeners.splice(i, 1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
listeners,
|
||||||
|
fire(key) { listeners.forEach(fn => fn({ key })); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
HoldemState.otherTabActive = false;
|
||||||
|
HoldemState.onOtherTab = null;
|
||||||
|
HoldemState.tabWatcher = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
delete globalThis.window;
|
||||||
|
HoldemState.otherTabActive = false;
|
||||||
|
HoldemState.onOtherTab = null;
|
||||||
|
HoldemState.tabWatcher = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a write to this table from elsewhere is noticed", () => {
|
||||||
|
const win = withWindow();
|
||||||
|
let told = 0;
|
||||||
|
HoldemState.onOtherTab = () => { told++; };
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
|
||||||
|
win.fire(HoldemState.STORAGE_KEY);
|
||||||
|
|
||||||
|
assert.equal(HoldemState.otherTabActive, true);
|
||||||
|
assert.equal(told, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("somebody else's storage key is not our business", () => {
|
||||||
|
const win = withWindow();
|
||||||
|
let told = 0;
|
||||||
|
HoldemState.onOtherTab = () => { told++; };
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
|
||||||
|
win.fire("some.other.app.state");
|
||||||
|
win.fire(HoldemState.RECOVERY_KEY);
|
||||||
|
|
||||||
|
assert.equal(HoldemState.otherTabActive, false);
|
||||||
|
assert.equal(told, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("told once, however many times the other tab acts", () => {
|
||||||
|
const win = withWindow();
|
||||||
|
let told = 0;
|
||||||
|
HoldemState.onOtherTab = () => { told++; };
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) win.fire(HoldemState.STORAGE_KEY);
|
||||||
|
|
||||||
|
assert.equal(told, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("watching twice doesn't listen twice", () => {
|
||||||
|
const win = withWindow();
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
|
||||||
|
assert.equal(win.listeners.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no listener set is not an error", () => {
|
||||||
|
const win = withWindow();
|
||||||
|
HoldemState.onOtherTab = null;
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => win.fire(HoldemState.STORAGE_KEY));
|
||||||
|
assert.equal(HoldemState.otherTabActive, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no window at all is not an error", () => {
|
||||||
|
// The same modules are loaded in Node by the test harness.
|
||||||
|
delete globalThis.window;
|
||||||
|
HoldemState.tabWatcher = null;
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => HoldemState.watchOtherTabs());
|
||||||
|
assert.equal(HoldemState.otherTabActive, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// Somebody who walks up and sits down after the blinds have gone past them.
|
||||||
|
//
|
||||||
|
// Dealing them straight in is a free hand in late position — which is what posting
|
||||||
|
// exists to stop, and it comes out of everybody else's night a couple of dollars at a
|
||||||
|
// time. The tool used to do it silently. It now says so, and takes the post if the
|
||||||
|
// dealer says to.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { HoldemState, Betting, makeTable } = require("./load");
|
||||||
|
|
||||||
|
const NAMES = ["Ann", "Ben", "Cal", "Dee"];
|
||||||
|
const table = (dealerSeatIndex = 0) => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex });
|
||||||
|
NAMES.forEach((name, i) => { state.seats[i].name = name; });
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Eve walks up and takes seat 5 while the table is running.
|
||||||
|
const seatEve = state => {
|
||||||
|
state.seats[4].name = "Eve";
|
||||||
|
state.seats[4].inHand = true;
|
||||||
|
state.seats[4].stack = 200;
|
||||||
|
state.seats[4].buyIns = 200;
|
||||||
|
return 4;
|
||||||
|
};
|
||||||
|
|
||||||
|
const debtors = state => {
|
||||||
|
const { bbIndex } = HoldemState.getBlindSeats(state);
|
||||||
|
return HoldemState.blindDebtors(state, bbIndex).map(i => state.seats[i].name);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("who is owed a blind", () => {
|
||||||
|
test("nobody, at a table that hasn't dealt a hand yet", () => {
|
||||||
|
// Everyone is new on the first hand. Asking the whole table to post would be
|
||||||
|
// the tool mistaking the start of the night for five people walking up late.
|
||||||
|
const state = table();
|
||||||
|
assert.deepEqual(debtors(state), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nobody, on an ordinary hand where the same people are still sitting there", () => {
|
||||||
|
const state = table();
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
assert.deepEqual(debtors(state), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the player who has just sat down", () => {
|
||||||
|
const state = table();
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
seatEve(state);
|
||||||
|
|
||||||
|
assert.deepEqual(debtors(state), ["Eve"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("not once they've been dealt a hand", () => {
|
||||||
|
const state = table();
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
seatEve(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
assert.deepEqual(debtors(state), [], "she's one of the regulars now");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("not somebody who is the big blind this hand — they're paying already", () => {
|
||||||
|
const state = table(0); // blinds Ben and Cal
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state); // blinds move to Cal and Dee
|
||||||
|
|
||||||
|
// Dee sits out for a hand and comes straight back into her own big blind.
|
||||||
|
state.seats[3].inHand = false;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
state.seats[3].inHand = true;
|
||||||
|
|
||||||
|
const { bbIndex } = HoldemState.getBlindSeats(state);
|
||||||
|
assert.ok(!HoldemState.blindDebtors(state, bbIndex).includes(bbIndex),
|
||||||
|
"the big blind is never asked to post on top of their blind");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("somebody back from sitting out is owed one just like a newcomer", () => {
|
||||||
|
const state = table();
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
state.seats[3].inHand = false; // Dee stands up for a hand
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
state.seats[3].inHand = true; // and sits back down
|
||||||
|
|
||||||
|
assert.ok(debtors(state).includes("Dee"), "the blinds went past her while she was up");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a seat sat out for having no chips isn't owed anything", () => {
|
||||||
|
// They aren't being dealt in, so there's no free hand to charge for.
|
||||||
|
const state = table();
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
const eve = seatEve(state);
|
||||||
|
state.seats[eve].stack = 0;
|
||||||
|
|
||||||
|
const preview = Betting.blindPreview(state);
|
||||||
|
assert.ok(!preview.debtors.includes(eve), "she's sitting out until she buys chips");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("posting to come in", () => {
|
||||||
|
test("the post goes in the middle and buys a hand", () => {
|
||||||
|
const state = table();
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
const eve = seatEve(state);
|
||||||
|
|
||||||
|
state.postingIn = [state.seats[eve].id];
|
||||||
|
const before = Betting.stackOf(state, state.seats[eve]);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(Betting.committed(state.seats[eve]), state.bigBlind, "she's posted a big blind");
|
||||||
|
assert.equal(Betting.stackOf(state, state.seats[eve]), before - state.bigBlind,
|
||||||
|
"out of her own stack, not conjured");
|
||||||
|
assert.equal(state.currentBet, state.bigBlind, "and it doesn't raise the hand");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("not posting leaves her stack alone", () => {
|
||||||
|
const state = table();
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
const eve = seatEve(state);
|
||||||
|
|
||||||
|
const before = Betting.stackOf(state, state.seats[eve]);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(Betting.committed(state.seats[eve]), 0);
|
||||||
|
assert.equal(Betting.stackOf(state, state.seats[eve]), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the pot is what the blinds and the post add up to", () => {
|
||||||
|
const state = table();
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
const eve = seatEve(state);
|
||||||
|
|
||||||
|
state.postingIn = [state.seats[eve].id];
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
const middle = state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
|
||||||
|
assert.equal(middle, state.smallBlind + state.bigBlind + state.bigBlind);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it's spent after the hand, not a standing charge", () => {
|
||||||
|
const state = table();
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
const eve = seatEve(state);
|
||||||
|
|
||||||
|
state.postingIn = [state.seats[eve].id];
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
assert.deepEqual(state.postingIn, [], "or she'd pay an extra blind every hand all night");
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.ok(Betting.committed(state.seats[eve]) === 0 ||
|
||||||
|
state.seats[eve].id === state.seats[HoldemState.getBlindSeats(state).bbIndex]?.id,
|
||||||
|
"she pays nothing extra on the next hand");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moving the button after a post doesn't lose it", () => {
|
||||||
|
// repostBlinds takes every posted chip back and deals again. A post that
|
||||||
|
// cleared itself on the way in would be refunded and never retaken.
|
||||||
|
const state = table();
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
const eve = seatEve(state);
|
||||||
|
|
||||||
|
state.postingIn = [state.seats[eve].id];
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const chips = () => state.seats.reduce(
|
||||||
|
(sum, p) => sum + Betting.stackOf(state, p) + Betting.handTotal(p), 0);
|
||||||
|
const before = chips();
|
||||||
|
|
||||||
|
state.dealerSeatIndex = 2;
|
||||||
|
Betting.repostBlinds(state);
|
||||||
|
|
||||||
|
assert.equal(Betting.committed(state.seats[eve]), state.bigBlind, "still posted");
|
||||||
|
assert.equal(chips(), before, "and no chips made or lost in the shuffle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a post from somebody who isn't owed one is not taken", () => {
|
||||||
|
// Only seats actually being dealt in can post, and only for their own chips.
|
||||||
|
const state = table();
|
||||||
|
state.postingIn = [state.seats[3].id];
|
||||||
|
state.seats[3].inHand = false;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(Betting.committed(state.seats[3]), 0, "she isn't even in the hand");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what the dealer is shown", () => {
|
||||||
|
test("the prompt has something to say about it, and something to press", () => {
|
||||||
|
const state = table();
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
const eve = seatEve(state);
|
||||||
|
|
||||||
|
const preview = Betting.blindPreview(state);
|
||||||
|
assert.deepEqual(preview.debtors, [eve]);
|
||||||
|
assert.deepEqual(preview.posting, [], "not posting until the dealer says so");
|
||||||
|
|
||||||
|
state.postingIn = [state.seats[eve].id];
|
||||||
|
assert.deepEqual(Betting.blindPreview(state).posting, [state.seats[eve].id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an old save reads as a table nobody has just walked up to", () => {
|
||||||
|
const state = table();
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const parsed = JSON.parse(JSON.stringify(state));
|
||||||
|
delete parsed.dealtLastHand;
|
||||||
|
delete parsed.postingIn;
|
||||||
|
|
||||||
|
const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: key => (store.has(key) ? store.get(key) : null),
|
||||||
|
setItem: (key, value) => store.set(key, value),
|
||||||
|
removeItem: key => store.delete(key),
|
||||||
|
};
|
||||||
|
|
||||||
|
const loaded = HoldemState.load();
|
||||||
|
assert.deepEqual(loaded.dealtLastHand, []);
|
||||||
|
assert.deepEqual(loaded.postingIn, []);
|
||||||
|
assert.deepEqual(debtors(loaded), [],
|
||||||
|
"reopening the tab doesn't ask the whole table to post");
|
||||||
|
delete globalThis.localStorage;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { PotCalculator } = require("./load");
|
||||||
|
|
||||||
|
const p = (id, contribution, folded = false) => ({ id, contribution, folded });
|
||||||
|
|
||||||
|
describe("PotCalculator.layers", () => {
|
||||||
|
test("one pot when everyone put in the same", () => {
|
||||||
|
const layers = PotCalculator.layers([p("a", 50), p("b", 50), p("c", 50)]);
|
||||||
|
assert.equal(layers.length, 1);
|
||||||
|
assert.equal(layers[0].amount, 150);
|
||||||
|
assert.equal(layers[0].label, "Main pot");
|
||||||
|
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a short all-in opens a side pot only the deeper players can win", () => {
|
||||||
|
// a is all-in for 20, b and c keep betting to 60.
|
||||||
|
const layers = PotCalculator.layers([p("a", 20), p("b", 60), p("c", 60)]);
|
||||||
|
assert.equal(layers.length, 2);
|
||||||
|
|
||||||
|
assert.equal(layers[0].amount, 60); // 20 x 3
|
||||||
|
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c"]);
|
||||||
|
|
||||||
|
assert.equal(layers[1].amount, 80); // 40 x 2
|
||||||
|
assert.deepEqual(layers[1].eligible.sort(), ["b", "c"]);
|
||||||
|
assert.equal(layers[1].label, "Side pot 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("folded money stays in the pot as dead money but wins nothing", () => {
|
||||||
|
const layers = PotCalculator.layers([p("a", 50), p("b", 50), p("folder", 50, true)]);
|
||||||
|
assert.equal(layers.length, 1);
|
||||||
|
assert.equal(layers[0].amount, 150, "the folder's chips are still in the middle");
|
||||||
|
assert.ok(!layers[0].eligible.includes("folder"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("layers with the same eligible players are announced as one pot", () => {
|
||||||
|
// Two folders at different heights would each open a band, but a dealer
|
||||||
|
// announces one pot between the two players who can actually win it.
|
||||||
|
const layers = PotCalculator.layers([
|
||||||
|
p("a", 100),
|
||||||
|
p("b", 100),
|
||||||
|
p("f1", 20, true),
|
||||||
|
p("f2", 40, true),
|
||||||
|
]);
|
||||||
|
const eligibleSets = layers.map(l => l.eligible.slice().sort().join(","));
|
||||||
|
assert.deepEqual(eligibleSets, ["a,b"], "one announceable pot, not three");
|
||||||
|
assert.equal(layers[0].amount, 260);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("layer amounts always sum to the contested money", () => {
|
||||||
|
const players = [p("a", 12.5), p("b", 33.25), p("c", 33.25), p("d", 5, true)];
|
||||||
|
const total = players.reduce((s, x) => s + x.contribution, 0);
|
||||||
|
const uncalled = PotCalculator.uncalledReturns(players).reduce((s, r) => s + r.amount, 0);
|
||||||
|
const layered = PotCalculator.layers(players).reduce((s, l) => s + l.amount, 0);
|
||||||
|
assert.equal(Math.round((layered + uncalled) * 100), Math.round(total * 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nobody contributed means no pots, not an empty main pot", () => {
|
||||||
|
assert.deepEqual(PotCalculator.layers([p("a", 0), p("b", 0)]), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PotCalculator.uncalledReturns", () => {
|
||||||
|
test("an overbet nobody called goes back to whoever bet it", () => {
|
||||||
|
// b shoves 200, a calls only 50 and everyone else folds.
|
||||||
|
const returns = PotCalculator.uncalledReturns([p("a", 50), p("b", 200)]);
|
||||||
|
assert.deepEqual(returns, [{ id: "b", amount: 150 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a called bet leaves nothing to return", () => {
|
||||||
|
assert.deepEqual(PotCalculator.uncalledReturns([p("a", 50), p("b", 50)]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the cap is the biggest live contribution, so a folder's overbet comes back", () => {
|
||||||
|
const returns = PotCalculator.uncalledReturns([p("live", 30), p("bully", 90, true)]);
|
||||||
|
assert.deepEqual(returns, [{ id: "bully", amount: 60 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with nobody live it stays quiet rather than refunding the table", () => {
|
||||||
|
// Every fold flag set is a dealer mistake to correct, not a signal that
|
||||||
|
// every chip on the felt was uncalled.
|
||||||
|
assert.deepEqual(PotCalculator.uncalledReturns([p("a", 10, true), p("b", 20, true)]), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PotCalculator.resolveRound", () => {
|
||||||
|
test("a single winner takes the layer they were matched for", () => {
|
||||||
|
const entries = PotCalculator.buildEntries([p("a", 50), p("b", 50)]);
|
||||||
|
const result = PotCalculator.resolveRound(entries, ["a"]);
|
||||||
|
assert.equal(result.payoutPool, 100);
|
||||||
|
assert.deepEqual(result.perWinner, [{ id: "a", amount: 100 }]);
|
||||||
|
assert.equal(result.remainingContenders.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an all-in winner only takes what everyone matched, the rest rolls forward", () => {
|
||||||
|
const entries = PotCalculator.buildEntries([p("short", 20), p("b", 60), p("c", 60)]);
|
||||||
|
const result = PotCalculator.resolveRound(entries, ["short"]);
|
||||||
|
assert.equal(result.capApplied, 20);
|
||||||
|
assert.equal(result.payoutPool, 60, "20 from each of three players");
|
||||||
|
// b and c still have 40 each left to play for.
|
||||||
|
assert.deepEqual(result.remainingContenders.map(e => e.id).sort(), ["b", "c"]);
|
||||||
|
assert.equal(PotCalculator.totalPot(result.updatedEntries), 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a chopped pot splits evenly", () => {
|
||||||
|
const entries = PotCalculator.buildEntries([p("a", 30), p("b", 30)]);
|
||||||
|
const result = PotCalculator.resolveRound(entries, ["a", "b"]);
|
||||||
|
assert.deepEqual(result.perWinner, [{ id: "a", amount: 30 }, { id: "b", amount: 30 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("odd cents go to the winners in selection order and nothing is lost", () => {
|
||||||
|
// A $10.01 pot three ways doesn't divide: 3.34 / 3.34 / 3.33.
|
||||||
|
const entries = PotCalculator.buildEntries([p("a", 3.34), p("b", 3.34), p("c", 3.33)]);
|
||||||
|
const result = PotCalculator.resolveRound(entries, ["a", "b", "c"]);
|
||||||
|
const paid = result.perWinner.reduce((s, w) => s + w.amount, 0);
|
||||||
|
assert.equal(Math.round(paid * 100), Math.round(result.payoutPool * 100), "no chips lost to rounding");
|
||||||
|
assert.ok(result.perWinner[0].amount >= result.perWinner[2].amount, "odd cent goes to the first selected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting nobody who can win is an error, not a silent no-op", () => {
|
||||||
|
const entries = PotCalculator.buildEntries([p("a", 50), p("b", 50)]);
|
||||||
|
assert.throws(() => PotCalculator.resolveRound(entries, ["ghost"]), /No valid winners/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// What a pot-sized raise actually is.
|
||||||
|
//
|
||||||
|
// The raise pad worked it out as "the current bet plus the pot as it stands", which
|
||||||
|
// leaves out the fact that the raiser calls first and their call is in the pot before
|
||||||
|
// the raise is measured off it. Every quick amount came out short — pre-flop at 1/2 it
|
||||||
|
// offered "Pot $5" where a pot raise is $7. Players say "pot" out loud meaning a
|
||||||
|
// definite number, and a dealer tapping the button that claims to be it should record
|
||||||
|
// what was actually said.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { Betting, Money, makeTable } = require("./load");
|
||||||
|
|
||||||
|
describe("pre-flop at 1/2", () => {
|
||||||
|
const dealt = () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("a pot raise from the first player to act is $7", () => {
|
||||||
|
// Call the $2, which makes the pot $5, then raise $5.
|
||||||
|
const state = dealt();
|
||||||
|
assert.equal(Betting.potSoFar(state), 3, "the blinds");
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("half pot is $5 — $4.50 rounded up to something the chips can make", () => {
|
||||||
|
// The old arithmetic gave $3.50, which clamped up to the minimum and put the
|
||||||
|
// same $4 on two buttons side by side. The honest half-pot is $4.50, and the
|
||||||
|
// smallest chip in the default legend is a dollar, so the button would have
|
||||||
|
// offered an amount the dealer had to round themselves every single time.
|
||||||
|
const state = dealt();
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5);
|
||||||
|
assert.notEqual(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5),
|
||||||
|
Betting.minRaiseTo(state), "the two quick buttons say different things");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the small blind owes less, so their pot raise is smaller", () => {
|
||||||
|
// They have $1 in already: call $1, pot becomes $4, raise $4, total $6.
|
||||||
|
const state = dealt();
|
||||||
|
const { sbIndex } = require("./load").HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(Betting.toCall(state, state.seats[sbIndex]), 1);
|
||||||
|
assert.equal(Betting.potRaiseTo(state, sbIndex), 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the big blind owes nothing, so it is just the pot", () => {
|
||||||
|
const state = dealt();
|
||||||
|
const { bbIndex } = require("./load").HoldemState.getBlindSeats(state);
|
||||||
|
assert.equal(Betting.toCall(state, state.seats[bbIndex]), 0);
|
||||||
|
assert.equal(Betting.potRaiseTo(state, bbIndex), 5, "the $2 bet plus the $3 pot");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("after the flop", () => {
|
||||||
|
const flopWithBet = () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 500, dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("a pot raise facing a bet is three times the bet plus the dead money", () => {
|
||||||
|
const state = flopWithBet();
|
||||||
|
const potBefore = Betting.potSoFar(state); // $6 carried over
|
||||||
|
const bettor = state.actingSeatIndex;
|
||||||
|
Betting.raiseTo(state, bettor, 10);
|
||||||
|
|
||||||
|
const raiser = state.actingSeatIndex;
|
||||||
|
// Call $10, pot becomes $26, raise $26 → $36. Which is 3×10 + 6.
|
||||||
|
assert.equal(Betting.potRaiseTo(state, raiser), 36);
|
||||||
|
assert.equal(Betting.potRaiseTo(state, raiser), 3 * 10 + potBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("half pot facing that bet is $23", () => {
|
||||||
|
const state = flopWithBet();
|
||||||
|
Betting.raiseTo(state, state.actingSeatIndex, 10);
|
||||||
|
// Call $10, pot becomes $26, raise $13 → $23.
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 23);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("facing no bet at all it is a plain pot-sized bet", () => {
|
||||||
|
const state = flopWithBet();
|
||||||
|
assert.equal(state.currentBet, 0, "nobody has bet the flop");
|
||||||
|
assert.equal(Betting.toCall(state, state.seats[state.actingSeatIndex]), 0);
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), Betting.potSoFar(state));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("it stays sane at the edges", () => {
|
||||||
|
test("a seat that doesn't exist gets zero rather than throwing", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||||
|
assert.equal(Betting.potRaiseTo(state, 99), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the amount is always at least a call", () => {
|
||||||
|
// A raise-to below the current bet would be nonsense to offer. Every fraction
|
||||||
|
// from a tenth up lands at or above what the player owes.
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const index = state.actingSeatIndex;
|
||||||
|
[0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => {
|
||||||
|
assert.ok(Betting.potRaiseTo(state, index, fraction) >= state.currentBet,
|
||||||
|
`${fraction} of the pot should still cover the bet`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cent-sized blinds don't drift", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 20, smallBlind: 0.05, bigBlind: 0.1 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
// Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35.
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the amounts are ones the table can actually build", () => {
|
||||||
|
const CENTS = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }];
|
||||||
|
const DOLLARS = [{ value: 1 }, { value: 5 }, { value: 25 }, { value: 100 }];
|
||||||
|
|
||||||
|
const table = (legend, smallBlind, bigBlind, stack = 200) => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack, smallBlind, bigBlind });
|
||||||
|
state.chipLegend = legend;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("a half-pot that lands on a half chip is rounded up, not offered as it is", () => {
|
||||||
|
const state = table(DOLLARS, 1, 2);
|
||||||
|
// Honestly $4.50. No dollar chip makes fifty cents.
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5);
|
||||||
|
assert.equal(Money.chipBreakdown(5, DOLLARS).remainder, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("up rather than down — a shaved pot bet is not a pot bet", () => {
|
||||||
|
const state = table(DOLLARS, 1, 2);
|
||||||
|
assert.ok(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5) > 4.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an amount the chips already make is left exactly alone", () => {
|
||||||
|
const state = table(DOLLARS, 1, 2);
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7, "the pot raise was already whole");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a cent table keeps its cents", () => {
|
||||||
|
const state = table(CENTS, 0.05, 0.1);
|
||||||
|
// Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35, which cent chips make.
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
|
||||||
|
assert.equal(Money.chipBreakdown(0.35, CENTS).remainder, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legend that plainly isn't for these stakes is not applied", () => {
|
||||||
|
// Dollar chips on a 5c/10c game means somebody never filled the legend in.
|
||||||
|
// Rounding those blinds up to whole dollars would be the tool doing real
|
||||||
|
// damage on the strength of a stale field, so the amounts stay exact and the
|
||||||
|
// wrong legend stays visible.
|
||||||
|
const state = table(DOLLARS, 0.05, 0.1);
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a table with no legend at all gets exact amounts", () => {
|
||||||
|
const state = table([], 1, 2);
|
||||||
|
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 4.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every fraction comes out buildable, and never shaved", () => {
|
||||||
|
const state = table(DOLLARS, 1, 2);
|
||||||
|
const index = state.actingSeatIndex;
|
||||||
|
const potAfterCall = Betting.potSoFar(state) + Betting.toCall(state, state.seats[index]);
|
||||||
|
|
||||||
|
[0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => {
|
||||||
|
const value = Betting.potRaiseTo(state, index, fraction);
|
||||||
|
const exact = state.currentBet + potAfterCall * fraction;
|
||||||
|
assert.ok(value >= exact, `${fraction} of the pot should never be rounded down`);
|
||||||
|
assert.ok(value - exact < 1, `${fraction} of the pot should round by less than a chip`);
|
||||||
|
assert.equal(Money.chipBreakdown(value, DOLLARS).remainder, 0,
|
||||||
|
`${fraction} of the pot should be buildable from the table's chips`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Chips only ever leave this tool because a dealer meant them to. Starting the next
|
||||||
|
// hand clears every seat's contribution, so an unresolved pot is simply destroyed —
|
||||||
|
// the UI now asks first, and the number it quotes has to be exactly what goes.
|
||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Betting, HoldemState, Money, makeTable, chipsOnTable } = require("./load");
|
||||||
|
|
||||||
|
// The same measure the confirmation uses.
|
||||||
|
const unresolvedPot = state => HoldemState.unresolvedContributions(state);
|
||||||
|
|
||||||
|
describe("what starting the next hand destroys", () => {
|
||||||
|
test("the quoted amount is exactly what leaves the table", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
const before = chipsOnTable(state);
|
||||||
|
const quoted = unresolvedPot(state);
|
||||||
|
assert.ok(quoted > 0, "there is a pot to warn about");
|
||||||
|
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
Money.toCents(chipsOnTable(state)),
|
||||||
|
Money.toCents(before - quoted),
|
||||||
|
"the dealer must be told the real number, not an approximation"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chips still in front of players on the current street are counted too", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state); // blinds are `committed`, not yet banked
|
||||||
|
Betting.raiseTo(state, 0, 25); // and so is a raise mid-street
|
||||||
|
|
||||||
|
const before = chipsOnTable(state);
|
||||||
|
const quoted = unresolvedPot(state);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
assert.equal(Money.toCents(chipsOnTable(state)), Money.toCents(before - quoted));
|
||||||
|
assert.ok(quoted >= 25, `a live raise has to be included; quoted ${quoted}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a hand that was paid out properly leaves nothing to warn about", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
const pot = HoldemState.totalPot(state);
|
||||||
|
const payouts = [{ id: state.seats[0].id, label: "Ann", amount: pot }];
|
||||||
|
HoldemState.recordHand(state, payouts);
|
||||||
|
Betting.creditWinnings(state, payouts);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
assert.equal(unresolvedPot(state), 0, "nothing left over after a settled hand");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settling then dealing on conserves every chip", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||||
|
const start = chipsOnTable(state);
|
||||||
|
|
||||||
|
for (let hand = 0; hand < 5; hand++) {
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const live = Betting.liveSeats(state);
|
||||||
|
live.forEach(s => { if (Betting.toCall(state, s.player) > 0) Betting.call(state, s.index); });
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
|
||||||
|
const pot = HoldemState.totalPot(state);
|
||||||
|
const winner = Betting.liveSeats(state)[0];
|
||||||
|
const payouts = [{ id: winner.player.id, label: "w", amount: pot }];
|
||||||
|
HoldemState.recordHand(state, payouts);
|
||||||
|
Betting.creditWinnings(state, payouts);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
assert.equal(chipsOnTable(state), start, `chips drifted on hand ${hand + 1}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty table has no phantom pot to warn about", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [] });
|
||||||
|
assert.equal(unresolvedPot(state), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a standing ante is never warned about, because it isn't lost", () => {
|
||||||
|
// The table default is a stand-in for "everyone puts the same in every hand",
|
||||||
|
// used by a dealer not tracking stacks at all. It re-applies to the next hand
|
||||||
|
// rather than going anywhere, so counting it would nag this dealer before
|
||||||
|
// every single hand — which is why the warning measures the fields
|
||||||
|
// startNextHand actually clears rather than the pot badge.
|
||||||
|
const state = makeTable({ seatCount: 9, seated: [0, 1] });
|
||||||
|
state.basePotAmount = 25;
|
||||||
|
state.seats.forEach(p => { p.amountOverride = null; });
|
||||||
|
|
||||||
|
// The base pot carries into the next hand, so nothing is being thrown away
|
||||||
|
// and the dealer must not be asked to confirm anything.
|
||||||
|
assert.equal(HoldemState.totalPot(state), 50, "the badge still shows a pot");
|
||||||
|
assert.equal(unresolvedPot(state), 0, "but none of it is at risk, so no warning");
|
||||||
|
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
assert.equal(HoldemState.totalPot(state), 50, "the standing ante applies to the new hand too");
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user