Compare commits
214
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd57c16050 | ||
|
|
2a080eab6a | ||
|
|
f93e0935ef | ||
|
|
190ccebf6c | ||
|
|
c052ee32c8 | ||
|
|
1f3dda180f | ||
|
|
3ffce9d7c1 | ||
|
|
5d5093f3cc | ||
|
|
aaf96543a8 | ||
|
|
2e9c55af21 | ||
|
|
8b43614b1c | ||
|
|
42b9e83e8f | ||
|
|
419c1de2a0 | ||
|
|
470fbc0d26 | ||
|
|
c4e842f3e3 | ||
|
|
1d9353c94e | ||
|
|
8616636212 | ||
|
|
6174981cdc | ||
|
|
4db6f6e4d3 | ||
|
|
0ad5592687 | ||
|
|
1c2e716508 | ||
|
|
236c52567e | ||
|
|
1689ecb4f0 | ||
|
|
b8655be258 | ||
|
|
55eb569d3b | ||
|
|
7e420ce747 | ||
|
|
86886ac126 | ||
|
|
c36d7f60bd | ||
|
|
f966d8c340 | ||
|
|
15cec18256 | ||
|
|
0e2821257f | ||
|
|
9c8d981e2d | ||
|
|
c19575ab10 | ||
|
|
907e0165db | ||
|
|
8aa90e90a4 | ||
|
|
86e1570618 | ||
|
|
dc5db16ffd | ||
|
|
6df400322e | ||
|
|
b9b2237dbe | ||
|
|
ca9ac7b6af | ||
|
|
fc866597eb | ||
|
|
80dae56f0d | ||
|
|
b97453fba1 | ||
|
|
6ca99fde6b | ||
|
|
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 | ||
|
|
b2262f8604 | ||
|
|
6ad9923aae | ||
|
|
168e8a380c | ||
|
|
edc08d7213 | ||
|
|
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
|
||||
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
|
||||
run: |
|
||||
install -m 700 -d ~/.ssh
|
||||
@@ -63,7 +69,9 @@ jobs:
|
||||
|
||||
- name: Rsync to server
|
||||
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 }}/
|
||||
|
||||
- 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
|
||||
|
||||
# Learned chess engine weights (runtime training output, not source)
|
||||
chess-data/
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace JoshHeaps.Net.Controllers;
|
||||
|
||||
@@ -15,58 +14,60 @@ public class ChessController(
|
||||
IBackgroundTaskQueue queue,
|
||||
IChessEngineFactory engineFactory,
|
||||
IComputerMoveOrchestrator orchestrator,
|
||||
ILearnedWeightsStore weightsStore,
|
||||
IGameStore gameStore,
|
||||
ISelfPlayCoordinator selfPlay,
|
||||
AutoTrainingSettings autoTraining,
|
||||
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 _multiplayerGameTimeout = TimeSpan.FromDays(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>
|
||||
/// Create a new chess game and store it in-memory.
|
||||
/// </summary>
|
||||
[HttpGet("new")]
|
||||
[HttpGet("new/{difficulty}")]
|
||||
public ActionResult CreateGame(int difficulty = 20)
|
||||
public ActionResult CreateGame(int difficulty = 20, string color = "random")
|
||||
{
|
||||
var gameState = chessService.CreateNewGame();
|
||||
_games[gameState.GameId] = gameState;
|
||||
gameStore.Add(gameState);
|
||||
|
||||
gameState.IsVsComputer = true;
|
||||
gameState.WhiteJoined = true;
|
||||
gameState.BlackJoined = true;
|
||||
Guid playerId = 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)
|
||||
{
|
||||
gameState.WhitePlayerId = playerId;
|
||||
gameState.BlackPlayerId = computerId;
|
||||
gameState.BlackComputer = computer;
|
||||
}
|
||||
else
|
||||
{
|
||||
gameState.WhitePlayerId = computerId;
|
||||
gameState.BlackPlayerId = playerId;
|
||||
gameState.WhiteComputer = computer;
|
||||
queue.Queue(async () =>
|
||||
{
|
||||
// Give user's browser time to connect to signalR and such.
|
||||
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
|
||||
{
|
||||
@@ -77,28 +78,52 @@ public class ChessController(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a game the computer plays against itself and auto-play it move by move,
|
||||
/// broadcasting each move so it can be watched on the spectator page.
|
||||
/// Create a computer-vs-computer game and auto-play it move by move, broadcasting each
|
||||
/// 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>
|
||||
[HttpGet("watch/cpu")]
|
||||
[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();
|
||||
_games[gameState.GameId] = gameState;
|
||||
var config = new SelfPlayConfig(
|
||||
ParseEngineKind(whiteEngine), whiteSkill ?? difficulty,
|
||||
ParseEngineKind(blackEngine), blackSkill ?? difficulty);
|
||||
|
||||
gameState.IsVsComputer = true;
|
||||
gameState.IsComputerVsComputer = true;
|
||||
gameState.WhiteJoined = true;
|
||||
gameState.BlackJoined = true;
|
||||
gameState.WhitePlayerId = Guid.NewGuid();
|
||||
gameState.BlackPlayerId = Guid.NewGuid();
|
||||
gameState.Computer = engineFactory.Create(difficulty);
|
||||
var (gameId, _) = selfPlay.StartGame(config);
|
||||
|
||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||
StartSelfPlay(gameState);
|
||||
return Ok(new { GameId = gameId });
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -110,12 +135,12 @@ public class ChessController(
|
||||
public ActionResult JoinGame()
|
||||
{
|
||||
Console.WriteLine("joining game");
|
||||
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen);
|
||||
GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen);
|
||||
|
||||
if (gameState == null)
|
||||
{
|
||||
gameState = chessService.CreateNewGame();
|
||||
_games[gameState.GameId] = gameState;
|
||||
gameStore.Add(gameState);
|
||||
}
|
||||
|
||||
Guid playerId = Guid.NewGuid();
|
||||
@@ -133,7 +158,7 @@ public class ChessController(
|
||||
isWhite = false;
|
||||
}
|
||||
|
||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -149,7 +174,7 @@ public class ChessController(
|
||||
[HttpGet("active")]
|
||||
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.
|
||||
.Where(g => g.WhiteJoined && g.BlackJoined
|
||||
&& ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer))
|
||||
@@ -158,6 +183,8 @@ public class ChessController(
|
||||
g.GameId,
|
||||
g.IsVsComputer,
|
||||
g.IsComputerVsComputer,
|
||||
WhiteEngine = g.WhiteEngineKind.ToString(),
|
||||
BlackEngine = g.BlackEngineKind.ToString(),
|
||||
CurrentPlayer = g.CurrentPlayer.ToString(),
|
||||
MoveCount = g.MoveHistory.Count,
|
||||
g.IsCheck
|
||||
@@ -167,42 +194,36 @@ public class ChessController(
|
||||
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>
|
||||
/// Get the state of an existing game by ID.
|
||||
/// </summary>
|
||||
[HttpGet("{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");
|
||||
|
||||
var response = new
|
||||
{
|
||||
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);
|
||||
return Ok(gameState.ToDto());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -210,9 +231,9 @@ public class ChessController(
|
||||
/// The test passes a JSON body with a MoveDto.
|
||||
/// </summary>
|
||||
[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");
|
||||
|
||||
// Check if player is authorized to move
|
||||
@@ -239,16 +260,28 @@ public class ChessController(
|
||||
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
|
||||
|
||||
if (isGameOver)
|
||||
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
|
||||
else if (gameState.IsVsComputer)
|
||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||
else
|
||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||
|
||||
if (!isGameOver && gameState.IsVsComputer && gameState.Computer is not null)
|
||||
queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!));
|
||||
var state = gameState.ToDto();
|
||||
|
||||
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>
|
||||
@@ -258,7 +291,7 @@ public class ChessController(
|
||||
[HttpPost("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");
|
||||
|
||||
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
|
||||
@@ -276,7 +309,7 @@ public class ChessController(
|
||||
await chessHub.Clients.Group(gameState.GameId.ToString())
|
||||
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
|
||||
|
||||
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -287,7 +320,7 @@ public class ChessController(
|
||||
[HttpGet("{gameId}/pgn")]
|
||||
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 Content(gameState.ToPgn(), "application/x-chess-pgn");
|
||||
@@ -299,7 +332,7 @@ public class ChessController(
|
||||
[HttpGet("{gameId}/legalMoves/{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");
|
||||
|
||||
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
|
||||
@@ -313,7 +346,7 @@ public class ChessController(
|
||||
[HttpGet("{gameId}/legalMoves")]
|
||||
public ActionResult GetAllLegalMoves(Guid gameId)
|
||||
{
|
||||
if (!_games.TryGetValue(gameId, out var gameState))
|
||||
if (!gameStore.TryGet(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
var allMoves = chessService.GetAllLegalMoves(gameState)
|
||||
@@ -325,75 +358,4 @@ public class ChessController(
|
||||
|
||||
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;
|
||||
|
||||
@@ -11,11 +10,6 @@ public class ChessHub : Hub
|
||||
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)
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -59,7 +60,20 @@ public class GameState
|
||||
public bool IsVsComputer { 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
|
||||
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,679 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.Casino.TexasHoldemModel
|
||||
@{
|
||||
ViewData["Title"] = "Texas Hold'em Dealer";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div id="holdemApp">
|
||||
<header id="holdemHeader">
|
||||
@* Six equally-weighted buttons used to sit here in two rows, and on a 390px
|
||||
phone the felt did not start until 225px down — a quarter of the screen spent
|
||||
before the dealer saw the table. Only two of the six are per-hand controls.
|
||||
The rest have gone where they belong rather than into a "more" menu, which
|
||||
would have relocated the crowding rather than removed it: Reset Table to the
|
||||
Session panel beside Clear history and the save/load pair, Quick Split down
|
||||
with Common rulings where the other "open something else" link already lives,
|
||||
and the way back to the site up top where a back link goes. *@
|
||||
<div id="holdemHeaderLeft">
|
||||
<a href="/casino" class="holdemBackLink">← Casino</a>
|
||||
<h1>Texas Hold'em Dealer</h1>
|
||||
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
|
||||
</div>
|
||||
<div id="holdemHeaderActions">
|
||||
<button id="misdealBtn" class="holdemBtn" title="Take this hand back — every chip returns to the player who put it in, and the same button deals again" hidden>Misdeal</button>
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="holdemLayout">
|
||||
<section id="tableSection">
|
||||
<div id="boardStrip" hidden></div>
|
||||
<div id="pokerTableWrap">
|
||||
<div id="pokerTable">
|
||||
<div id="tableCentre">
|
||||
<div id="boardRow" hidden aria-label="Community cards"></div>
|
||||
<div id="potBadge">
|
||||
<span id="potBadgeLabel">Pot</span>
|
||||
<span id="potBadgeAmount">$0</span>
|
||||
</div>
|
||||
</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>
|
||||
<a href="/casino/texasholdem/quicksplit" class="linkBtn" title="Chop a pot without setting up a table">Quick Split</a>
|
||||
</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>
|
||||
|
||||
@* Not `open` in the markup any more. Whether this panel starts open is a
|
||||
question about the table, so main.js answers it after the state has
|
||||
loaded: open on a table nobody has set up yet, shut on one already
|
||||
half-way through a night. *@
|
||||
<details class="sidebarPanel" id="tableSetupPanel">
|
||||
<summary>Table Setup</summary>
|
||||
<div class="panelBody">
|
||||
<label class="fieldRow">
|
||||
<span>Seats at table</span>
|
||||
<input type="number" id="seatCountInput" min="2" max="24" value="10" />
|
||||
</label>
|
||||
<label class="fieldRow">
|
||||
<span>Blinds move</span>
|
||||
<select id="directionSelect">
|
||||
<option value="clockwise" selected>Clockwise</option>
|
||||
<option value="counterclockwise">Counterclockwise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="fieldRow">
|
||||
<span>Dedicated dealer (not a seat)</span>
|
||||
<input type="checkbox" id="dedicatedDealerToggle" />
|
||||
</label>
|
||||
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
|
||||
<label class="fieldRow">
|
||||
<span>Track the board and read the showdown</span>
|
||||
<input type="checkbox" id="trackBoardToggle" />
|
||||
</label>
|
||||
<p class="fieldHint">Puts the community cards on the felt and asks for them as each street is dealt. Turn the players’ cards over at showdown and Resolve Pot works out who won and how much, instead of asking you.</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>
|
||||
|
||||
<h3 class="panelSubhead">Waiting list</h3>
|
||||
<form id="waitingListForm" class="waitingAdd">
|
||||
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
|
||||
<button type="submit" id="waitingAddBtn" class="holdemBtn holdemBtnSmall">Add</button>
|
||||
</form>
|
||||
<ol id="waitingList" class="waitingList"></ol>
|
||||
<p id="waitingEmptyHint" class="fieldHint">Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.</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>
|
||||
<div class="bankReconcileRow" id="bankPaidOutRow" hidden>
|
||||
<span>Paid out</span>
|
||||
<span id="bankPaidOut"></span>
|
||||
</div>
|
||||
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||
<button type="button" id="bankRecordBuyInsBtn" class="holdemBtn holdemBtnSmall" hidden></button>
|
||||
</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 />
|
||||
@* Down from the header, where it sat in the same weight as Resolve Pot
|
||||
— the most-pressed control in a session next to the one that throws
|
||||
the night away. Here it is beside Clear history, which is the other
|
||||
thing that destroys records, and behind a panel you have to open. *@
|
||||
<button id="resetTableBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Reset table</button>
|
||||
</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>
|
||||
|
||||
@* No aria-live here any more. This element's entire contents are rebuilt on every
|
||||
action, so a live region on it announced the whole bar every time — measured, after
|
||||
one tap: "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
|
||||
Fold Call $10 Raise". That is what a screen reader read out after every single fold.
|
||||
A live region has to be a stable element whose *text* changes; the one below is. *@
|
||||
<div id="actionBar" hidden></div>
|
||||
|
||||
@* Whose turn it is, in a sentence. Empty in the markup and only ever written to, so
|
||||
assistive tech is already watching it when the first update lands — a live region
|
||||
created at the moment it has something to say is one the screen reader misses. *@
|
||||
<div id="actionAnnouncer" role="status" aria-live="polite" class="visuallyHidden"></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>
|
||||
@* The buttons live outside the scrolling body on purpose. This is the one
|
||||
dialog that divides money, and its Confirm sat below a scroll at every screen
|
||||
size — 244px off the bottom on an iPhone, 583 on a phone held sideways. A
|
||||
sticky footer cannot fix that from inside: the actions were the last child of
|
||||
the scrolling content, so their containing block ended exactly where they did
|
||||
and `bottom` had no room to lift them. Out here they simply don't scroll. *@
|
||||
<div id="potWizardFooter"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/casino/holdem.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
|
||||
@* Installable, and works with no signal — see the service worker registration at the
|
||||
foot of main.js. A dealer running a night in somebody's back room is on the worst
|
||||
network there is, and the tool being a *website* was the last thing that could
|
||||
take it away from them mid-game. *@
|
||||
<link rel="manifest" href="/casino/holdem.webmanifest" />
|
||||
@* iOS reads none of the manifest's icons and only this. *@
|
||||
<link rel="apple-touch-icon" href="/images/casino/holdem-apple-touch.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Hold'em" />
|
||||
@* Paints the phone's chrome the same near-black as the felt's ground, so an
|
||||
installed copy doesn't sit under a white status bar. *@
|
||||
<meta name="theme-color" content="#141414" />
|
||||
}
|
||||
|
||||
@* A note for whoever reads the ?v= below and assumes it works: it doesn't. ViewData
|
||||
["cssVersion"] is assigned in _Layout's own code block, which runs after these
|
||||
sections are handed their values, so every one of these renders as a bare "?v=" —
|
||||
on this page and on Chess, Particles and Memory Lane too. It is inert rather than
|
||||
broken: Program.cs sends `Cache-Control: no-store` on every static file, so nothing
|
||||
is being browser-cached for a version string to bust in the first place.
|
||||
Left alone deliberately. Fixing it is a one-line change to a file every page on the
|
||||
site shares, and the service worker was the reason to care — that now uses stale-
|
||||
while-revalidate, which needs no version to stay current. *@
|
||||
@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/handRank.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="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">
|
||||
<!-- Placeholder squares -->
|
||||
@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>
|
||||
</div>
|
||||
|
||||
<div id="textContainer" class="sideContent">
|
||||
<h1>Chess</h1>
|
||||
<p>Click a button to start a game :)</p>
|
||||
</div>
|
||||
<div class="playerBar" id="bottomPlayerBar">
|
||||
<span class="playerDot white"></span>
|
||||
<span class="playerName">You</span>
|
||||
<div class="capturedTray" id="captured-bottom"></div>
|
||||
<span class="advantage" id="advantage-bottom"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="buttonContainer" class="sideContent">
|
||||
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
|
||||
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
|
||||
<button id="copyPgnBtn" onclick="copyPgn()" style="display: none">Copy PGN</button>
|
||||
<a id="watchLink" href="/watch">Watch other games →</a>
|
||||
<aside id="gamePanel">
|
||||
<header class="panelHeader">
|
||||
<span class="panelLogo">♞</span>
|
||||
<h1>Chess</h1>
|
||||
<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>
|
||||
|
||||
@@ -59,19 +89,31 @@
|
||||
</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 {
|
||||
<script src="~/js/signalr/signalr.min.js"></script>
|
||||
<script src="~/js/ChessScripts/GameState.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessUtils.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessAPI.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessSignalR.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessInteractions.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessBoard.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessModals.js"></script>
|
||||
<script src="~/js/ChessScripts/chessMain.js"></script>
|
||||
<script src="~/js/ChessScripts/GameState.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessUtils.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessAPI.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessSignalR.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessInteractions.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessBoard.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessModals.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/chessMain.js?v=@ViewData["cssVersion"]"></script>
|
||||
}
|
||||
|
||||
@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/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='/particles'">Particle Simulator</button>
|
||||
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
|
||||
<button class="demoButton" onclick="window.location.href='/casino'">Casino Games</button>
|
||||
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
|
||||
<button id="MoreFiller" class="demoButton">More coming soon...</button>
|
||||
</div>
|
||||
|
||||
@@ -9,16 +9,43 @@
|
||||
<h1>Live Chess</h1>
|
||||
<p id="watchStatus">Loading games…</p>
|
||||
<div id="watchControls">
|
||||
<label for="cpuDifficulty">Difficulty</label>
|
||||
<select id="cpuDifficulty">
|
||||
@for (int i = 1; i <= 20; i++)
|
||||
{
|
||||
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
||||
}
|
||||
</select>
|
||||
<fieldset class="enginePicker">
|
||||
<legend>White</legend>
|
||||
<select id="whiteEngine">
|
||||
<option value="custom">Custom (v1)</option>
|
||||
<option value="customLearned" selected>Custom (Learned)</option>
|
||||
<option value="stockfish">Stockfish</option>
|
||||
</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>
|
||||
<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>
|
||||
<a id="backToPlay" href="/chess">← Play a game</a>
|
||||
<a id="viewWeights" href="/weights">View learned weights →</a>
|
||||
</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;
|
||||
ViewData["cssVersion"] = "1.0.5"; // <--- change this once to bust cache
|
||||
ViewData["cssVersion"] = "1.0.8"; // <--- change this once to bust cache
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -23,12 +24,23 @@ builder.Services.AddSingleton<IChessService, ChessService>();
|
||||
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
|
||||
|
||||
builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName));
|
||||
builder.Services.AddSingleton<ILearnedWeightsStore, LearnedWeightsStore>();
|
||||
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
|
||||
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
|
||||
builder.Services.AddSingleton<IGameStore, GameStore>();
|
||||
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
|
||||
builder.Services.AddSingleton<AutoTrainingSettings>();
|
||||
|
||||
if (!builder.Environment.IsDevelopment())
|
||||
{
|
||||
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();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
@@ -41,8 +53,16 @@ if (!app.Environment.IsDevelopment())
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// .webmanifest isn't in the default MIME map, and an unknown extension isn't served
|
||||
// with the wrong type — it isn't served at all. Without this the Hold'em tool's
|
||||
// manifest 404s, and a 404 manifest is exactly the silent failure that stops a browser
|
||||
// ever offering "Add to Home Screen": nothing is logged and no prompt appears.
|
||||
var contentTypes = new FileExtensionContentTypeProvider();
|
||||
contentTypes.Mappings[".webmanifest"] = "application/manifest+json";
|
||||
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
ContentTypeProvider = contentTypes,
|
||||
OnPrepareResponse = ctx =>
|
||||
{
|
||||
ctx.Context.Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
|
||||
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 JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
@@ -10,6 +10,7 @@ public class BlogService : IBlogService
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<BlogService> _logger;
|
||||
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
|
||||
private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||
|
||||
@@ -65,7 +66,9 @@ public class BlogService : IBlogService
|
||||
|
||||
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
|
||||
{
|
||||
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > DateTime.UtcNow)
|
||||
_cache.TryGetValue(key, out var entry);
|
||||
|
||||
if (entry is not null && entry.ExpiresAt > DateTime.UtcNow)
|
||||
return (T?)entry.Value;
|
||||
|
||||
var result = await factory();
|
||||
@@ -73,15 +76,26 @@ public class BlogService : IBlogService
|
||||
if (result is not null)
|
||||
{
|
||||
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
|
||||
return result;
|
||||
}
|
||||
else if (entry is not null)
|
||||
|
||||
return entry is null ? null : ServeStale<T>(key, entry);
|
||||
}
|
||||
|
||||
// Stale entries are dropped once the grace window closes so that a dead API fails the same
|
||||
// way for every key. Serving them indefinitely let a cached post list render next to 404s on
|
||||
// the posts themselves, which reads as a site bug rather than an outage.
|
||||
private T? ServeStale<T>(string key, CacheEntry entry) where T : class
|
||||
{
|
||||
if (entry.ExpiresAt.Add(_staleGrace) > DateTime.UtcNow)
|
||||
{
|
||||
// API unreachable — serve stale cache
|
||||
_logger.LogWarning("Serving stale cache for key {Key}", key);
|
||||
_logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key);
|
||||
return (T?)entry.Value;
|
||||
}
|
||||
|
||||
return result;
|
||||
_cache.TryRemove(key, out _);
|
||||
_logger.LogError("Blog API unreachable for over {StaleGrace}, dropping stale cache for key {Key}", _staleGrace, key);
|
||||
return null;
|
||||
}
|
||||
|
||||
public void ClearCache()
|
||||
|
||||
@@ -7,7 +7,10 @@ namespace JoshHeaps.Net.Services.Implementations;
|
||||
public enum ChessEngineKind
|
||||
{
|
||||
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>
|
||||
@@ -16,17 +19,46 @@ public sealed class ChessEngineOptions
|
||||
public const string SectionName = "ChessEngine";
|
||||
|
||||
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>
|
||||
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;
|
||||
|
||||
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.CustomLearned => new CustomChessEngine(skill, EngineVariant.Learned, weightsStore.WeightsFilePath),
|
||||
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>
|
||||
public sealed class ComputerMoveOrchestrator(
|
||||
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 move = uci.ToMoveDto(state, CurrentPlayerId(state));
|
||||
|
||||
var move = uci.ToMoveDto(
|
||||
state,
|
||||
state.CurrentPlayer == PieceColor.White
|
||||
? state.WhitePlayerId
|
||||
: state.BlackPlayerId);
|
||||
return await ApplyAndBroadcastAsync(state, move);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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())
|
||||
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result);
|
||||
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, state.ToDto());
|
||||
|
||||
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;
|
||||
|
||||
/// <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>
|
||||
/// 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.
|
||||
@@ -16,11 +26,17 @@ public sealed partial class CustomChessEngine : IChessEngine
|
||||
|
||||
public int Skill { get; }
|
||||
|
||||
public CustomChessEngine(int skill = 20)
|
||||
public CustomChessEngine(int skill = 20, EngineVariant variant = EngineVariant.Classic, string? weightsPath = null)
|
||||
{
|
||||
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)
|
||||
throw new InvalidOperationException("Native chess engine failed to initialize (engine_create returned null).");
|
||||
@@ -79,8 +95,9 @@ public sealed partial class CustomChessEngine : IChessEngine
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// Internal so <see cref="LearnedWeightsStore"/> can share the single import resolver.
|
||||
/// </summary>
|
||||
private static partial class NativeMethods
|
||||
internal static partial class NativeMethods
|
||||
{
|
||||
private const string LibName = "chess_engine";
|
||||
|
||||
@@ -115,5 +132,31 @@ public sealed partial class CustomChessEngine : IChessEngine
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
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 Stockfish doesn't exit on "quit" (crashed, zombie, ignores stdin close), don't hang
|
||||
// here forever — that would block GameStore's removal task and leave the finished game
|
||||
// stuck in the store, cluttering the watch page indefinitely.
|
||||
private static readonly TimeSpan _exitTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Send("quit");
|
||||
_stdin.Close();
|
||||
await _p.WaitForExitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await _p.WaitForExitAsync().WaitAsync(_exitTimeout);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
if (!_p.HasExited)
|
||||
_p.Kill(entireProcessTree: true);
|
||||
}
|
||||
|
||||
_p.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
@@ -6,9 +8,20 @@ namespace JoshHeaps.Net.Services.Interfaces;
|
||||
public interface IChessEngineFactory
|
||||
{
|
||||
/// <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>
|
||||
/// <param name="skill">The desired playing strength / search depth.</param>
|
||||
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Drives a computer move: asks the engine for a move, applies it through the rules
|
||||
/// service, and broadcasts the result to the game's clients.
|
||||
/// Drives a computer move: asks the side-to-move's engine for a move, applies it through
|
||||
/// the rules service, and broadcasts the result to the game's clients.
|
||||
/// </summary>
|
||||
public interface IComputerMoveOrchestrator
|
||||
{
|
||||
/// <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>
|
||||
/// <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>
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "Texas Hold'em Dealer",
|
||||
"short_name": "Hold'em",
|
||||
"description": "Seat your players, track the action, split the pot. Works with no signal.",
|
||||
"start_url": "/casino/texasholdem",
|
||||
"scope": "/casino/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#141414",
|
||||
"theme_color": "#141414",
|
||||
"categories": ["utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/casino/holdem-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/images/casino/holdem-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/images/casino/holdem-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Keeps the dealer tool working when the network doesn't.
|
||||
//
|
||||
// A home game is the worst network in the world: somebody's back room, one bar of
|
||||
// signal, a router two floors away. The tool already keeps the whole night in
|
||||
// localStorage, so the *table* survives anything — but the page didn't. Reload with no
|
||||
// signal, or open it fresh when the wifi has dropped, and a dealer holding a phone in
|
||||
// one hand and a deck in the other got a browser error page with nine players waiting.
|
||||
//
|
||||
// This is the one thing standing between "a website about poker" and "a tool you can
|
||||
// rely on at a table", so it is worth the moving part.
|
||||
//
|
||||
// Scope is /casino/ rather than /casino/texasholdem/, because a scope has to be a
|
||||
// prefix of the page it controls and "/casino/texasholdem" does not start with
|
||||
// "/casino/texasholdem/". Anything in that scope this worker has not been told about
|
||||
// goes straight to the network, so the other casino pages behave exactly as they did.
|
||||
|
||||
// Bumped whenever the shape of what is cached changes. The asset *version* rides in
|
||||
// the URLs themselves (?v=1.0.8 from _Layout), so a normal release makes new keys
|
||||
// without touching this.
|
||||
const CACHE = "holdem-shell-v1";
|
||||
|
||||
// What this worker will keep. Anything else in scope — a form post, an API call, a
|
||||
// page it was never told about — goes straight to the network and behaves exactly as
|
||||
// it did before the worker existed.
|
||||
const CACHEABLE = new Set(["script", "style", "image", "font", "manifest"]);
|
||||
|
||||
// The page hands over the exact list of URLs it was built from — see registerWorker()
|
||||
// in main.js. Deriving it from the document instead of keeping a list here is the
|
||||
// whole trick: a hand-written manifest of files drifts from the markup the first time
|
||||
// somebody adds a script tag, and the failure mode is a tool that is offline-capable
|
||||
// right up until the release that quietly broke it.
|
||||
async function precache(urls) {
|
||||
const cache = await caches.open(CACHE);
|
||||
// Individually rather than cache.addAll, which rejects the whole batch if any one
|
||||
// URL 404s — one stale entry in the list would leave the dealer with nothing.
|
||||
await Promise.all(urls.map(async url => {
|
||||
try {
|
||||
const res = await fetch(url, { cache: "reload" });
|
||||
if (res.ok) await cache.put(url, res);
|
||||
} catch {
|
||||
// Offline while installing. The runtime handler below picks these up on
|
||||
// the next online visit.
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// No install handler, and deliberately no skipWaiting(). A new worker taking over a
|
||||
// live page can hand it assets from a different release than the ones it started with,
|
||||
// and this page loads seventeen separate scripts that expect to agree with each other.
|
||||
// Waiting costs a dealer nothing — the update lands the next time they open the tool —
|
||||
// and it cannot leave them with a half-swapped app in the middle of a hand.
|
||||
|
||||
self.addEventListener("activate", event => {
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys
|
||||
.filter(key => key.startsWith("holdem-shell-") && key !== CACHE)
|
||||
.map(key => caches.delete(key)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("message", event => {
|
||||
if (event.data && event.data.type === "precache" && Array.isArray(event.data.urls)) {
|
||||
event.waitUntil(precache(event.data.urls));
|
||||
}
|
||||
});
|
||||
|
||||
// Only GETs, and only same-origin. A cross-origin request has opaque responses that
|
||||
// can't be inspected, and caching one would mean serving a failure forever.
|
||||
function handles(request) {
|
||||
if (request.method !== "GET") return false;
|
||||
const url = new URL(request.url);
|
||||
return url.origin === self.location.origin;
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", event => {
|
||||
const { request } = event;
|
||||
if (!handles(request)) return;
|
||||
|
||||
// The page itself: network first, so an online dealer always gets the current
|
||||
// build, and the cached copy is there the moment they aren't.
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith((async () => {
|
||||
try {
|
||||
const fresh = await fetch(request);
|
||||
if (fresh.ok) (await caches.open(CACHE)).put(request, fresh.clone());
|
||||
return fresh;
|
||||
} catch {
|
||||
const cached = await caches.match(request, { ignoreSearch: true });
|
||||
if (cached) return cached;
|
||||
throw new Error("offline and this page was never cached");
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
|
||||
// Assets: stale while revalidate. The cached copy answers immediately — which is
|
||||
// the whole point, a dealer opening this between hands should not wait for a
|
||||
// network — and a fresh copy is fetched in the background for next time.
|
||||
//
|
||||
// Cache-first would be the obvious choice, on the reasoning that these URLs carry
|
||||
// ?v= from the layout and so a hit is always the right file. They don't: the
|
||||
// version renders empty (see the note in TexasHoldem.cshtml), so every release
|
||||
// reuses the same URLs. Cache-first against unversioned URLs is how a tool gets
|
||||
// frozen on a build from three months ago with no way for anyone to tell. This
|
||||
// strategy needs no version to be correct: worst case a dealer is one launch
|
||||
// behind, which for a tool that runs entirely in the browser is nothing.
|
||||
if (!CACHEABLE.has(request.destination)) return;
|
||||
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
const fresh = fetch(request).then(res => {
|
||||
if (res.ok) cache.put(request, res.clone());
|
||||
return res;
|
||||
}).catch(() => null);
|
||||
|
||||
if (cached) return cached;
|
||||
const res = await fresh;
|
||||
if (res) return res;
|
||||
throw new Error("offline and not cached: " + request.url);
|
||||
})());
|
||||
});
|
||||
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 {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
margin: 2vw auto;
|
||||
}
|
||||
|
||||
#chessBoard {
|
||||
width: 60vw;
|
||||
height: 60vw;
|
||||
#chessBoard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
grid-template-rows: repeat(8, 1fr);
|
||||
@@ -165,6 +157,47 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
font-size: 1.2vw;
|
||||
|
||||
@@ -1,159 +1,429 @@
|
||||
html, body {
|
||||
background-color: #2b2c30;
|
||||
color: #d6d6d6;
|
||||
cursor: default;
|
||||
:root {
|
||||
--bg: #2b2c30;
|
||||
--panel: #21232a;
|
||||
--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%;
|
||||
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 {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border-radius: 30px;
|
||||
border: 0px;
|
||||
cursor: pointer;
|
||||
order: 1;
|
||||
padding: 2vh;
|
||||
margin: 2vh;
|
||||
main {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#startCPUGame {
|
||||
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;
|
||||
}
|
||||
/* ---- Layout ------------------------------------------------------------ */
|
||||
|
||||
/* Portrait / narrow: board on top, panel stacked below. */
|
||||
#chessContainer {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 5vw;
|
||||
gap: clamp(8px, 1.6vh, 16px);
|
||||
padding: clamp(10px, 2vh, 20px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#boardArea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
width: min(94vw, 56vh);
|
||||
}
|
||||
|
||||
#chessBoard {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 90vw; /* use the smaller of width or height */
|
||||
width: 100%;
|
||||
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 {
|
||||
align-content: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.sideContent {
|
||||
#gamePanel {
|
||||
width: min(94vw, 56vh);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
#textContainer {
|
||||
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 */
|
||||
/* Landscape / wide: board left, game panel right (chess.com style). */
|
||||
@media (min-aspect-ratio: 1/1) {
|
||||
#chessContainer {
|
||||
display: grid;
|
||||
grid-template-columns: auto, auto;
|
||||
grid-template-rows: auto, auto;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
padding: 0;
|
||||
gap: clamp(16px, 3vw, 48px);
|
||||
padding: clamp(12px, 3vh, 28px);
|
||||
}
|
||||
|
||||
.sideContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 0;
|
||||
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;
|
||||
#boardArea {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
#chessBoard {
|
||||
aspect-ratio: 1 / 1;
|
||||
flex-shrink: 1;
|
||||
width: min(90vh, 90vw);
|
||||
max-height: 90vh;
|
||||
width: min(72vh, 54vw);
|
||||
height: min(72vh, 54vw);
|
||||
}
|
||||
|
||||
#difficultyButtonContainer {
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
.playerBar {
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
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;
|
||||
color: #d6d6d6;
|
||||
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;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -4,8 +4,8 @@ const ChessAPI = {
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async createCPUGame(difficulty) {
|
||||
const response = await fetch(`/api/chess/new/${difficulty}`);
|
||||
async createCPUGame(difficulty, color = "random") {
|
||||
const response = await fetch(`/api/chess/new/${difficulty}?color=${color}`);
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
@@ -46,13 +46,15 @@ const ChessAPI = {
|
||||
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) {
|
||||
throw new Error(result.message || "Invalid move or not your turn.");
|
||||
if (!data.result.success) {
|
||||
throw new Error(data.result.message || "Invalid move or not your turn.");
|
||||
}
|
||||
|
||||
return result;
|
||||
return data;
|
||||
},
|
||||
|
||||
async handleMove(targetRow, targetCol) {
|
||||
@@ -85,16 +87,14 @@ const ChessAPI = {
|
||||
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
|
||||
|
||||
try {
|
||||
const moveResult = await this.makeMove(moveDto);
|
||||
const { result, state } = await this.makeMove(moveDto);
|
||||
|
||||
const updatedGame = await this.getGameState(GameState.currentGameId);
|
||||
ChessBoard.renderPieces(updatedGame.pieces);
|
||||
ChessBoard.renderState(state);
|
||||
|
||||
GameState.clearSelection();
|
||||
ChessInteractions.clearHighlights();
|
||||
|
||||
await ChessSignalR.notifyMoveMade(moveDto, moveResult);
|
||||
this.alertGameStatusChange(moveResult);
|
||||
this.alertGameStatusChange(result);
|
||||
|
||||
} catch (error) {
|
||||
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 = {
|
||||
// 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) {
|
||||
this.clearAllSquares();
|
||||
this.renderCoordinateLabels();
|
||||
@@ -7,6 +94,48 @@ const ChessBoard = {
|
||||
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() {
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const square = document.getElementById(`square-${i}`);
|
||||
|
||||
@@ -23,6 +23,16 @@ const ChessModals = {
|
||||
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);
|
||||
});
|
||||
|
||||
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto) => {
|
||||
await this.handleMoveUpdate(gameId, moveDto, moveResultDto);
|
||||
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto, state) => {
|
||||
await this.handleMoveUpdate(gameId, moveDto, moveResultDto, state);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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]);
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(state);
|
||||
|
||||
ChessAPI.alertGameStatusChange(moveResultDto);
|
||||
},
|
||||
@@ -50,12 +52,6 @@ const ChessSignalR = {
|
||||
await this.leaveGame();
|
||||
},
|
||||
|
||||
async notifyMoveMade(moveDto, moveResult) {
|
||||
if (this.connection) {
|
||||
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
|
||||
}
|
||||
},
|
||||
|
||||
async leaveGame() {
|
||||
if (this.connection) {
|
||||
await this.connection.invoke("LeaveWebsocketGroup", GameState.currentGameId);
|
||||
|
||||
@@ -6,6 +6,10 @@ const GameState = {
|
||||
legalMoves: [],
|
||||
previousMoveStart: 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() {
|
||||
this.currentGameId = null;
|
||||
@@ -15,12 +19,22 @@ const GameState = {
|
||||
this.legalMoves = [];
|
||||
this.previousMoveStart = 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) {
|
||||
this.currentGameId = gameId;
|
||||
this.currentPlayerId = playerId;
|
||||
this.currentPlayerIsWhite = isWhite;
|
||||
this.lastVersion = -1;
|
||||
},
|
||||
|
||||
setSelectedPiece(piece) {
|
||||
|
||||
@@ -11,8 +11,8 @@ const Spectate = {
|
||||
.configureLogging(signalR.LogLevel.Warning)
|
||||
.build();
|
||||
|
||||
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto) =>
|
||||
this.handleMoveUpdate(gameId, moveDto));
|
||||
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto, _moveResult, state) =>
|
||||
this.handleMoveUpdate(gameId, moveDto, state));
|
||||
|
||||
this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId));
|
||||
|
||||
@@ -23,14 +23,50 @@ const Spectate = {
|
||||
}
|
||||
|
||||
await this.refreshGames();
|
||||
await this.loadAutoTrainCount();
|
||||
setInterval(() => this.refreshGames(), 5000);
|
||||
},
|
||||
|
||||
async startCpuGame() {
|
||||
const difficulty = document.getElementById("cpuDifficulty").value;
|
||||
// Auto-training runs server-side; show its target count and let it be changed here.
|
||||
async loadAutoTrainCount() {
|
||||
const input = document.getElementById("autoTrainCount");
|
||||
if (!input) return;
|
||||
|
||||
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();
|
||||
} catch (err) {
|
||||
console.error("❌ Could not start CPU vs CPU game.", err);
|
||||
@@ -68,7 +104,9 @@ const Spectate = {
|
||||
async addGame(game) {
|
||||
this.games.set(game.gameId, {
|
||||
isVsComputer: game.isVsComputer,
|
||||
isComputerVsComputer: game.isComputerVsComputer
|
||||
isComputerVsComputer: game.isComputerVsComputer,
|
||||
whiteEngine: game.whiteEngine,
|
||||
blackEngine: game.blackEngine
|
||||
});
|
||||
|
||||
const card = document.createElement("div");
|
||||
@@ -117,7 +155,10 @@ const Spectate = {
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const state = await response.json();
|
||||
this.renderFromState(gameId, await response.json());
|
||||
},
|
||||
|
||||
renderFromState(gameId, state) {
|
||||
const result = this.resultTextFromState(state);
|
||||
const stored = this.games.get(gameId);
|
||||
|
||||
@@ -128,10 +169,13 @@ const Spectate = {
|
||||
this.setResult(gameId, result);
|
||||
},
|
||||
|
||||
async handleMoveUpdate(gameId, moveDto) {
|
||||
async handleMoveUpdate(gameId, moveDto, state) {
|
||||
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);
|
||||
},
|
||||
|
||||
@@ -177,11 +221,21 @@ const Spectate = {
|
||||
},
|
||||
|
||||
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";
|
||||
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) {
|
||||
if (stored.result)
|
||||
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.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() {
|
||||
const gameId = ChessUtils.getCookie("chessGameId");
|
||||
const playerId = ChessUtils.getCookie("chessPlayerId");
|
||||
@@ -44,6 +56,7 @@ async function forfeitCurrentGame() {
|
||||
}
|
||||
|
||||
async function startNewGame() {
|
||||
closeMenu();
|
||||
await ChessSignalR.stopConnection();
|
||||
await forfeitCurrentGame();
|
||||
resetCopyPgn();
|
||||
@@ -63,7 +76,7 @@ async function startNewGame() {
|
||||
await ChessSignalR.setupConnection();
|
||||
|
||||
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(gameState);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to start new game:", error);
|
||||
@@ -71,13 +84,15 @@ async function startNewGame() {
|
||||
}
|
||||
|
||||
async function startCPUGame() {
|
||||
closeMenu();
|
||||
await ChessSignalR.stopConnection();
|
||||
await forfeitCurrentGame();
|
||||
resetCopyPgn();
|
||||
|
||||
try {
|
||||
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.clearPreviousMove();
|
||||
@@ -91,7 +106,7 @@ async function startCPUGame() {
|
||||
await ChessSignalR.setupConnection();
|
||||
|
||||
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(gameState);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to start CPU game:", error);
|
||||
@@ -111,7 +126,7 @@ async function resumeSavedGame() {
|
||||
GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true");
|
||||
|
||||
await ChessSignalR.setupConnection();
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(gameState);
|
||||
|
||||
} catch (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,801 @@
|
||||
// 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"],
|
||||
|
||||
// How many community cards are face up once a street has been dealt. The flop is
|
||||
// three at once and everything after it is one at a time, which is why this is a
|
||||
// table rather than an index.
|
||||
BOARD_CARDS: { preflop: 0, flop: 3, turn: 4, river: 5 },
|
||||
|
||||
boardCardsFor(street) {
|
||||
return this.BOARD_CARDS[street] ?? 0;
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
// Are the blinds actually down? Moving the button or naming a blind means two
|
||||
// completely different things depending on the answer, and `onlyBlindsIn` alone
|
||||
// cannot tell them apart: a table sitting in setup, with no cards out and nothing
|
||||
// committed, satisfies it just as well as a hand that has only just been dealt.
|
||||
//
|
||||
// With a hand open, the blinds are chips on the felt. Re-aiming them has to take
|
||||
// those chips back and post them again from the new positions, or the badges and
|
||||
// the money disagree.
|
||||
//
|
||||
// Before the deal it is a marker being slid round a table with nothing in the
|
||||
// middle. Re-posting there would *deal the hand* — betting opened, blinds taken off
|
||||
// two stacks, action on the seat after the big blind — which is not what a dealer
|
||||
// means by "she's the big blind this one". Setting the positions is how a dealer
|
||||
// gets ready to deal; the cards come out when they press the button, and not a
|
||||
// moment before.
|
||||
blindsArePosted(state) {
|
||||
return !!state.bettingOpen && this.onlyBlindsIn(state);
|
||||
},
|
||||
|
||||
// Chips the pot has been priced with but nobody has actually paid.
|
||||
//
|
||||
// Two ways a contribution gets into this tool. Tapping the action moves chips: the
|
||||
// bet leaves the stack, sits in front of the player, and is swept into their hand
|
||||
// total at the end of the street. The other way is the "without live betting" panel
|
||||
// — a base pot amount, or chips counted out on a seat — where the dealer is
|
||||
// recording a hand they are watching rather than driving, and nothing ever leaves
|
||||
// anybody's stack.
|
||||
//
|
||||
// The pot wizard prices both the same way, and awarding it credits the winner. So
|
||||
// in that second mode the table simply grew: measured at a three-handed table with
|
||||
// a $50 base pot, paying the pot took the chips on the table from $600 to $750 and
|
||||
// left the bank check blaming a rebuy that never happened.
|
||||
//
|
||||
// So before the pot is pushed, whatever was assumed rather than taken comes off the
|
||||
// stacks that assumed it. Chips already committed or swept are left alone; they
|
||||
// have been paid once already, and taking them twice is the same bug the other way
|
||||
// round.
|
||||
collectAssumedContributions(state) {
|
||||
const taken = [];
|
||||
state.seats.forEach((player, index) => {
|
||||
const priced = HoldemState.computeContribution(player, state.chipLegend, state.basePotAmount);
|
||||
const alreadyPaid = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
|
||||
const owed = this.round(priced - alreadyPaid);
|
||||
if (owed <= this.EPSILON_CHIPS) return;
|
||||
|
||||
// Never more than they are holding. A seat priced above its own stack is a
|
||||
// dealer's typo or a player who is short, and inventing the difference is
|
||||
// what this whole function exists to stop.
|
||||
const moved = this.round(Math.min(owed, this.stackOf(state, player)));
|
||||
if (moved <= this.EPSILON_CHIPS) return;
|
||||
|
||||
player.stack = this.round(this.stackOf(state, player) - moved);
|
||||
// Recorded where a swept bet lives, so the money is in exactly the place
|
||||
// the rest of the tool looks for chips that are in the middle this hand.
|
||||
player.amountOverride = this.round((Number(player.amountOverride) || 0) + moved);
|
||||
taken.push({ index, name: HoldemState.displayName(player, index), amount: moved });
|
||||
});
|
||||
return { taken, total: this.round(taken.reduce((sum, row) => sum + row.amount, 0)) };
|
||||
},
|
||||
|
||||
// A misdeal: the hand never happened, so every chip goes back where it came from.
|
||||
//
|
||||
// The rulings sheet in this tool has told dealers to redeal since the day it was
|
||||
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one
|
||||
// of the first two cards exposed — and the tool had no way to do it. The only
|
||||
// control that ended a live hand was Next Hand, which says out loud that it takes
|
||||
// the money off the table without paying anybody. That is right for an abandoned
|
||||
// pot and wrong for a misdeal, where the blinds and any calls belong to the people
|
||||
// who put them in.
|
||||
//
|
||||
// Undo covers the first second of it — "↶ dealing the hand" is one tap — but only
|
||||
// while the action history is still there. It lives in memory, so a dealer who
|
||||
// refreshes the page mid-hand, or who is three calls in, has nothing.
|
||||
//
|
||||
// What is deliberately kept: the button, the blinds, the straddle and anyone
|
||||
// posting in. A misdeal is the *same* hand dealt again, not the next one.
|
||||
takeHandBack(state) {
|
||||
const returned = [];
|
||||
state.seats.forEach((player, index) => {
|
||||
const back = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
|
||||
if (back > 0) {
|
||||
player.stack = this.round(this.stackOf(state, player) + back);
|
||||
returned.push({ index, name: HoldemState.displayName(player, index), amount: back });
|
||||
}
|
||||
player.committed = 0;
|
||||
player.amountOverride = null;
|
||||
player.hasActed = false;
|
||||
player.folded = false;
|
||||
player.cards = ["", ""];
|
||||
});
|
||||
|
||||
state.bettingOpen = false;
|
||||
state.street = "preflop";
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
state.actingSeatIndex = null;
|
||||
state.streetAggressorIndex = null;
|
||||
state.handAggressorIndex = null;
|
||||
HoldemState.clearBoard(state);
|
||||
// Nothing was posted, as far as the next rotation is concerned: the hand this
|
||||
// would have counted as is being dealt again from the same button.
|
||||
state.postedBlinds = null;
|
||||
state.postedStraddles = [];
|
||||
|
||||
return {
|
||||
returned,
|
||||
total: this.round(returned.reduce((sum, row) => sum + row.amount, 0)),
|
||||
};
|
||||
},
|
||||
|
||||
// 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;
|
||||
// A fresh deal starts on an empty felt. Dealing without moving the button —
|
||||
// after a misdeal, or a table set up and dealt twice — would otherwise leave
|
||||
// the last hand's board sitting there being ranked against the new cards.
|
||||
HoldemState.clearBoard(state);
|
||||
// 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,195 @@
|
||||
// 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,
|
||||
// Which set of slots is being filled: a seat's two hole cards, or the five
|
||||
// community cards. The grid, the duplicate check and the tap-to-fill behaviour are
|
||||
// the same job either way — what changes is where the card is written and how many
|
||||
// slots there are to walk along.
|
||||
target: null,
|
||||
seatIndex: null,
|
||||
cardIndex: 0,
|
||||
|
||||
open(state, hooks, seatIndex, cardIndex) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.target = "seat";
|
||||
this.seatIndex = seatIndex;
|
||||
this.cardIndex = cardIndex;
|
||||
this.render();
|
||||
document.getElementById("cardPickerOverlay").hidden = false;
|
||||
},
|
||||
|
||||
// The board. `limit` is how many of the five are actually out — three on the flop,
|
||||
// four on the turn — so the sheet never offers a slot for a card the dealer has not
|
||||
// turned over yet.
|
||||
openBoard(state, hooks, cardIndex, limit = 5) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.target = "board";
|
||||
this.seatIndex = null;
|
||||
this.boardLimit = Math.max(1, Math.min(5, limit));
|
||||
this.cardIndex = Math.min(cardIndex, this.boardLimit - 1);
|
||||
this.render();
|
||||
document.getElementById("cardPickerOverlay").hidden = false;
|
||||
},
|
||||
|
||||
onBoard() {
|
||||
return this.target === "board";
|
||||
},
|
||||
|
||||
// The slots the sheet is filling, whichever set that is.
|
||||
slotValues() {
|
||||
return this.onBoard()
|
||||
? (this.state.board || []).slice(0, this.boardLimit)
|
||||
: this.state.seats[this.seatIndex].cards;
|
||||
},
|
||||
|
||||
write(index, card) {
|
||||
if (this.onBoard()) {
|
||||
if (!Array.isArray(this.state.board)) this.state.board = ["", "", "", "", ""];
|
||||
this.state.board[index] = card;
|
||||
return;
|
||||
}
|
||||
this.state.seats[this.seatIndex].cards[index] = card;
|
||||
},
|
||||
|
||||
close() {
|
||||
document.getElementById("cardPickerOverlay").hidden = true;
|
||||
},
|
||||
|
||||
// Every card already dealt somewhere — in front of a player or face up in the
|
||||
// middle. The slot being edited is excluded, so re-picking the card that's already
|
||||
// there isn't treated as a duplicate.
|
||||
//
|
||||
// The board belongs in here as much as the hands do, and in both directions: the
|
||||
// seven of clubs cannot be on the flop and in somebody's hand, whichever of the two
|
||||
// the dealer is filling in at the time.
|
||||
cardsInUse() {
|
||||
const used = new Map();
|
||||
this.state.seats.forEach((player, seat) => {
|
||||
(player.cards || []).forEach((card, slot) => {
|
||||
if (!card) return;
|
||||
if (!this.onBoard() && seat === this.seatIndex && slot === this.cardIndex) return;
|
||||
used.set(card.toUpperCase(), HoldemState.displayName(player, seat));
|
||||
});
|
||||
});
|
||||
(this.state.board || []).forEach((card, slot) => {
|
||||
if (!card) return;
|
||||
if (this.onBoard() && slot === this.cardIndex) return;
|
||||
used.set(card.toUpperCase(), "the board");
|
||||
});
|
||||
return used;
|
||||
},
|
||||
|
||||
assign(card) {
|
||||
this.write(this.cardIndex, card);
|
||||
this.hooks.onPick(this.seatIndex, this.cardIndex, card);
|
||||
|
||||
// Straight on to the next empty slot — a dealer picking hole cards is always
|
||||
// picking two and a flop is always three, and making them re-open the picker
|
||||
// between each one multiplies the taps by the only thing the sheet is for.
|
||||
const values = this.slotValues();
|
||||
const next = values.findIndex((value, i) => i > this.cardIndex && !value);
|
||||
if (next !== -1) {
|
||||
this.cardIndex = next;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
},
|
||||
|
||||
clearSlot() {
|
||||
this.write(this.cardIndex, "");
|
||||
this.hooks.onPick(this.seatIndex, this.cardIndex, "");
|
||||
this.render();
|
||||
},
|
||||
|
||||
// What this slot is called out loud: the flop's three cards, then the turn, then
|
||||
// the river — which is what a dealer is holding when they open this, rather than
|
||||
// "card 4".
|
||||
boardSlotLabel(slot) {
|
||||
return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
|
||||
},
|
||||
|
||||
render() {
|
||||
const used = this.cardsInUse();
|
||||
const values = this.slotValues();
|
||||
|
||||
document.getElementById("cardPickerTitle").textContent = this.onBoard()
|
||||
? `Board — ${this.boardSlotLabel(this.cardIndex).toLowerCase()}`
|
||||
: `${HoldemState.displayName(this.state.seats[this.seatIndex], this.seatIndex)} — card ${this.cardIndex + 1}`;
|
||||
|
||||
// Which slot the next tap fills, switchable without closing the sheet.
|
||||
const slots = document.getElementById("cardPickerSlots");
|
||||
slots.innerHTML = "";
|
||||
values.forEach((_, slot) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pickerSlot";
|
||||
btn.classList.toggle("active", slot === this.cardIndex);
|
||||
const value = values[slot] || "";
|
||||
btn.textContent = value || "—";
|
||||
if (this.onBoard()) btn.title = this.boardSlotLabel(slot);
|
||||
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 = takenBy === "the board"
|
||||
? "Already face up on the board"
|
||||
: `Already dealt to ${takenBy}`;
|
||||
}
|
||||
if ((values[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,130 @@
|
||||
// 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([
|
||||
// Somebody who has gone home is marked in the file the same way the
|
||||
// panel marks them, because this is the copy that gets read at the
|
||||
// end of the night when nobody remembers who has already been paid.
|
||||
row.cashedOut ? `${row.name} (paid out)` : 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,285 @@
|
||||
// What beats what.
|
||||
//
|
||||
// The dealer has always been the one who decides who won: the pot wizard asks "who won
|
||||
// the hand?" and pays whoever gets ticked. That is the right default — most hands are
|
||||
// won without a showdown, and at a home game the table reads the cards out loud between
|
||||
// them. But when the board is on the felt and the hands are on their backs, the tool
|
||||
// knows the answer, and reading it off a screen beats three people counting kickers.
|
||||
//
|
||||
// Cards are the same two-character strings the rest of the tool has always stored:
|
||||
// a rank from `RANKS` and a suit from S, H, D, C. Case is not significant.
|
||||
//
|
||||
// Nothing in here touches the DOM, the table, or money. It compares cards, and the pot
|
||||
// wizard decides what to do about it.
|
||||
const HandRank = {
|
||||
RANKS: "23456789TJQKA",
|
||||
|
||||
// High to low. The number is the hand's category and nothing else — kickers and
|
||||
// the rest of the tie-breaking live in the `tiebreak` array beside it.
|
||||
STRAIGHT_FLUSH: 8,
|
||||
FOUR_OF_A_KIND: 7,
|
||||
FULL_HOUSE: 6,
|
||||
FLUSH: 5,
|
||||
STRAIGHT: 4,
|
||||
THREE_OF_A_KIND: 3,
|
||||
TWO_PAIR: 2,
|
||||
ONE_PAIR: 1,
|
||||
HIGH_CARD: 0,
|
||||
|
||||
// 2 through 14. Null for anything that isn't a card, which is how a half-filled
|
||||
// slot gets told apart from a bad one.
|
||||
rankValue(card) {
|
||||
if (typeof card !== "string" || card.length < 2) return null;
|
||||
const index = this.RANKS.indexOf(card[0].toUpperCase());
|
||||
return index < 0 ? null : index + 2;
|
||||
},
|
||||
|
||||
suitOf(card) {
|
||||
if (typeof card !== "string" || card.length < 2) return null;
|
||||
const suit = card[card.length - 1].toUpperCase();
|
||||
return "SHDC".includes(suit) ? suit : null;
|
||||
},
|
||||
|
||||
isCard(card) {
|
||||
return this.rankValue(card) !== null && this.suitOf(card) !== null;
|
||||
},
|
||||
|
||||
// Singular and plural, for reading a hand out loud.
|
||||
NAMES: {
|
||||
14: ["ace", "aces"], 13: ["king", "kings"], 12: ["queen", "queens"],
|
||||
11: ["jack", "jacks"], 10: ["ten", "tens"], 9: ["nine", "nines"],
|
||||
8: ["eight", "eights"], 7: ["seven", "sevens"], 6: ["six", "sixes"],
|
||||
5: ["five", "fives"], 4: ["four", "fours"], 3: ["three", "threes"],
|
||||
2: ["two", "twos"],
|
||||
},
|
||||
|
||||
name(rank, plural = false) {
|
||||
const pair = this.NAMES[rank];
|
||||
return pair ? pair[plural ? 1 : 0] : "?";
|
||||
},
|
||||
|
||||
// ---- The five-card hand -------------------------------------------------------
|
||||
|
||||
// Exactly five cards, ranked. Everything else is built on this.
|
||||
//
|
||||
// Five rather than seven because five is the hand a player actually holds, and it
|
||||
// is small enough to check by hand — which is the whole argument for doing it this
|
||||
// way. The seven-card version below tries all twenty-one five-card hands and keeps
|
||||
// the best, which is a hand of poker's own definition of "your best five" written
|
||||
// out literally. A dealer resolves one pot a hand with at most a dozen players in
|
||||
// it; two hundred and fifty comparisons of five cards is not a cost worth trading
|
||||
// a subtler algorithm's correctness for.
|
||||
evaluate5(cards) {
|
||||
if (!Array.isArray(cards) || cards.length !== 5) return null;
|
||||
if (!cards.every(card => this.isCard(card))) return null;
|
||||
|
||||
const ranks = cards.map(card => this.rankValue(card)).sort((a, b) => b - a);
|
||||
const suits = cards.map(card => this.suitOf(card));
|
||||
const flush = suits.every(suit => suit === suits[0]);
|
||||
|
||||
// How many of each rank, ordered by how many first and by rank second — so
|
||||
// the front of this list is always the rank that decides the hand.
|
||||
const counts = new Map();
|
||||
ranks.forEach(rank => counts.set(rank, (counts.get(rank) || 0) + 1));
|
||||
const groups = [...counts.entries()]
|
||||
.sort((a, b) => (b[1] - a[1]) || (b[0] - a[0]));
|
||||
const shape = groups.map(([, count]) => count);
|
||||
const byGroup = groups.map(([rank]) => rank);
|
||||
|
||||
const straightHigh = this.straightHigh(ranks);
|
||||
|
||||
const made = (category, tiebreak) => ({ category, tiebreak, cards: [...cards] });
|
||||
|
||||
if (flush && straightHigh) return made(this.STRAIGHT_FLUSH, [straightHigh]);
|
||||
if (shape[0] === 4) return made(this.FOUR_OF_A_KIND, byGroup);
|
||||
if (shape[0] === 3 && shape[1] === 2) return made(this.FULL_HOUSE, byGroup);
|
||||
if (flush) return made(this.FLUSH, ranks);
|
||||
if (straightHigh) return made(this.STRAIGHT, [straightHigh]);
|
||||
if (shape[0] === 3) return made(this.THREE_OF_A_KIND, byGroup);
|
||||
if (shape[0] === 2 && shape[1] === 2) return made(this.TWO_PAIR, byGroup);
|
||||
if (shape[0] === 2) return made(this.ONE_PAIR, byGroup);
|
||||
return made(this.HIGH_CARD, ranks);
|
||||
},
|
||||
|
||||
// The top card of the straight these five ranks make, or null.
|
||||
//
|
||||
// The wheel is the exception the rest of the ordering has to be told about: A-2-3-4-5
|
||||
// is a straight, and it is the *lowest* one, so its ace counts as five rather than
|
||||
// as fourteen. Getting that wrong is how A-2-3-4-5 comes to beat 10-J-Q-K-A.
|
||||
straightHigh(sortedDesc) {
|
||||
const distinct = [...new Set(sortedDesc)];
|
||||
if (distinct.length !== 5) return null;
|
||||
if (distinct[0] - distinct[4] === 4) return distinct[0];
|
||||
// Ace, five, four, three, two.
|
||||
if (distinct[0] === 14 && distinct[1] === 5 && distinct[4] === 2) return 5;
|
||||
return null;
|
||||
},
|
||||
|
||||
// ---- The best five out of however many ----------------------------------------
|
||||
|
||||
// The best five-card hand available from five, six or seven cards — hole cards and
|
||||
// board together, which is what Hold'em asks for. Returns null if there aren't five
|
||||
// real cards, which is the ordinary state of a hand still being dealt.
|
||||
best(cards) {
|
||||
const real = (cards || []).filter(card => this.isCard(card));
|
||||
if (real.length < 5) return null;
|
||||
// A card in two places at once is a mis-entry, not a hand — say so rather than
|
||||
// ranking a board that has the same king on it twice.
|
||||
if (new Set(real.map(card => card.toUpperCase())).size !== real.length) return null;
|
||||
|
||||
let best = null;
|
||||
this.eachFive(real, five => {
|
||||
const hand = this.evaluate5(five);
|
||||
if (!best || this.compare(hand, best) > 0) best = hand;
|
||||
});
|
||||
return best;
|
||||
},
|
||||
|
||||
// Every five-card subset, without building an array of them.
|
||||
eachFive(cards, visit) {
|
||||
const n = cards.length;
|
||||
for (let a = 0; a < n - 4; a++) {
|
||||
for (let b = a + 1; b < n - 3; b++) {
|
||||
for (let c = b + 1; c < n - 2; c++) {
|
||||
for (let d = c + 1; d < n - 1; d++) {
|
||||
for (let e = d + 1; e < n; e++) {
|
||||
visit([cards[a], cards[b], cards[c], cards[d], cards[e]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Positive if `a` wins, negative if `b` does, zero for a chop. A chop is a real
|
||||
// answer here rather than a rounding of one: two players playing the same board
|
||||
// split the pot, and the fifth card decides it far more often than a table expects.
|
||||
compare(a, b) {
|
||||
if (!a && !b) return 0;
|
||||
if (!a) return -1;
|
||||
if (!b) return 1;
|
||||
if (a.category !== b.category) return a.category - b.category;
|
||||
const length = Math.max(a.tiebreak.length, b.tiebreak.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const diff = (a.tiebreak[i] || 0) - (b.tiebreak[i] || 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
|
||||
// ---- Saying it out loud -------------------------------------------------------
|
||||
|
||||
// What a dealer says when they turn the hand over. Named to the level that settles
|
||||
// it and no further: "two pair, aces and eights" is the call, and the kicker only
|
||||
// gets a mention where the tool has had to use one to break a tie.
|
||||
describe(hand) {
|
||||
if (!hand) return "";
|
||||
const [first, second] = hand.tiebreak;
|
||||
switch (hand.category) {
|
||||
case this.STRAIGHT_FLUSH:
|
||||
return first === 14 ? "Royal flush" : `Straight flush, ${this.name(first)} high`;
|
||||
case this.FOUR_OF_A_KIND:
|
||||
return `Four of a kind, ${this.name(first, true)}`;
|
||||
case this.FULL_HOUSE:
|
||||
return `Full house, ${this.name(first, true)} full of ${this.name(second, true)}`;
|
||||
case this.FLUSH:
|
||||
return `Flush, ${this.name(first)} high`;
|
||||
case this.STRAIGHT:
|
||||
return `Straight, ${this.name(first)} high`;
|
||||
case this.THREE_OF_A_KIND:
|
||||
return `Three of a kind, ${this.name(first, true)}`;
|
||||
case this.TWO_PAIR:
|
||||
return `Two pair, ${this.name(first, true)} and ${this.name(second, true)}`;
|
||||
case this.ONE_PAIR:
|
||||
return `Pair of ${this.name(first, true)}`;
|
||||
default:
|
||||
return `${this.name(first).charAt(0).toUpperCase()}${this.name(first).slice(1)} high`;
|
||||
}
|
||||
},
|
||||
|
||||
// The same call with the article a table would put in front of it. Not derivable
|
||||
// from the first letter: it is "a full house" and "a flush", but "four of a kind"
|
||||
// and "two pair" take none at all, and "an ace high" is not something anybody says.
|
||||
// So it belongs to the category, like the name does.
|
||||
ARTICLED: {
|
||||
8: hand => (hand.tiebreak[0] === 14 ? "a royal flush" : "a"),
|
||||
7: () => null,
|
||||
6: () => "a",
|
||||
5: () => "a",
|
||||
4: () => "a",
|
||||
3: () => null,
|
||||
2: () => null,
|
||||
1: () => "a",
|
||||
0: () => null,
|
||||
},
|
||||
|
||||
// "wins with X" — the phrase, ready to drop into a sentence.
|
||||
phrase(hand) {
|
||||
if (!hand) return "the best hand";
|
||||
const described = this.describe(hand);
|
||||
const article = (this.ARTICLED[hand.category] || (() => null))(hand);
|
||||
if (article === null) return described.toLowerCase();
|
||||
if (article !== "a") return article;
|
||||
return `a ${described.toLowerCase()}`;
|
||||
},
|
||||
|
||||
// ---- The showdown -------------------------------------------------------------
|
||||
|
||||
// Rank a set of players against a board.
|
||||
//
|
||||
// `players` is [{ id, cards }, ...] — the two in front of each of them. Every player
|
||||
// is returned whether or not they could be ranked, in the order they were given, so
|
||||
// the caller can show a row for each and say which ones it could not read. `place`
|
||||
// is 1 for the winner and shared by a chop; a player whose cards aren't known has a
|
||||
// null hand and no place.
|
||||
//
|
||||
// `complete` is the question the pot wizard actually asks: can this be trusted to
|
||||
// pay the table. It is only true when the board is all five cards and every single
|
||||
// player handed in has two of their own — one unknown hand and the tool cannot say
|
||||
// who won, only who is winning so far, which is not the same thing and must not be
|
||||
// dressed up as it.
|
||||
showdown(board, players) {
|
||||
const boardCards = (board || []).filter(card => this.isCard(card));
|
||||
const boardComplete = boardCards.length === 5;
|
||||
|
||||
const ranked = (players || []).map(player => {
|
||||
const hole = (player.cards || []).filter(card => this.isCard(card));
|
||||
const hand = hole.length === 2 && boardComplete
|
||||
? this.best([...hole, ...boardCards])
|
||||
: null;
|
||||
return { id: player.id, cards: hole, hand, place: null };
|
||||
});
|
||||
|
||||
const readable = ranked.filter(row => row.hand);
|
||||
// A duplicated card anywhere means somebody has been mis-entered, and the
|
||||
// ranking would be of a deck that doesn't exist.
|
||||
const all = [...boardCards, ...ranked.flatMap(row => row.cards)].map(c => c.toUpperCase());
|
||||
const duplicated = new Set(all).size !== all.length;
|
||||
|
||||
const sorted = [...readable].sort((a, b) => this.compare(b.hand, a.hand));
|
||||
let place = 0;
|
||||
sorted.forEach((row, i) => {
|
||||
if (i === 0 || this.compare(row.hand, sorted[i - 1].hand) !== 0) place = i + 1;
|
||||
row.place = place;
|
||||
});
|
||||
|
||||
const winners = sorted.filter(row => row.place === 1);
|
||||
const complete = boardComplete && !duplicated &&
|
||||
ranked.length > 0 && readable.length === ranked.length;
|
||||
|
||||
return {
|
||||
players: ranked,
|
||||
winners: complete ? winners.map(row => row.id) : [],
|
||||
boardComplete,
|
||||
duplicated,
|
||||
complete,
|
||||
// Who the tool could not read, so the dialog can name them rather than
|
||||
// just declining to help.
|
||||
unknown: ranked.filter(row => !row.hand).map(row => row.id),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = HandRank;
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
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),
|
||||
|
||||
// The waiting list, from the Session panel and from an empty chair's menu.
|
||||
// The state change goes inside runUndoable, not before it: the snapshot is
|
||||
// taken as the first thing that happens, so anything done ahead of it is
|
||||
// already baked into what Undo would restore.
|
||||
seatWaiting: (id, index) => {
|
||||
const entry = (state.waitingList || []).find(row => row.id === id);
|
||||
const seat = state.seats[index];
|
||||
if (!entry || !seat) {
|
||||
Toast.show("They aren't on the list any more.", { duration: 3000 });
|
||||
hooks.rerender();
|
||||
return;
|
||||
}
|
||||
const occupied = HoldemState.seatInPlay(seat);
|
||||
if (occupied) {
|
||||
Toast.show(`${HoldemState.displayName(seat, index)} is already in that seat.`, { duration: 3500 });
|
||||
hooks.rerender();
|
||||
return;
|
||||
}
|
||||
runUndoable(`${entry.name} takes seat ${index + 1}. Add their chips when they buy in.`,
|
||||
() => HoldemState.seatFromWaitingList(state, id, index));
|
||||
},
|
||||
|
||||
dropWaiting: id => {
|
||||
const entry = (state.waitingList || []).find(row => row.id === id);
|
||||
if (!entry) return;
|
||||
runUndoable(`${entry.name} taken off the waiting list.`,
|
||||
() => HoldemState.removeFromWaitingList(state, id));
|
||||
},
|
||||
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;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Somebody is done for the night. The dealer counts their chips out, the chair
|
||||
// opens, and both halves of what happened stay on the record — the cash the box
|
||||
// took from them, and the chips it handed back. Wiping the seat by hand did
|
||||
// neither: it left the bank check declaring their whole stack missing for the
|
||||
// rest of the night.
|
||||
cashOut: async index => {
|
||||
const allowed = HoldemState.canCashOut(state, index);
|
||||
if (!allowed.ok) {
|
||||
Toast.show(allowed.reason, { duration: 4000 });
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = HoldemState.cashOutPreview(state, index);
|
||||
const result = preview.net === 0
|
||||
? "even for the night"
|
||||
: preview.net > 0
|
||||
? `up ${Money.format(preview.net)}`
|
||||
: `down ${Money.format(Math.abs(preview.net))}`;
|
||||
const chips = preview.breakdown ? ` — ${preview.breakdown}` : "";
|
||||
|
||||
const ok = await Dialog.confirm({
|
||||
title: `Cash out ${preview.name}?`,
|
||||
message: `Count out ${Money.format(preview.chips)}${chips}. They bought in for ` +
|
||||
`${Money.format(preview.boughtIn)}, so they leave ${result}. The chair opens up, and the ` +
|
||||
`night's record keeps both their buy-in and this payout so the bank still balances.`,
|
||||
confirmLabel: `Pay ${Money.format(preview.chips)}`,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
runUndoable(`${preview.name} cashed out for ${Money.format(preview.chips)}.`,
|
||||
() => HoldemState.cashOut(state, index));
|
||||
},
|
||||
|
||||
// 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 = Betting.blindsArePosted(state);
|
||||
|
||||
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";
|
||||
// Only a hand that is already open has blinds to move. Before the deal this
|
||||
// is aiming the next hand, and re-posting would have dealt it — the dealer
|
||||
// says who the blind is well before they say "cards".
|
||||
const posted = Betting.blindsArePosted(state);
|
||||
seatAction(index, posted
|
||||
? `${nameOf(index)} posts the ${label}.`
|
||||
: `${nameOf(index)} has the ${label} next hand.`, () => {
|
||||
state.blindOverride = which === "sb"
|
||||
? { sbIndex: index, bbIndex: partner }
|
||||
: { sbIndex: partner, bbIndex: index };
|
||||
if (posted) Betting.repostBlinds(state);
|
||||
});
|
||||
},
|
||||
|
||||
clearBlindOverride: () => {
|
||||
const posted = Betting.blindsArePosted(state);
|
||||
seatAction(state.dealerSeatIndex, posted
|
||||
? "Blinds follow the button again — taken back and re-posted."
|
||||
: "Blinds follow the button again.", () => {
|
||||
state.blindOverride = null;
|
||||
if (posted) 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);
|
||||
|
||||
// Table Setup used to ship open, so the tool's first screen was its most crowded
|
||||
// one: seat count, blind direction, dedicated dealer, both blinds, starting stack
|
||||
// and two sub-panels, all under a felt the dealer had come here to look at. It is
|
||||
// the right panel to have open on a table nobody has touched, and the wrong one to
|
||||
// have open on the fourth hand of a night.
|
||||
//
|
||||
// Decided once, here, rather than on every render — a panel that reopened itself
|
||||
// whenever the table emptied would fight the dealer who had just shut it.
|
||||
(() => {
|
||||
const panel = document.getElementById("tableSetupPanel");
|
||||
if (!panel) return;
|
||||
const fresh = (Number(state.handNumber) || 1) === 1 &&
|
||||
!state.seats.some(seat => HoldemState.seatInPlay(seat));
|
||||
panel.open = fresh;
|
||||
})();
|
||||
|
||||
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);
|
||||
}
|
||||
// Anything the pot was priced with but nobody actually paid — the
|
||||
// base pot and counted chips of the no-live-betting flow — comes off
|
||||
// the stacks that owe it before the winner is paid. Otherwise the
|
||||
// table gains the whole pot out of nowhere.
|
||||
Betting.collectAssumedContributions(state);
|
||||
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,
|
||||
|
||||
// The first thing a dealer does on a table nobody is sitting at. Opens the
|
||||
// seat editor on the first free chair — the same panel tapping the seat gives
|
||||
// them, so the button teaches the gesture rather than replacing it.
|
||||
seatFirstPlayer: () => {
|
||||
const index = state.seats.findIndex(seat => !HoldemState.seatInPlay(seat));
|
||||
Render.toggleSeatEditor(index < 0 ? 0 : index);
|
||||
const panel = document.getElementById("seatEditorPanel");
|
||||
if (panel) panel.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
},
|
||||
|
||||
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.
|
||||
// One question, asked in one place. This predicate existed here, in the seat menu,
|
||||
// in the action bar and twice in the state module, in four slightly different
|
||||
// spellings — and a chair that counts as occupied for the waiting list but empty
|
||||
// for the seat count is how somebody's chips get thrown away.
|
||||
const seatHasSomeoneIn = player => HoldemState.seatInPlay(player);
|
||||
|
||||
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("trackBoardToggle").addEventListener("change", e => {
|
||||
state.trackBoard = e.target.checked;
|
||||
// Turning it off leaves whatever was entered in place rather than wiping it —
|
||||
// a dealer who taps this by mistake mid-hand can tap it back and the flop is
|
||||
// still there. The next hand clears it the way it clears everything else.
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
document.getElementById("tableNotes").addEventListener("input", e => {
|
||||
state.notes = e.target.value;
|
||||
hooks.save(state);
|
||||
});
|
||||
|
||||
document.getElementById("addChipColorBtn").addEventListener("click", () => {
|
||||
state.chipLegend.push({
|
||||
id: HoldemState.makeId("chip"),
|
||||
color: "#888888",
|
||||
value: 1,
|
||||
});
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
// A misdeal. Everything in the middle goes back to whoever put it there, and the
|
||||
// same button deals the same hand again.
|
||||
// The seats holding the table's starting stack, written down as cash taken.
|
||||
document.getElementById("bankRecordBuyInsBtn").addEventListener("click", async () => {
|
||||
const starting = HoldemState.seatsOwingCash(state).filter(row => row.fromStartingStack);
|
||||
if (starting.length === 0) return;
|
||||
const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
|
||||
|
||||
const ok = await Dialog.confirm({
|
||||
title: `Write down ${Money.format(total)} taken?`,
|
||||
message: `${starting.map(row => `${row.name} ${Money.format(row.chips)}`).join(", ")} — the starting stack ` +
|
||||
`each of them is playing with. Only do this if the cash is actually in the box.`,
|
||||
confirmLabel: "Write it down",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
runUndoable(`${Money.format(total)} recorded from ${starting.length} ${starting.length === 1 ? "player" : "players"}.`,
|
||||
() => HoldemState.recordStartingBuyIns(state));
|
||||
});
|
||||
|
||||
document.getElementById("waitingListForm").addEventListener("submit", e => {
|
||||
e.preventDefault();
|
||||
const field = document.getElementById("waitingNameInput");
|
||||
const name = field.value.trim();
|
||||
if (!name) return;
|
||||
field.value = "";
|
||||
runUndoable(`${name} added to the waiting list.`, () => HoldemState.addToWaitingList(state, name));
|
||||
field.focus();
|
||||
});
|
||||
|
||||
document.getElementById("misdealBtn").addEventListener("click", async () => {
|
||||
const inPlay = HoldemState.unresolvedContributions(state);
|
||||
const acted = state.seats.filter(p => p.hasActed).length;
|
||||
|
||||
const ok = await Dialog.confirm({
|
||||
title: "Misdeal — take the hand back?",
|
||||
message: inPlay > Betting.EPSILON_CHIPS
|
||||
? `${Money.format(inPlay)} goes back to the players who put it in, cards are cleared, and the same ` +
|
||||
`button deals again.${acted > 1 ? " Note that most rooms let a deal stand once two players have acted." : ""}`
|
||||
: "Cards are cleared and the same button deals again. Nothing has been put in yet, so no chips move.",
|
||||
confirmLabel: "Take it back",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// The label is both the toast a dealer reads now and the words on the undo
|
||||
// control afterwards, so it says what happened to the money rather than naming
|
||||
// the ruling: "the misdeal" told a table nothing about where their chips went.
|
||||
const label = inPlay > Betting.EPSILON_CHIPS
|
||||
? `Misdeal — ${Money.format(inPlay)} returned, same button deals again.`
|
||||
: "Misdeal — nothing was in yet, same button deals again.";
|
||||
runUndoable(label, () => Betting.takeHandBack(state));
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// ---- Working with no signal ----
|
||||
//
|
||||
// The night already survives anything — it lives in localStorage — but the page
|
||||
// did not. Reload in a back room with one bar of signal and a dealer with nine
|
||||
// players waiting got a browser error page. The worker below keeps the tool itself
|
||||
// on the phone; see wwwroot/casino/sw.js for what it does and doesn't cache.
|
||||
//
|
||||
// The list of files to keep is read off this document rather than written down in
|
||||
// the worker. Every stylesheet and script the page actually loaded is right here in
|
||||
// the DOM, already carrying the ?v= release string from _Layout — so the list
|
||||
// cannot drift from the markup, which is the way a hand-kept one always fails: it
|
||||
// stays right until somebody adds a script tag, and then the tool is quietly
|
||||
// offline-broken until a dealer finds out the hard way.
|
||||
const registerWorker = () => {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
const shell = [
|
||||
location.pathname,
|
||||
"/casino/holdem.webmanifest",
|
||||
...[...document.querySelectorAll('link[rel="stylesheet"][href]')].map(el => el.href),
|
||||
...[...document.querySelectorAll("script[src]")].map(el => el.src),
|
||||
].filter(url => new URL(url, location.origin).origin === location.origin);
|
||||
|
||||
navigator.serviceWorker.register("/casino/sw.js", { scope: "/casino/" })
|
||||
.then(reg => {
|
||||
// The worker that will answer for this page — `controller` is still null
|
||||
// on the very first visit, before activate has claimed it.
|
||||
const worker = reg.active || navigator.serviceWorker.controller;
|
||||
if (worker) {
|
||||
worker.postMessage({ type: "precache", urls: shell });
|
||||
return;
|
||||
}
|
||||
navigator.serviceWorker.ready.then(ready => {
|
||||
if (ready.active) ready.active.postMessage({ type: "precache", urls: shell });
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// No worker means no offline, and nothing else. Every other part of the
|
||||
// tool works exactly as it did, so this is not worth a toast at a table.
|
||||
});
|
||||
};
|
||||
|
||||
// After load, not during it: registering competes with the page's own scripts for
|
||||
// the network, and the first paint matters more than the second visit does.
|
||||
if (document.readyState === "complete") registerWorker();
|
||||
else window.addEventListener("load", registerWorker, { once: true });
|
||||
});
|
||||
@@ -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,498 @@
|
||||
// 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() {
|
||||
this.renderStepInto();
|
||||
this.markOverflow();
|
||||
},
|
||||
|
||||
// Say when the list runs on past the bottom of the dialog.
|
||||
//
|
||||
// The body scrolls and the footer stays put, which is the right shape and was as far
|
||||
// as it went: nothing on screen said there was anything below. Measured on a Galaxy
|
||||
// S8, a three-way pot showed two names and cut the third off on a row boundary, just
|
||||
// under a hairline that reads as the end of the list. A dealer would have ticked the
|
||||
// two they could see. On the dialog that pays the table, a list that silently
|
||||
// continues is not a small thing.
|
||||
markOverflow() {
|
||||
const body = document.getElementById("potWizardBody");
|
||||
const modal = document.getElementById("potWizardModal");
|
||||
if (!body || !modal) return;
|
||||
|
||||
const update = () => {
|
||||
const below = body.scrollHeight - body.clientHeight - body.scrollTop;
|
||||
modal.classList.toggle("potWizardMoreBelow", below > 1);
|
||||
};
|
||||
if (!this.watchingOverflow) {
|
||||
body.addEventListener("scroll", update, { passive: true });
|
||||
window.addEventListener("resize", update);
|
||||
this.watchingOverflow = true;
|
||||
}
|
||||
update();
|
||||
// Again once the browser has laid the step out — on the first open the body has
|
||||
// no height yet, and an answer taken then is always "nothing below".
|
||||
requestAnimationFrame(update);
|
||||
},
|
||||
|
||||
renderStepInto() {
|
||||
// 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 = "";
|
||||
// The footer is a sibling of the body, outside the scroll — see the note in
|
||||
// TexasHoldem.cshtml. Cleared alongside it so a step can never inherit the
|
||||
// previous step's buttons.
|
||||
const footer = this.footer();
|
||||
footer.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 showdown = this.readShowdown(contenders);
|
||||
const decided = showdown && showdown.complete ? new Set(showdown.winners) : null;
|
||||
const handById = new Map((showdown ? showdown.players : []).map(p => [p.id, p.hand]));
|
||||
|
||||
const prompt = document.createElement("p");
|
||||
prompt.className = "potWizardPrompt";
|
||||
prompt.innerHTML = decided
|
||||
// It has read the hands, so it stops asking a question it knows the answer
|
||||
// to. The list stays — the dealer confirms, and a hand entered wrong is
|
||||
// corrected by unticking rather than by starting over.
|
||||
? `<strong>${Money.format(remaining)}</strong> in play.`
|
||||
: 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 verdict = this.renderVerdict(showdown, contenders);
|
||||
if (verdict) round.appendChild(verdict);
|
||||
|
||||
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;
|
||||
if (decided && decided.has(c.id)) {
|
||||
checkbox.checked = true;
|
||||
label.classList.add("chosen");
|
||||
}
|
||||
// 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);
|
||||
|
||||
// How this hand was read, under the name — on every row rather than only
|
||||
// the winning one, because a dealer checking the tool's answer is reading
|
||||
// the losers' hands, and a row that says nothing is a row they have to work
|
||||
// out for themselves. It takes the line the ceiling used to have; the
|
||||
// ceiling moves out beside the name, which is where the phone layout had
|
||||
// already put it to save exactly this line.
|
||||
const read = handById.get(c.id);
|
||||
if (read) {
|
||||
const ranked = document.createElement("span");
|
||||
ranked.className = "potWizardChoiceHand";
|
||||
ranked.textContent = HandRank.describe(read);
|
||||
text.appendChild(ranked);
|
||||
}
|
||||
label.appendChild(text);
|
||||
|
||||
const upTo = document.createElement("span");
|
||||
upTo.className = "potWizardChoiceUpTo";
|
||||
upTo.textContent = `up to ${Money.format(c.remaining)}`;
|
||||
label.appendChild(upTo);
|
||||
|
||||
// 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 = decided
|
||||
? (showdown.winners.length === 1
|
||||
? `Pay ${this.nameById.get(showdown.winners[0])}`
|
||||
: "Split the pot")
|
||||
: "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();
|
||||
});
|
||||
body.appendChild(round);
|
||||
footer.appendChild(confirmBtn);
|
||||
},
|
||||
|
||||
// How the tool reads the hands still in for this round of the pot.
|
||||
//
|
||||
// Asked per round rather than once for the whole hand, because a side pot has its
|
||||
// own contenders: the player who won the main pot is out of the question for the
|
||||
// next one, and the best hand among who is left is a different answer.
|
||||
//
|
||||
// Only when the dealer asked to track the board — everything here is off by default
|
||||
// and the wizard behaves exactly as it always has.
|
||||
readShowdown(contenders) {
|
||||
if (!this.state.trackBoard || typeof HandRank === "undefined") return null;
|
||||
return HandRank.showdown(this.state.board, contenders.map(c => ({
|
||||
id: c.id,
|
||||
cards: this.cardsById.get(c.id) || [],
|
||||
})));
|
||||
},
|
||||
|
||||
// What the winners would actually be paid if this round were confirmed now.
|
||||
//
|
||||
// Worked out from the same function that does the paying rather than from a second
|
||||
// one that agrees with it today — a preview that can drift from the payout is worse
|
||||
// than no preview, because it is the number the dealer says out loud.
|
||||
previewPayout(winnerIds) {
|
||||
try {
|
||||
const result = PotCalculator.resolveRound(this.entries, this.orderFromButton(winnerIds));
|
||||
return result.perWinner || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// "Alexandra wins $25 with a full house, kings full of threes."
|
||||
//
|
||||
// The whole point of the feature, in one line: who won and how much. It goes above
|
||||
// the list rather than replacing it, because the dealer still confirms — the tool
|
||||
// reads the cards, a person still says the pot is good.
|
||||
renderVerdict(showdown, contenders) {
|
||||
if (!showdown) return null;
|
||||
|
||||
const line = document.createElement("p");
|
||||
line.className = "potWizardVerdict";
|
||||
|
||||
if (!showdown.boardComplete) {
|
||||
line.classList.add("potWizardVerdictUnsure");
|
||||
line.textContent = "The board isn't all five cards yet, so pick the winner yourself — or fill it in on the felt and this will read it.";
|
||||
return line;
|
||||
}
|
||||
if (showdown.duplicated) {
|
||||
line.classList.add("potWizardVerdictUnsure");
|
||||
line.textContent = "The same card is in two places, so this can't be read. Check the cards on the felt.";
|
||||
return line;
|
||||
}
|
||||
if (!showdown.complete) {
|
||||
const missing = showdown.unknown.map(id => this.nameById.get(id)).filter(Boolean);
|
||||
line.classList.add("potWizardVerdictUnsure");
|
||||
line.textContent = missing.length
|
||||
? `${this.listOf(missing)} ${missing.length === 1 ? "hasn't" : "haven't"} turned over, so who won is still yours to say.`
|
||||
: "Not enough cards to read this one.";
|
||||
return line;
|
||||
}
|
||||
|
||||
const paid = this.previewPayout(showdown.winners);
|
||||
const amount = paid.length ? Money.format(paid[0].amount) : "";
|
||||
const names = showdown.winners.map(id => this.nameById.get(id)).filter(Boolean);
|
||||
const hand = showdown.players.find(p => p.id === showdown.winners[0]);
|
||||
const described = HandRank.phrase(hand && hand.hand);
|
||||
|
||||
line.classList.add("potWizardVerdictSure");
|
||||
line.textContent = names.length === 1
|
||||
? `${names[0]} wins ${amount} with ${described}.`
|
||||
: `${this.listOf(names)} split it, ${amount} each, with ${described}.`;
|
||||
return line;
|
||||
},
|
||||
|
||||
listOf(names) {
|
||||
if (names.length <= 1) return names[0] || "";
|
||||
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
||||
},
|
||||
|
||||
// The row of buttons that never scrolls away.
|
||||
footer() {
|
||||
return document.getElementById("potWizardFooter");
|
||||
},
|
||||
|
||||
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(note);
|
||||
}
|
||||
|
||||
this.footer().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,178 @@
|
||||
// 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),
|
||||
});
|
||||
|
||||
// An empty chair with people waiting: the dealer taps the chair somebody is
|
||||
// about to sit in, which is what their hand is doing anyway. Only the front of
|
||||
// the queue is offered, for the same reason the panel only offers them — a list
|
||||
// that isn't kept in order is just a pile of names.
|
||||
const waiting = (this.state.waitingList || [])[0];
|
||||
const emptyChair = !HoldemState.seatInPlay(player);
|
||||
if (waiting && emptyChair) {
|
||||
actions.push({
|
||||
label: `Seat ${waiting.name}`,
|
||||
className: "menuAccent",
|
||||
run: () => this.hooks.seatWaiting(waiting.id, index),
|
||||
});
|
||||
}
|
||||
|
||||
// Leaving for the night, which is a different thing from sitting out a hand:
|
||||
// the chips go back over the rail and the chair opens. Offered only for a seat
|
||||
// that has somebody in it, since an empty chair has nobody to pay.
|
||||
if (HoldemState.seatInPlay(player)) {
|
||||
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,9 @@ extern "C" { /* prevent C++ name mangling */
|
||||
* Internally it points to your engine state object. */
|
||||
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. */
|
||||
enum {
|
||||
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. */
|
||||
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
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -20,10 +20,13 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
#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 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
|
||||
* shared g_tt above. */
|
||||
/* Eval variant for an engine handle. CLASSIC = the hand-crafted evaluate(); LEARNED =
|
||||
* 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 {
|
||||
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) {
|
||||
@@ -108,6 +180,83 @@ static int parse_skill(const char* options, int fallback) {
|
||||
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
|
||||
* quiescence yet, so deep fixed-depth runs get expensive quickly. */
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!pos.pieces(chess::QUEEN))
|
||||
return 0;
|
||||
chess::Bitboard pcs = pos.pieces();
|
||||
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);
|
||||
bool castled = (c == chess::WHITE) ? (k == chess::G1 || k == chess::C1)
|
||||
: (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) {
|
||||
@@ -261,10 +428,141 @@ static int evaluate(const chess::Position& pos) {
|
||||
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
|
||||
* move, so flip the sign when black is to move. */
|
||||
static int evaluate_stm(const chess::Position& pos, bool whiteToMove) {
|
||||
int s = evaluate(pos);
|
||||
static int evaluate_stm(chess::Position& pos, bool whiteToMove, const EvalParams& ep) {
|
||||
int s = (ep.variant == EVAL_LEARNED) ? evaluateLearned(pos, ep) : evaluate(pos);
|
||||
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_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
|
||||
* 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
|
||||
@@ -351,6 +638,15 @@ CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) {
|
||||
auto* e = new (std::nothrow) ChessEngine();
|
||||
if (!e) return nullptr;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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) */
|
||||
|
||||
struct SearchContext {
|
||||
uint64_t nodes = 0;
|
||||
chess::Move killers[MAX_PLY][2] = {}; /* [ply][slot]; MOVE_NONE until filled */
|
||||
uint64_t nodes = 0;
|
||||
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
|
||||
@@ -387,7 +684,7 @@ static int negamax(chess::Position& pos, int maxDepth, int depth, int ply,
|
||||
return 0;
|
||||
|
||||
if (depth <= 0)
|
||||
return evaluate_stm(pos, whiteToMove);
|
||||
return evaluate_stm(pos, whiteToMove, *ctx.eval);
|
||||
|
||||
const uint64_t key = pos.key();
|
||||
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;
|
||||
|
||||
SearchContext ctx;
|
||||
ctx.eval = &engine->eval;
|
||||
int maxDepth = depth_for_skill(engine->skill);
|
||||
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 */
|
||||
}
|
||||
|
||||
/* ---- 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" */
|
||||
|
||||
@@ -47,6 +47,22 @@ public:
|
||||
void do_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
|
||||
// is_draw() can see game history the FEN doesn't carry. Call once, right after
|
||||
// from_fen and before any do_move.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# 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 |
|
||||
| `handRank.test.js` | What beats what: categories, the wheel, kickers, best five of seven |
|
||||
| `readingTheShowdown.test.js` | The board, the picker that fills it, and who wins a side pot |
|
||||
| `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, that no
|
||||
text is painted outside its box, that no seat covers another seat's writing, and that
|
||||
no bet chip is hidden behind a seat or another chip. 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, and at three table shapes, mid-hand:
|
||||
the sweep posts blinds, deals and calls three times before it measures anything, so
|
||||
the bets layer has chips on it. It used to audit a table nobody had ever bet at,
|
||||
which is a third of what is on the felt during a hand left unlooked at.
|
||||
|
||||
"Covered" and "out of view" are told apart before anything is hit-tested. An element
|
||||
clipped away by a panel that scrolls is not covered by whatever happens to be painted
|
||||
at that height, and reporting it that way is a diagnosis of something that isn't
|
||||
happening: the pot wizard's third winner, below the fold in a list, came back for four
|
||||
viewports running as "fully covered by button Confirm Winner(s)".
|
||||
|
||||
More than one table shape is also newer than it sounds. The sweep ran on one — five
|
||||
players at a nine-handed table — for its whole life, and how much room the felt has
|
||||
depends on how many people are sitting down far more than on anything a viewport can
|
||||
report. A full table is where the seats crowd and it had never been looked at: the
|
||||
first sweep that did found one player's name or stack painted under another on eighteen
|
||||
of the configurations, on every phone in the list.
|
||||
|
||||
The third shape is the same five players with the board turned on and five cards on it.
|
||||
It is deliberately the same roster in the same chairs as the second, so the only thing
|
||||
that differs between the two rows is the board — a seventh player would have told us
|
||||
that a seven-handed table crowds, which is true, already known, and not what that row
|
||||
is there to find out. It earned its place immediately: the board takes the middle of
|
||||
the felt, or a strip above it where the middle is too small, and the strip costs the
|
||||
felt height. The seats follow a felt that changes size on their own, being placed in
|
||||
percentages of it; the chips anchored inside them do not, being placed in pixels. They
|
||||
were laid out for a felt 23px taller than the one they were painted on.
|
||||
|
||||
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 about five
|
||||
seconds, 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 hand evaluator is tested the way it is
|
||||
|
||||
`handRank.js` is the only module here that can pay the wrong person without anybody
|
||||
noticing. A pot wizard that asks "who won?" is never more wrong than the dealer ticking
|
||||
the box; one that answers the question itself is wrong on its own account, silently,
|
||||
and a table that has come to trust it will not check.
|
||||
|
||||
So besides the hand-written cases — every category against every other, the wheel, three
|
||||
kickers deep, the counterfeited two pair, the board that plays — it is checked against a
|
||||
second evaluator written a different way, on twenty thousand random seven-card hands and
|
||||
four thousand random pairs of them. The second one is in the test file and shares no
|
||||
helper with the module: it scores a hand by counting ranks into a sortable list rather
|
||||
than by asking about shapes. That pass is most of the suite's running time and it is
|
||||
worth it; three deliberately introduced bugs — an ace that keeps its value in the wheel,
|
||||
a dropped two-pair kicker, a flush read only by its top card — are each caught by it as
|
||||
well as by the cases written for them.
|
||||
|
||||
## 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,128 @@
|
||||
// Saying who the blind is, before saying "cards".
|
||||
//
|
||||
// A dealer sets the table up out loud — button here, she's the small blind, he's the
|
||||
// big blind — and only then deals. The seat menu offers "Make small blind" for exactly
|
||||
// that, and it was answering by dealing the hand: betting opened, two stacks docked,
|
||||
// the action bar up and waiting on the seat after the big blind. The dealer had said
|
||||
// where the blinds go, and the tool heard "deal".
|
||||
//
|
||||
// The condition it was asking is `onlyBlindsIn`, which means "nothing has happened in
|
||||
// this hand except the blinds" — true of a hand one second old, and equally true of a
|
||||
// table in setup with no cards out and nothing committed. Those two need telling apart,
|
||||
// which is what `blindsArePosted` is for.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
describe("before the deal", () => {
|
||||
test("the blinds are not down, however much the table looks ready", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
assert.equal(state.bettingOpen, false);
|
||||
// The old condition, on its own, says yes here — this is the whole bug.
|
||||
assert.equal(Betting.onlyBlindsIn(state), true);
|
||||
assert.equal(Betting.blindsArePosted(state), false);
|
||||
});
|
||||
|
||||
test("a table straight out of setup, with nobody seated, is the same answer", () => {
|
||||
assert.equal(Betting.blindsArePosted(HoldemState.createDefault()), false);
|
||||
});
|
||||
|
||||
test("re-posting would have dealt the hand, which is why it is guarded", () => {
|
||||
// Not a rule, a demonstration: repostBlinds ends in beginHand. Calling it in
|
||||
// setup is the deal, and nothing about naming a blind should reach it.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
Betting.repostBlinds(state);
|
||||
assert.equal(state.bettingOpen, true, "chips off two stacks and the action bar up");
|
||||
assert.notEqual(state.actingSeatIndex, null);
|
||||
});
|
||||
|
||||
test("naming a blind moves a badge and no chips", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
// What the seat menu does: pin the override, repost only if the blinds are down.
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(state.bettingOpen, false, "still setup — the dealer hasn't dealt");
|
||||
assert.equal(state.actingSeatIndex, null, "nobody is being asked to act");
|
||||
assert.deepEqual(state.seats.map(p => Betting.committed(p)), [0, 0, 0, 0, 0, 0]);
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
|
||||
// And the badges say what the dealer just said.
|
||||
const blinds = HoldemState.getBlindSeats(state);
|
||||
assert.equal(blinds.sbIndex, 3);
|
||||
assert.equal(blinds.bbIndex, 0);
|
||||
});
|
||||
|
||||
test("and the hand that follows posts from where they were aimed", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.committed(state.seats[3]), 1);
|
||||
assert.equal(Betting.committed(state.seats[0]), 2);
|
||||
});
|
||||
|
||||
test("giving the button away is a marker moving too", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
const willRepost = Betting.blindsArePosted(state);
|
||||
state.dealerSeatIndex = 2;
|
||||
if (willRepost) Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(willRepost, false);
|
||||
assert.equal(state.bettingOpen, false);
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("once the hand is dealt", () => {
|
||||
test("the blinds are down, and re-aiming them moves real chips", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
const before = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.blindsArePosted(state), true);
|
||||
assert.equal(Betting.committed(state.seats[1]), 1);
|
||||
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[1]), 0, "the old small blind got their chip back");
|
||||
assert.equal(Betting.committed(state.seats[3]), 1);
|
||||
assert.equal(Betting.committed(state.seats[0]), 2);
|
||||
assert.equal(chipsOnTable(state), before, "no chips created or lost in the shuffle");
|
||||
});
|
||||
|
||||
test("but not once somebody has answered them", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
assert.equal(Betting.blindsArePosted(state), false, "there is a call to unpick now");
|
||||
});
|
||||
|
||||
test("nor on a later street", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
state.street = "flop";
|
||||
assert.equal(Betting.blindsArePosted(state), false);
|
||||
});
|
||||
|
||||
test("and a misdealt hand goes back to being a table in setup", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(Betting.blindsArePosted(state), false, "nothing is posted until the dealer deals again");
|
||||
});
|
||||
|
||||
test("as does a hand that has been paid out", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.sweepToHandTotals(state);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(Betting.blindsArePosted(state), false);
|
||||
});
|
||||
});
|
||||
@@ -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,135 @@
|
||||
// What the tool says out loud between taps.
|
||||
//
|
||||
// The action bar carried `aria-live="polite"` on the element whose entire contents
|
||||
// render() rebuilds every action, so a screen reader read the whole bar after every
|
||||
// single tap. Measured, after one:
|
||||
//
|
||||
// "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
|
||||
// Fold Call $10 Raise"
|
||||
//
|
||||
// Nothing in that is wrong. It is just unusable at the speed a hand is dealt, and it
|
||||
// is the documented anti-pattern: a live region has to be a stable element whose text
|
||||
// changes, not a container that gets replaced.
|
||||
//
|
||||
// What a dealer running this by ear needs is who is on the clock and what it costs
|
||||
// them. These are the sentences.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { ActionBar, Betting, HoldemState, makeTable } = require("./load");
|
||||
|
||||
const barOn = (state) => {
|
||||
ActionBar.state = state;
|
||||
ActionBar.lastAnnouncement = undefined;
|
||||
return ActionBar;
|
||||
};
|
||||
|
||||
describe("before the cards are out", () => {
|
||||
test("a table that could be dealt says so", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
assert.equal(barOn(state).announcement(), "Ready to deal.");
|
||||
});
|
||||
|
||||
test("and an empty table says what it is, rather than nothing", () => {
|
||||
// This used to announce "" — silence being the honest answer when the bar was
|
||||
// hidden on a table nobody was sitting at. The bar isn't hidden any more: an
|
||||
// empty table is the one state a dealer meets before they know how any of this
|
||||
// works, so it says so and offers the next step.
|
||||
const state = HoldemState.createDefault();
|
||||
assert.equal(barOn(state).announcement(), "Nobody's at the table yet.");
|
||||
});
|
||||
|
||||
test("and a table with one named player has moved on from that", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
state.seats[0].name = "Ann";
|
||||
assert.notEqual(barOn(state).announcement(), "Nobody's at the table yet.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("while a hand is running", () => {
|
||||
const dealt = () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
||||
state.seats.forEach((p, i) => { p.name = ["Ann", "Ben", "Cal", "Dee"][i] || ""; });
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
test("names who is on the clock, what it costs, and the pot", () => {
|
||||
const state = dealt();
|
||||
const said = barOn(state).announcement();
|
||||
assert.match(said, /to act/);
|
||||
assert.match(said, /\$2 to call/);
|
||||
assert.match(said, /Pot \$3\./);
|
||||
});
|
||||
|
||||
test("it is the seat that is actually acting, not the one after it", () => {
|
||||
const state = dealt();
|
||||
const name = HoldemState.displayName(state.seats[state.actingSeatIndex], state.actingSeatIndex);
|
||||
assert.ok(barOn(state).announcement().startsWith(name + " to act"));
|
||||
});
|
||||
|
||||
test("says checked to them rather than $0 to call", () => {
|
||||
// A number nobody owes is a number a dealer has to stop and parse.
|
||||
const state = dealt();
|
||||
state.currentBet = 0;
|
||||
assert.match(barOn(state).announcement(), /checked to them/);
|
||||
assert.doesNotMatch(barOn(state).announcement(), /\$0 to call/);
|
||||
});
|
||||
|
||||
test("a street with nobody left to act says the street is done", () => {
|
||||
const state = dealt();
|
||||
state.actingSeatIndex = null;
|
||||
state.street = "flop";
|
||||
assert.equal(barOn(state).announcement(), "Flop betting is done.");
|
||||
});
|
||||
|
||||
test("and a finished hand points at the pot", () => {
|
||||
const state = dealt();
|
||||
state.seats.forEach((p, i) => { if (i !== 0) p.folded = true; });
|
||||
assert.equal(Betting.isHandOver(state), true);
|
||||
assert.equal(barOn(state).announcement(), "Hand over. Resolve the pot.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("how often it speaks", () => {
|
||||
// A live region that is handed the same sentence twice reads it out twice, and
|
||||
// render() runs for a great many reasons that have nothing to do with whose turn
|
||||
// it is — a resize, a panel opening, a note being typed.
|
||||
const stubTarget = () => {
|
||||
const el = { textContent: "" };
|
||||
global.document = { getElementById: id => (id === "actionAnnouncer" ? el : null) };
|
||||
return el;
|
||||
};
|
||||
|
||||
test("says nothing new when nothing changed", () => {
|
||||
const el = stubTarget();
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
||||
Betting.beginHand(state);
|
||||
const bar = barOn(state);
|
||||
|
||||
bar.announce();
|
||||
const first = el.textContent;
|
||||
assert.ok(first.length > 0);
|
||||
|
||||
el.textContent = "CLEARED"; // if announce() writes again, this comes back
|
||||
bar.announce();
|
||||
assert.equal(el.textContent, "CLEARED", "the same sentence was not written twice");
|
||||
delete global.document;
|
||||
});
|
||||
|
||||
test("but does when the action moves", () => {
|
||||
const el = stubTarget();
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
||||
Betting.beginHand(state);
|
||||
const bar = barOn(state);
|
||||
|
||||
bar.announce();
|
||||
const first = el.textContent;
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
bar.announce();
|
||||
|
||||
assert.notEqual(el.textContent, first);
|
||||
assert.match(el.textContent, /to act/);
|
||||
delete global.document;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// Chips the pot was priced with that nobody actually paid.
|
||||
//
|
||||
// There are two ways a contribution gets into this tool. Tapping the action moves
|
||||
// chips: the bet leaves the stack, sits in front of the player, and is swept into their
|
||||
// hand total at the end of the street. The other is the "without live betting" panel —
|
||||
// a base pot amount, or chips counted out on a seat — where the dealer is recording a
|
||||
// hand they are watching rather than driving, and nothing ever leaves a stack.
|
||||
//
|
||||
// The pot wizard prices both the same way and awarding it credits the winner, so in
|
||||
// that second mode the table simply grew.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, Betting, HoldemState, chipsOnTable } = require("./load");
|
||||
|
||||
const basePotTable = (base = 50, stack = 200) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 4);
|
||||
state.basePotAmount = base;
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].stack = stack;
|
||||
state.seats[i].buyIns = stack;
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
const pricedPot = (state) => state.seats.reduce(
|
||||
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
|
||||
|
||||
describe("what nobody paid", () => {
|
||||
test("comes off the stacks that owe it", () => {
|
||||
const state = basePotTable();
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.equal(collected.total, 150);
|
||||
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben", "Cal"]);
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [150, 150, 150]);
|
||||
});
|
||||
|
||||
test("and lands where the rest of the tool looks for chips in the middle", () => {
|
||||
const state = basePotTable();
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.amountOverride), [50, 50, 50]);
|
||||
assert.equal(HoldemState.unresolvedContributions(state), 150);
|
||||
});
|
||||
|
||||
test("chips counted out on a seat are money too", () => {
|
||||
const state = basePotTable(0);
|
||||
state.seats[0].chipCounts = { red: 4 }; // four $5 chips
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.equal(collected.total, 20);
|
||||
assert.equal(state.seats[0].stack, 180);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what has already been paid", () => {
|
||||
test("a live bet is left exactly alone", () => {
|
||||
const state = basePotTable(0);
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
Betting.beginHand(state);
|
||||
const before = state.seats.map(p => Betting.stackOf(state, p));
|
||||
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.equal(collected.total, 0, "the blinds are already off their stacks");
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
|
||||
});
|
||||
|
||||
test("nor is a swept street taken a second time", () => {
|
||||
const state = basePotTable(0);
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
Betting.beginHand(state);
|
||||
Betting.sweepToHandTotals(state);
|
||||
const before = state.seats.map(p => Betting.stackOf(state, p));
|
||||
|
||||
assert.equal(Betting.collectAssumedContributions(state).total, 0);
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
|
||||
});
|
||||
|
||||
test("a base pot on top of a live bet only collects the difference", () => {
|
||||
// The base pot says "assume everyone is in for fifty". Somebody who has already
|
||||
// put twenty in owes thirty of it, not fifty.
|
||||
const state = basePotTable(50);
|
||||
state.seats[0].committed = 20;
|
||||
state.seats[0].stack = 180;
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
const ann = collected.taken.find(row => row.name === "Ann");
|
||||
assert.equal(ann.amount, 30);
|
||||
assert.equal(state.seats[0].stack, 150, "fifty in the middle, either way");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it will not do", () => {
|
||||
test("take more than a player is holding", () => {
|
||||
const state = basePotTable(50);
|
||||
state.seats[1].stack = 20;
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.equal(state.seats[1].stack, 0, "all of it, and no further");
|
||||
assert.equal(state.seats[1].amountOverride, 20);
|
||||
});
|
||||
|
||||
test("touch an empty chair", () => {
|
||||
const state = basePotTable();
|
||||
const before = { ...state.seats[3] };
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.equal(state.seats[3].stack, before.stack);
|
||||
assert.equal(state.seats[3].amountOverride, before.amountOverride);
|
||||
});
|
||||
|
||||
test("or a seat that isn't in the hand", () => {
|
||||
// The base pot stands in for "everyone put the same in", and somebody sitting
|
||||
// out did not.
|
||||
const state = basePotTable();
|
||||
state.seats[2].inHand = false;
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben"]);
|
||||
assert.equal(state.seats[2].stack, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the whole hand", () => {
|
||||
test("conserves the chips on the table, which it did not before", () => {
|
||||
const state = basePotTable();
|
||||
const total = chipsOnTable(state);
|
||||
const pot = pricedPot(state);
|
||||
|
||||
Betting.collectAssumedContributions(state);
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), total, "six hundred before, six hundred after");
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [300, 150, 150]);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("and the bank check stays balanced all the way through", () => {
|
||||
const state = basePotTable();
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true, "chips are in the middle, not gone");
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("a live-betting hand is unchanged by any of this", () => {
|
||||
const state = basePotTable(0);
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
const total = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
[3, 0, 1, 2].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
|
||||
Betting.sweepToHandTotals(state);
|
||||
const pot = HoldemState.unresolvedContributions(state);
|
||||
|
||||
Betting.collectAssumedContributions(state);
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), total);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("collecting twice takes nothing the second time", () => {
|
||||
// The pot wizard can be opened, cancelled and opened again.
|
||||
const state = basePotTable();
|
||||
Betting.collectAssumedContributions(state);
|
||||
const stacks = state.seats.map(p => p.stack);
|
||||
const again = Betting.collectAssumedContributions(state);
|
||||
assert.equal(again.total, 0);
|
||||
assert.deepEqual(state.seats.map(p => p.stack), stacks);
|
||||
});
|
||||
});
|
||||
@@ -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,473 @@
|
||||
// Where a player's bet sits on the felt.
|
||||
//
|
||||
// The chips were the last thing on the table nobody had measured. They were laid out on
|
||||
// a ring of their own at a fixed 27/44 of the seat's reach — a fraction picked when the
|
||||
// seat ring was itself fixed — and the choice between that ring and clinging to the
|
||||
// puck was made by asking how wide the *window* was. Neither of those is a question
|
||||
// about the felt. A phone held sideways is 844px across and gives the felt 117px of
|
||||
// height: it passed the width test, took the ring, and every chip on it was painted
|
||||
// behind a seat. The seats carry `z-index: 1` and the bets layer carries none, so a
|
||||
// chip under a puck is not dimmed or half-hidden — it is gone, and with it the only
|
||||
// number saying what that player has in.
|
||||
//
|
||||
// Everything below is geometry, so it runs without a browser. The felts are the ones
|
||||
// measured on real screens: 692x117 is a phone on its side, 300x216 a phone upright.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
const RADII = { rx: 44, ry: 42 };
|
||||
const PUCK = { width: 96, height: 64 };
|
||||
const CHIP = { width: 44, height: 22 };
|
||||
|
||||
const felt = (width, height) => ({ width, height });
|
||||
const spacingOn = (box, puck = PUCK, chip = CHIP) =>
|
||||
({ box, puck, chip, clearance: Render.BET_CLEARANCE_PX });
|
||||
|
||||
// A chip's centre in pixels on a given felt.
|
||||
const chipPixels = (index, count, box, spacing, radii = RADII) => {
|
||||
const pos = Render.betPosition(index, count, box.width / box.height, radii, spacing);
|
||||
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
|
||||
};
|
||||
|
||||
const seatPixels = (index, count, box, radii = RADII) => {
|
||||
const pos = Render.seatPosition(index, count, box.width / box.height, radii);
|
||||
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
|
||||
};
|
||||
|
||||
// Two axis-aligned boxes, by their centres. The same test the seats settle among
|
||||
// themselves — see seatFootprint.test.js — because it is the same question.
|
||||
const boxesOverlap = (a, b, sizeA, sizeB) =>
|
||||
Math.abs(a.x - b.x) < (sizeA.width + sizeB.width) / 2 - 0.5 &&
|
||||
Math.abs(a.y - b.y) < (sizeA.height + sizeB.height) / 2 - 0.5;
|
||||
|
||||
describe("reaching the edge of a box", () => {
|
||||
test("straight out the side is half the width, straight up half the height", () => {
|
||||
assert.equal(Render.edgeReach(96, 64, 1, 0), 48);
|
||||
assert.equal(Render.edgeReach(96, 64, 0, -1), 32);
|
||||
assert.equal(Render.edgeReach(96, 64, -1, 0), 48);
|
||||
});
|
||||
|
||||
test("a corner is whichever edge the ray meets first", () => {
|
||||
// 45° out of a 96x64 box: the top and bottom are nearer than the sides, so the
|
||||
// ray leaves through one of those. A single radius — half the diagonal, 57.7 —
|
||||
// would put a chip 25px further out than it needs to be, which on a 117px felt
|
||||
// is the difference between clear of the puck and off the table.
|
||||
const diagonal = Render.edgeReach(96, 64, Math.SQRT1_2, Math.SQRT1_2);
|
||||
assert.ok(Math.abs(diagonal - 32 / Math.SQRT1_2) < 1e-9, `got ${diagonal}`);
|
||||
assert.ok(diagonal < 48, "and never further than the wider half-edge");
|
||||
});
|
||||
|
||||
test("a square is the same in every direction it is asked about", () => {
|
||||
const straight = Render.edgeReach(50, 50, 0, 1);
|
||||
assert.equal(Render.edgeReach(50, 50, 1, 0), straight);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getting one box clear of another", () => {
|
||||
const PUCK96 = { width: 96, height: 88 };
|
||||
const CHIP50 = { width: 50, height: 21 };
|
||||
|
||||
// Travel `t` along `u` from the puck's centre, then ask whether the two boxes are
|
||||
// apart. This is the question clearanceReach is supposed to answer, asked the long
|
||||
// way round so the answer can be checked rather than restated.
|
||||
const apart = (t, ux, uy) =>
|
||||
Math.abs(t * ux) >= (PUCK96.width + CHIP50.width) / 2 - 1e-9 ||
|
||||
Math.abs(t * uy) >= (PUCK96.height + CHIP50.height) / 2 - 1e-9;
|
||||
|
||||
test("straight up and down it is what adding the two half-heights gives", () => {
|
||||
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 0, -1), (88 + 21) / 2);
|
||||
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 1, 0), (96 + 50) / 2);
|
||||
});
|
||||
|
||||
test("on a diagonal it is more than the two reaches added together", () => {
|
||||
// The bug this replaced. Two axis-aligned boxes are clear when they are apart on
|
||||
// one axis, and the travel that achieves that is the reach of the box you get by
|
||||
// adding their widths and their heights — which is never less, and on a diagonal
|
||||
// is more, than adding the two boxes' own reaches. At 768x1024 the difference was
|
||||
// 11px, and 11px is how far inside its own player's puck the chip came to rest.
|
||||
const dirs = [[0.83, 0.56], [0.6, 0.8], [0.9, 0.44], [Math.SQRT1_2, Math.SQRT1_2]];
|
||||
let shortfalls = 0;
|
||||
dirs.forEach(([ux, uy]) => {
|
||||
const summed = Render.edgeReach(PUCK96.width, PUCK96.height, ux, uy) +
|
||||
Render.edgeReach(CHIP50.width, CHIP50.height, ux, uy);
|
||||
const proper = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
|
||||
assert.ok(proper >= summed - 1e-9, `${ux},${uy}: ${proper} < ${summed}`);
|
||||
if (proper <= summed + 1e-9) return;
|
||||
// Where they differ, the old answer was not merely smaller — it was wrong:
|
||||
// travelling that far leaves the two boxes still on top of each other.
|
||||
shortfalls++;
|
||||
assert.ok(!apart(summed, ux, uy),
|
||||
`${ux},${uy}: adding the reaches gave ${summed.toFixed(1)}, which is enough after all`);
|
||||
});
|
||||
assert.ok(shortfalls >= 2, "expected the two to actually differ on some of these");
|
||||
});
|
||||
|
||||
test("and it is exactly enough — a hair less is not", () => {
|
||||
const dirs = [[0, -1], [1, 0], [0.83, 0.56], [0.6, -0.8], [-0.9, 0.44]];
|
||||
dirs.forEach(([ux, uy]) => {
|
||||
const t = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
|
||||
assert.ok(apart(t, ux, uy), `${ux},${uy}: ${t}px still overlaps`);
|
||||
assert.ok(!apart(t - 0.5, ux, uy), `${ux},${uy}: ${t}px is further than needed`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a chip on the ring", () => {
|
||||
test("with nothing measured it keeps the proportions the ring always used", () => {
|
||||
// The unit suite and the very first render both call it this way. 27/44 and
|
||||
// 25/42 of the seat's own reach, so the chips move with the ring rather than
|
||||
// with the felt.
|
||||
const seat = Render.seatPosition(3, 9, 1.4, RADII);
|
||||
const chip = Render.betPosition(3, 9, 1.4, RADII, null);
|
||||
assert.ok(Math.abs((chip.left - 50) - (seat.left - 50) * (27 / 44)) < 1e-9);
|
||||
assert.ok(Math.abs((chip.top - 50) - (seat.top - 50) * (25 / 42)) < 1e-9);
|
||||
});
|
||||
|
||||
test("measured, it sits just clear of its own puck rather than at a fixed fraction", () => {
|
||||
const box = felt(560, 400);
|
||||
const spacing = spacingOn(box);
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const seat = seatPixels(i, 9, box);
|
||||
const chip = chipPixels(i, 9, box, spacing);
|
||||
assert.ok(!boxesOverlap(seat, chip, PUCK, CHIP),
|
||||
`seat ${i + 1}'s own chip is under its own puck`);
|
||||
}
|
||||
});
|
||||
|
||||
test("and never wanders into the pot badge in the middle", () => {
|
||||
// The floor: 38% of the way out. Without it a chip walked in past a puck that
|
||||
// is 96px wide on a felt whose reach is 90px would land on the pot total, which
|
||||
// is the same fault at the other end of the spoke.
|
||||
const box = felt(300, 216);
|
||||
const spacing = spacingOn(box, { width: 200, height: 150 });
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const seat = seatPixels(i, 6, box);
|
||||
const chip = chipPixels(i, 6, box, spacing);
|
||||
const seatReach = Math.hypot(seat.x - box.width / 2, seat.y - box.height / 2);
|
||||
const chipReach = Math.hypot(chip.x - box.width / 2, chip.y - box.height / 2);
|
||||
assert.ok(chipReach >= seatReach * 0.38 - 0.5,
|
||||
`chip ${i + 1} is ${chipReach.toFixed(1)}px out of a possible ${seatReach.toFixed(1)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a seat sitting dead centre gets its chip put somewhere, not NaN", () => {
|
||||
const box = felt(400, 300);
|
||||
const pos = Render.betPosition(0, 1, box.width / box.height, { rx: 0, ry: 0 }, spacingOn(box));
|
||||
assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top), JSON.stringify(pos));
|
||||
});
|
||||
});
|
||||
|
||||
describe("deciding whether a ring of chips fits at all", () => {
|
||||
// The shape the layout works in: one entry per chip on the felt, carrying its own
|
||||
// measured size, because a chip is as wide as the number printed on it.
|
||||
const tableOf = (count, chip = CHIP) => {
|
||||
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
({ index: i, width: chip.width, height: chip.height }));
|
||||
};
|
||||
|
||||
test("a desktop felt has room for one", () => {
|
||||
const box = felt(700, 500);
|
||||
const all = tableOf(9);
|
||||
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), true);
|
||||
});
|
||||
|
||||
test("a phone on its side has not, whatever the window's width says about it", () => {
|
||||
// 692x117, measured on an 844px-wide phone in landscape — a viewport that sails
|
||||
// past a `max-width: 700px` test. The ring is a slot: the 38% floor puts a chip
|
||||
// on the seat across the table from it.
|
||||
const box = felt(692, 117);
|
||||
const all = tableOf(9);
|
||||
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), false,
|
||||
"this is the felt where every chip was painted behind a puck");
|
||||
});
|
||||
|
||||
test("its answer is the one the geometry gives", () => {
|
||||
// Not a restatement of the two cases above: whatever it returns, it has to agree
|
||||
// with laying the chips out and measuring them against every puck on the felt.
|
||||
const shapes = [felt(700, 500), felt(692, 117), felt(300, 216), felt(1000, 620), felt(480, 300)];
|
||||
shapes.forEach(box => {
|
||||
const all = tableOf(9);
|
||||
const spacing = spacingOn(box);
|
||||
const aspect = box.width / box.height;
|
||||
const clear = all.every(({ index: i }) => {
|
||||
const chip = chipPixels(i, 9, box, spacing);
|
||||
return all.every(({ index: j }) => !boxesOverlap(chip, seatPixels(j, 9, box), CHIP, PUCK));
|
||||
});
|
||||
assert.equal(Render.chipsClearTheSeats(all, aspect, RADII, spacing), clear,
|
||||
`${box.width}x${box.height}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("each chip is asked about at its own size", () => {
|
||||
// A chip is as wide as the number on it: at 768x1024, four players' chips were
|
||||
// 40px and one holding a four-figure bet was 47. Answering the question once, at
|
||||
// the widest, told the whole felt it had no room for a ring — and dropped all
|
||||
// five chips onto their own players' stacks to solve a problem four of them
|
||||
// did not have.
|
||||
// The felt and the pucks are the iPad's own, measured: 672x672, pucks 96x88.
|
||||
const box = felt(672, 672);
|
||||
const narrow = { width: 40, height: 20 };
|
||||
const huge = { width: 420, height: 380 };
|
||||
const spacing = { box, puck: { width: 96, height: 88 }, clearance: Render.BET_CLEARANCE_PX };
|
||||
const aspect = box.width / box.height;
|
||||
|
||||
assert.equal(Render.chipsClearTheSeats(tableOf(9, narrow), aspect, RADII, spacing), true,
|
||||
"a felt this size has room for a ring of ordinary chips");
|
||||
assert.equal(Render.chipsClearTheSeats(tableOf(9, huge), aspect, RADII, spacing), false,
|
||||
"and none at all for a ring of chips that size");
|
||||
|
||||
// Now one of each. The answer has to come from the sizes in the list, not from
|
||||
// one size standing in for all of them: with one huge chip the ring is off, and
|
||||
// taking that chip out of the question puts it back.
|
||||
const mixed = tableOf(9, narrow);
|
||||
mixed[0] = { index: 0, ...huge };
|
||||
assert.equal(Render.chipsClearTheSeats(mixed, aspect, RADII, spacing), false);
|
||||
assert.equal(Render.chipsClearTheSeats(mixed.slice(1), aspect, RADII, spacing), true,
|
||||
"the eight ordinary ones were never the problem");
|
||||
});
|
||||
|
||||
test("and at the chips against each other", () => {
|
||||
// The ring is a smaller circle than the seats' one and holds the same number of
|
||||
// things, so it is the tighter fit of the two. Measured on a Pixel 7 with twelve
|
||||
// players, two $10s on the ring sat on top of each other while every puck on the
|
||||
// felt was clear — so a check that only looks at the pucks says yes to a layout
|
||||
// that hides one player's bet behind another's.
|
||||
// The Pixel 7's own numbers, measured: a 300x336 felt on a ring of 44 by 40.84,
|
||||
// pucks 44x55, a pot badge 64x53, and the seven players who had put chips in.
|
||||
const box = felt(300, 336);
|
||||
const radii = { rx: 44, ry: 40.84 };
|
||||
const puck = { width: 44, height: 55 };
|
||||
const pot = { x: 150, y: 168, width: 64, height: 53 };
|
||||
const spacing = { box, puck, pot, clearance: Render.BET_CLEARANCE_PX };
|
||||
const aspect = box.width / box.height;
|
||||
|
||||
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
|
||||
const bettors = [2, 3, 4, 5, 6, 7, 8];
|
||||
const all = bettors.map(index => ({ index, width: index === 2 ? 44.1 : 49.7, height: 21 }));
|
||||
|
||||
const chips = all.map(entry => ({
|
||||
...entry,
|
||||
...chipPixels(entry.index, 12, box,
|
||||
{ ...spacing, chip: { width: entry.width, height: entry.height } }, radii),
|
||||
}));
|
||||
const seats = [...Array(12).keys()].map(i => seatPixels(i, 12, box, radii));
|
||||
|
||||
assert.equal(chips.some(chip => seats.some(seat => boxesOverlap(chip, seat, chip, puck))), false,
|
||||
"this table is chosen so the pucks are not the problem");
|
||||
assert.equal(chips.some(chip => boxesOverlap(chip, pot, chip, pot)), false,
|
||||
"nor the pot");
|
||||
assert.ok(chips.some((chip, i) => chips.some((other, j) => i !== j &&
|
||||
boxesOverlap(chip, other, chip, other))), "and so the chips are");
|
||||
|
||||
assert.equal(Render.chipsClearTheSeats(all, aspect, radii, spacing), false);
|
||||
});
|
||||
|
||||
test("it looks at every puck, not just the chip's own", () => {
|
||||
// The rule this replaced walked each chip in until it was clear of its *own*
|
||||
// seat and stopped there, which says nothing about the eight other pucks on the
|
||||
// felt. 560x190 is a felt where that rule is satisfied and the ring is still no
|
||||
// good: every chip clears the puck it belongs to, and one of them is sitting on
|
||||
// a stranger's.
|
||||
const box = felt(560, 190);
|
||||
const all = tableOf(9);
|
||||
const spacing = spacingOn(box);
|
||||
const chips = all.map(({ index: i }) => chipPixels(i, 9, box, spacing));
|
||||
|
||||
all.forEach(({ index: i }) => assert.ok(!boxesOverlap(chips[i], seatPixels(i, 9, box), CHIP, PUCK),
|
||||
`chip ${i + 1} is under its own puck, so this felt does not make the point`));
|
||||
assert.ok(all.some(({ index: i }) => all.some(({ index: j }) => i !== j &&
|
||||
boxesOverlap(chips[i], seatPixels(j, 9, box), CHIP, PUCK))),
|
||||
"expected a chip on somebody else's puck — what the old rule could not see");
|
||||
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacing), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a chip clinging to its puck", () => {
|
||||
// The fallback when there is no room for a ring: the chip hangs off the puck on the
|
||||
// side facing the empty middle of the felt.
|
||||
//
|
||||
// The felts the app actually draws, with the puck size each one settles on — read
|
||||
// off the running site at each viewport rather than guessed here, because the tight
|
||||
// felts shrink the puck too, and a test that holds the desktop's 96x64 puck against
|
||||
// a 528x83 felt is describing a table the app never draws.
|
||||
const REAL = [
|
||||
["Galaxy S8 on its side", felt(528, 83), { width: 44, height: 55 }],
|
||||
["iPhone on its side", felt(692, 113), { width: 72, height: 55 }],
|
||||
["Galaxy S8 upright, six-handed", felt(248, 280), { width: 78, height: 88 }],
|
||||
["Galaxy S8 upright, packed", felt(248, 280), { width: 44, height: 55 }],
|
||||
];
|
||||
// Chips are 21x17 everywhere they are anchored — the amount is the only thing on
|
||||
// one at that size.
|
||||
const TIGHT = { puck: { width: 44, height: 55 }, chip: { width: 21, height: 17 } };
|
||||
|
||||
// What each chip is told to do, worked out for the whole table at once.
|
||||
const placeAll = (count, box, puck, chip = TIGHT.chip, radii = RADII) => {
|
||||
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
|
||||
const indices = [...Array(count).keys()];
|
||||
const entries = indices.map(i => ({ index: i, width: chip.width, height: chip.height }));
|
||||
const spacing = { box, puck, clearance: Render.BET_CLEARANCE_PX };
|
||||
const offs = Render.anchoredChipTravel(entries, count, box.width / box.height, RADII, spacing);
|
||||
return indices.map(i => {
|
||||
const seat = Render.seatPosition(i, count, box.width / box.height, radii);
|
||||
const at = { x: (seat.left / 100) * box.width, y: (seat.top / 100) * box.height };
|
||||
const off = offs[i];
|
||||
return { seat: at, chip: { x: at.x + off.x, y: at.y + off.y }, off, puck };
|
||||
});
|
||||
};
|
||||
|
||||
test("on its own, a chip asks to sit just clear of its puck", () => {
|
||||
const box = felt(692, 113);
|
||||
const puck = { width: 72, height: 55 };
|
||||
const seat = Render.seatPosition(0, 9, box.width / box.height, RADII);
|
||||
const off = Render.anchoredChipOffset(seat, puck, TIGHT.chip, box, RADII);
|
||||
const travel = Math.hypot(off.x, off.y);
|
||||
const ux = off.x / travel, uy = off.y / travel;
|
||||
assert.ok(Math.abs(travel -
|
||||
(Render.clearanceReach(puck, TIGHT.chip, ux, uy) + Render.BET_CLEARANCE_PX)) < 1e-6, `travelled ${travel}`);
|
||||
});
|
||||
|
||||
test("and gets it, where nothing else is in the way", () => {
|
||||
// 692x113 is the roomier of the two landscape felts. Every chip clears its own
|
||||
// puck and none of them has to give ground.
|
||||
placeAll(9, felt(692, 113), { width: 72, height: 55 }).forEach((row, i) => {
|
||||
assert.ok(!boxesOverlap(row.seat, row.chip, row.puck, TIGHT.chip),
|
||||
`seat ${i + 1}'s chip is inside its own puck, and nothing made it go there`);
|
||||
});
|
||||
});
|
||||
|
||||
test("it goes off the rim, not down the spoke", () => {
|
||||
// On a felt 692x113 the two are nowhere near the same direction. A seat on the
|
||||
// top rail has a spoke pointing diagonally inward — which along a slot this flat
|
||||
// runs sideways into the next seat — while off the rim is very nearly straight
|
||||
// down, into the empty middle. This is the seam that put two chips on top of
|
||||
// each other on a Galaxy S8.
|
||||
const box = felt(692, 113);
|
||||
const rows = placeAll(9, box, { width: 72, height: 55 });
|
||||
const top = rows.find(r => r.seat.y < box.height * 0.3 && r.seat.x > box.width * 0.7);
|
||||
assert.ok(top, "expected a seat up on the right of the top rail");
|
||||
assert.ok(Math.abs(top.off.y) > Math.abs(top.off.x) * 2,
|
||||
`chip went ${top.off.x.toFixed(1)},${top.off.y.toFixed(1)} — that is along the rail, not off it`);
|
||||
assert.ok(top.off.y > 0, "and downward, toward the middle of the felt");
|
||||
});
|
||||
|
||||
test("no two chips on a full table land on each other", () => {
|
||||
// Four sides gave three seams. There are no sides now, and this is what that is
|
||||
// worth — on every felt the app actually draws, at every table size.
|
||||
REAL.forEach(([name, box, puck]) => {
|
||||
[6, 9, 12].forEach(count => {
|
||||
const rows = placeAll(count, box, puck);
|
||||
for (let a = 0; a < rows.length; a++) {
|
||||
for (let b = a + 1; b < rows.length; b++) {
|
||||
assert.ok(!boxesOverlap(rows[a].chip, rows[b].chip, TIGHT.chip, TIGHT.chip),
|
||||
`${name}, ${count} seats: chips ${a + 1} and ${b + 1} overlap`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("a chip only gives ground when it is standing on something", () => {
|
||||
// The point of pulling chips back one at a time rather than by a rule that
|
||||
// applies to all of them. Stopping every chip at the felt's midline, or scaling
|
||||
// them all by one factor, takes travel from the seats at the ends of a long
|
||||
// rail — measured at 692x113, that parked two chips on their own player's stack
|
||||
// to solve a collision that did not exist.
|
||||
//
|
||||
// So: every chip either got everything it asked for, or putting the last step
|
||||
// back reintroduces a collision it is part of. The step goes back for all of
|
||||
// them at once, because a facing pair gives ground together and the pass that
|
||||
// finally separates such a pair moves both ends of it.
|
||||
const step = Render.CHIP_PULLBACK_STEP_PX;
|
||||
REAL.forEach(([name, box, puck]) => {
|
||||
[6, 9, 12].forEach(count => {
|
||||
const rows = placeAll(count, box, puck).map(row => {
|
||||
const travel = Math.hypot(row.off.x, row.off.y);
|
||||
const dir = travel < 1e-6 ? { x: 0, y: 0 } : { x: row.off.x / travel, y: row.off.y / travel };
|
||||
const asked = travel < 1e-6 ? 0 :
|
||||
Render.clearanceReach(puck, TIGHT.chip, dir.x, dir.y) +
|
||||
Render.BET_CLEARANCE_PX;
|
||||
const back = Math.min(asked, travel + step);
|
||||
return { ...row, travel, asked,
|
||||
undone: { x: row.seat.x + dir.x * back, y: row.seat.y + dir.y * back } };
|
||||
});
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
if (row.travel < 1e-6 || row.travel >= row.asked - 1e-6) return;
|
||||
const onSomething = rows.some((other, j) => j !== i && (
|
||||
boxesOverlap(row.undone, other.undone, TIGHT.chip, TIGHT.chip) ||
|
||||
boxesOverlap(row.undone, other.seat, TIGHT.chip, puck)));
|
||||
assert.ok(onSomething,
|
||||
`${name}, ${count} seats: chip ${i + 1} stopped ${(row.asked - row.travel).toFixed(1)}px short of nothing`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("nobody is pushed further out than they asked to go", () => {
|
||||
// The pull-back only ever takes travel away. A chip pushed past its clearance
|
||||
// would be floating out on the felt with nothing to say whose it is.
|
||||
REAL.forEach(([name, box, puck]) => {
|
||||
placeAll(12, box, puck).forEach((row, i) => {
|
||||
const travel = Math.hypot(row.off.x, row.off.y);
|
||||
if (travel < 1e-6) return;
|
||||
const ux = row.off.x / travel, uy = row.off.y / travel;
|
||||
const asked = Render.clearanceReach(puck, TIGHT.chip, ux, uy) +
|
||||
Render.BET_CLEARANCE_PX;
|
||||
assert.ok(travel <= asked + 0.5,
|
||||
`${name}: chip ${i + 1} went ${travel.toFixed(1)}px when ${asked.toFixed(1)} was asked for`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("the direction turns smoothly all the way round the ring", () => {
|
||||
// The property a set of discrete sides cannot have, and the reason there are
|
||||
// none: neighbouring seats get neighbouring directions, with no step anywhere.
|
||||
const box = felt(560, 400);
|
||||
const angles = Array.from({ length: 24 }, (_, i) => {
|
||||
const seat = Render.seatPosition(i, 24, box.width / box.height, RADII);
|
||||
const off = Render.anchoredChipOffset(seat, PUCK, CHIP, box, RADII);
|
||||
return Math.atan2(off.y, off.x);
|
||||
});
|
||||
angles.forEach((angle, i) => {
|
||||
const next = angles[(i + 1) % angles.length];
|
||||
let turn = Math.abs(next - angle);
|
||||
if (turn > Math.PI) turn = 2 * Math.PI - turn;
|
||||
assert.ok(turn < Math.PI / 4,
|
||||
`between seats ${i + 1} and ${i + 2} the chip jumps ${(turn * 180 / Math.PI).toFixed(0)}°`);
|
||||
});
|
||||
});
|
||||
|
||||
test("it stops short of the pot total as well", () => {
|
||||
// The other thing in the middle of the felt, and the one number on the table
|
||||
// that belongs to everybody. A seat on the middle of a rail sends its chip
|
||||
// straight at it: on a Galaxy S8 on its side, seat 1's $10 was sitting on
|
||||
// "Pot $65". The badge is measured rather than assumed to be at the centre —
|
||||
// it lives on the table, not on either overlay, and the two are not concentric.
|
||||
const box = felt(528, 83);
|
||||
const pot = { x: 264, y: 40, width: 96, height: 34 };
|
||||
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
|
||||
const indices = [...Array(12).keys()];
|
||||
const entries = indices.map(i =>
|
||||
({ index: i, width: TIGHT.chip.width, height: TIGHT.chip.height }));
|
||||
const spacing = { box, puck: TIGHT.puck, clearance: Render.BET_CLEARANCE_PX, pot };
|
||||
const offs = Render.anchoredChipTravel(entries, 12, box.width / box.height, RADII, spacing);
|
||||
|
||||
indices.forEach(i => {
|
||||
const seat = Render.seatPosition(i, 12, box.width / box.height, RADII);
|
||||
const chip = { x: (seat.left / 100) * box.width + offs[i].x,
|
||||
y: (seat.top / 100) * box.height + offs[i].y };
|
||||
assert.ok(!boxesOverlap(chip, pot, TIGHT.chip, pot),
|
||||
`chip ${i + 1} is on the pot total`);
|
||||
});
|
||||
});
|
||||
|
||||
test("a seat with no rim to be off still gets an answer", () => {
|
||||
const box = felt(400, 300);
|
||||
const off = Render.anchoredChipOffset({ left: 50, top: 50 }, PUCK, CHIP, box, RADII);
|
||||
assert.ok(Number.isFinite(off.x) && Number.isFinite(off.y), JSON.stringify(off));
|
||||
assert.ok(off.y < 0, "upward, which is where a puck's own writing is not");
|
||||
});
|
||||
});
|
||||
@@ -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,118 @@
|
||||
// The community cards, as a thing the table remembers.
|
||||
//
|
||||
// The board is the one part of a hand this tool never had a place for: hole cards go on
|
||||
// a seat, chips go in the middle, and the five cards everybody plays lived only on the
|
||||
// felt in front of the dealer. It is opt-in, because most nights nobody wants to enter
|
||||
// cards at all — and everything that reads it has to behave exactly as it always did
|
||||
// when it is off.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable } = require("./load");
|
||||
|
||||
const dealt = (over = {}) => {
|
||||
const state = { ...makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2 }), ...over };
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("a new table", () => {
|
||||
test("has five empty slots and is not tracking them", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
assert.equal(state.trackBoard, false,
|
||||
"off by default: a dealer who never asked for this should not be asked for cards");
|
||||
});
|
||||
});
|
||||
|
||||
describe("how many cards are out", () => {
|
||||
test("none, three, four, five", () => {
|
||||
assert.equal(Betting.boardCardsFor("preflop"), 0);
|
||||
assert.equal(Betting.boardCardsFor("flop"), 3);
|
||||
assert.equal(Betting.boardCardsFor("turn"), 4);
|
||||
assert.equal(Betting.boardCardsFor("river"), 5);
|
||||
});
|
||||
|
||||
test("and nothing at all for a street that isn't one", () => {
|
||||
assert.equal(Betting.boardCardsFor("showdown"), 0);
|
||||
assert.equal(Betting.boardCardsFor(undefined), 0);
|
||||
});
|
||||
|
||||
test("it matches the streets the betting engine actually deals", () => {
|
||||
// If a street is ever added, this is the table that has to hear about it —
|
||||
// a street with no entry silently draws no cards.
|
||||
Betting.STREETS.forEach(street => {
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(Betting.BOARD_CARDS, street),
|
||||
`${street} has no card count`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearing it", () => {
|
||||
test("the next hand takes the board with it", () => {
|
||||
const state = dealt();
|
||||
state.board = ["AH", "KD", "9C", "4S", "2H"];
|
||||
HoldemState.startNextHand(state);
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("so does a misdeal", () => {
|
||||
const state = dealt();
|
||||
state.board = ["AH", "KD", "9C", "", ""];
|
||||
Betting.takeHandBack(state);
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("and so does dealing a hand, however the table got here", () => {
|
||||
// Dealing twice off the same button — after a misdeal, or a table set up and
|
||||
// then dealt again — would otherwise rank the last hand's board against this
|
||||
// hand's cards, and it would look exactly like a real answer.
|
||||
const state = dealt();
|
||||
state.board = ["AH", "KD", "9C", "4S", "2H"];
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("but turning the tracking off does not", () => {
|
||||
// A dealer who taps the toggle mid-hand can tap it back; wiping the flop on
|
||||
// the way past would cost them cards they had already entered.
|
||||
const state = dealt({ trackBoard: true });
|
||||
state.board = ["AH", "KD", "9C", "", ""];
|
||||
state.trackBoard = false;
|
||||
assert.deepEqual(state.board, ["AH", "KD", "9C", "", ""]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a board off a file", () => {
|
||||
const migrated = board => HoldemState.migrate({
|
||||
seats: [HoldemState.makePlayer()], board,
|
||||
}).board;
|
||||
|
||||
test("a table saved before boards existed gets an empty one, not tracked", () => {
|
||||
const parsed = HoldemState.migrate({ seats: [HoldemState.makePlayer()] });
|
||||
assert.deepEqual(parsed.board, ["", "", "", "", ""]);
|
||||
assert.equal(parsed.trackBoard, false);
|
||||
});
|
||||
|
||||
test("it is always five slots, however many arrived", () => {
|
||||
assert.equal(migrated(["AH"]).length, 5);
|
||||
assert.equal(migrated(["AH", "KD", "9C", "4S", "2H", "3D", "5S"]).length, 5);
|
||||
assert.equal(migrated("not an array").length, 5);
|
||||
assert.equal(migrated(null).length, 5);
|
||||
});
|
||||
|
||||
test("anything that isn't a card becomes an empty slot", () => {
|
||||
assert.deepEqual(migrated(["AH", "ZZ", 7, null, "10H"]), ["AH", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("and a real one keeps its place, upper-cased", () => {
|
||||
assert.deepEqual(migrated(["ah", " kd ", "9c", "", ""]), ["AH", "KD", "9C", "", ""]);
|
||||
});
|
||||
|
||||
test("tracking is only on if the file said so, in so many words", () => {
|
||||
const on = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: true });
|
||||
const truthy = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: "yes" });
|
||||
assert.equal(on.trackBoard, true);
|
||||
assert.equal(truthy.trackBoard, false, "a string is a file that has been edited, not a yes");
|
||||
});
|
||||
});
|
||||
@@ -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,229 @@
|
||||
// Somebody leaving with their chips.
|
||||
//
|
||||
// The most ordinary thing that happens at a home game after midnight, and the tool had
|
||||
// nothing for it. Freeing the chair meant wiping the seat by hand, and then the bank
|
||||
// check — cash taken against chips on the table — declared the departing player's whole
|
||||
// stack missing for the rest of the night. Wiping their buy-in too silenced it, at the
|
||||
// price of losing their night from the standings and the file the dealer hands over.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, HoldemState, Betting, makeTable } = require("./load");
|
||||
|
||||
const table = () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
|
||||
["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].buyIns = 200;
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("counting them out", () => {
|
||||
test("the preview is what the dealer's hands follow", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
const preview = HoldemState.cashOutPreview(state, 0);
|
||||
assert.equal(preview.name, "Ann");
|
||||
assert.equal(preview.chips, 350, "what they are holding");
|
||||
assert.equal(preview.boughtIn, 200);
|
||||
assert.equal(preview.net, 150, "up for the night");
|
||||
assert.match(preview.breakdown, /\d+×\$\d+/, "and the chips that make it");
|
||||
});
|
||||
|
||||
test("a player who is down reads as down, not as a smaller stack", () => {
|
||||
const state = table();
|
||||
state.seats[1].stack = 60;
|
||||
assert.equal(HoldemState.cashOutPreview(state, 1).net, -140);
|
||||
});
|
||||
|
||||
test("a seat that never set a stack is holding the table's starting stack", () => {
|
||||
const state = table();
|
||||
state.defaultStack = 200;
|
||||
state.seats[2].stack = null;
|
||||
assert.equal(HoldemState.cashOutPreview(state, 2).chips, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses", () => {
|
||||
test("paying somebody with money still in the middle", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
const bb = state.seats.findIndex(p => (Number(p.committed) || 0) > 0);
|
||||
const refusal = HoldemState.canCashOut(state, bb);
|
||||
assert.equal(refusal.ok, false);
|
||||
assert.match(refusal.reason, /in the pot/);
|
||||
// And the refusal is the whole story: nothing moved.
|
||||
assert.equal(HoldemState.cashOut(state, bb).ok, false);
|
||||
assert.equal((state.cashedOut || []).length, 0);
|
||||
assert.ok(state.seats[bb].name, "the seat is untouched");
|
||||
});
|
||||
|
||||
test("an empty chair, which has nobody to pay", () => {
|
||||
const state = table();
|
||||
const refusal = HoldemState.canCashOut(state, 99);
|
||||
assert.equal(refusal.ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the chair afterwards", () => {
|
||||
test("is empty and ready for the next player", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
HoldemState.cashOut(state, 0);
|
||||
const seat = state.seats[0];
|
||||
assert.equal(seat.name, "");
|
||||
assert.equal(seat.stack, null);
|
||||
assert.equal(seat.buyIns, 0);
|
||||
assert.equal(seat.inHand, false);
|
||||
});
|
||||
|
||||
test("belongs to somebody new, so two people's nights can't merge", () => {
|
||||
const state = table();
|
||||
const wasId = state.seats[0].id;
|
||||
HoldemState.cashOut(state, 0);
|
||||
assert.notEqual(state.seats[0].id, wasId);
|
||||
});
|
||||
|
||||
test("and isn't owed the blind the person before them was posting", () => {
|
||||
const state = table();
|
||||
state.postingIn = [state.seats[0].id, state.seats[1].id];
|
||||
HoldemState.cashOut(state, 0);
|
||||
assert.deepEqual(state.postingIn, [state.seats[1].id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bank check", () => {
|
||||
test("balanced before, balanced after — the chips went somewhere known", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50;
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
HoldemState.cashOut(state, 0);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.paidOut, 350);
|
||||
assert.equal(bank.cashIn, 800, "their buy-in is still cash the box took");
|
||||
assert.equal(bank.chipsInPlay, 450);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
|
||||
test("this is the fault it fixes: wiping the seat by hand loses $350", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50; // so the table starts out balanced
|
||||
// What a dealer had to do before: empty the chair themselves.
|
||||
state.seats[0].name = "";
|
||||
state.seats[0].stack = null;
|
||||
state.seats[0].inHand = false;
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, -350, "the tool called it missing money");
|
||||
});
|
||||
|
||||
test("chips that really are missing still show as missing", () => {
|
||||
// Counting a cash-out must not become a way of swallowing a genuine shortfall.
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50; // balanced to start with
|
||||
HoldemState.cashOut(state, 0);
|
||||
state.seats[2].stack = 150; // and now fifty chips have gone astray
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, -50);
|
||||
});
|
||||
|
||||
test("a rake and a cash-out are counted apart", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.rakeTaken = 12;
|
||||
state.seats[1].stack = 38; // the rake came out of Ben's stack
|
||||
HoldemState.cashOut(state, 0);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.raked, 12);
|
||||
assert.equal(bank.paidOut, 350);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the record of their night", () => {
|
||||
test("they stay in the standings with what they bought and what they left with", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
|
||||
HoldemState.cashOut(state, 0);
|
||||
|
||||
const ann = HoldemState.sessionStandings(state).find(row => row.name === "Ann");
|
||||
assert.ok(ann, "still on the list");
|
||||
assert.equal(ann.boughtIn, 200);
|
||||
assert.equal(ann.chipsInFront, 350, "what the box handed over");
|
||||
assert.equal(ann.cashPosition, 150);
|
||||
assert.equal(ann.cashedOut, true, "and marked, so nobody pays them twice");
|
||||
});
|
||||
|
||||
test("somebody still sitting there is not marked", () => {
|
||||
const state = table();
|
||||
HoldemState.cashOut(state, 0);
|
||||
const ben = HoldemState.sessionStandings(state).find(row => row.name === "Ben");
|
||||
assert.equal(ben.cashedOut, false);
|
||||
});
|
||||
|
||||
test("two people leaving are both kept", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50;
|
||||
HoldemState.cashOut(state, 0);
|
||||
HoldemState.cashOut(state, 1);
|
||||
assert.equal(state.cashedOut.length, 2);
|
||||
assert.equal(HoldemState.bankReconciliation(state).paidOut, 400);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("a table saved before any of this existed has nobody cashed out", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(table()));
|
||||
delete parsed.cashedOut;
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.deepEqual(migrated.cashedOut, []);
|
||||
assert.equal(HoldemState.bankReconciliation(migrated).paidOut, 0);
|
||||
});
|
||||
|
||||
test("nonsense in a saved record is cleaned up rather than trusted", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(table()));
|
||||
parsed.cashedOut = [
|
||||
{ id: "a", name: "Ann", buyIns: -5, chips: "350", at: "nope" },
|
||||
{ name: "no id" },
|
||||
null,
|
||||
];
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.equal(migrated.cashedOut.length, 1);
|
||||
assert.equal(migrated.cashedOut[0].buyIns, 0);
|
||||
assert.equal(migrated.cashedOut[0].chips, 350);
|
||||
assert.equal(migrated.cashedOut[0].at, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the table carries on", () => {
|
||||
test("the next hand deals round the empty chair", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
HoldemState.cashOut(state, 0);
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[0].inHand, false, "nobody is in the empty chair");
|
||||
assert.equal(Betting.liveSeats(state).length, 3);
|
||||
assert.ok(state.actingSeatIndex !== null, "somebody is on the clock");
|
||||
});
|
||||
|
||||
test("and the money still adds up once it has", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50;
|
||||
HoldemState.cashOut(state, 0);
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(Money.round(bank.chipsInPlay + bank.paidOut), bank.cashIn);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
});
|
||||
@@ -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,158 @@
|
||||
// The stylesheet has to keep picking from the scales.
|
||||
//
|
||||
// Before the token block went in, holdem.css used 42 different spacing values, 31 font
|
||||
// sizes, 14 border-radii and 139 colours, and defined three custom properties. None of
|
||||
// that was anybody's decision — each number was chosen at the moment it was needed, and
|
||||
// the result read as crowded even where nothing overlapped, because a 0.42rem gap next
|
||||
// to a 0.45rem gap next to a 0.5rem gap gives the eye no rhythm to lock onto.
|
||||
//
|
||||
// A one-off cleanup would last until the next `padding: 0.35rem`. So the scales are
|
||||
// checked here instead: a new value has to be a step on one of them, or an exception
|
||||
// with a reason written next to it.
|
||||
//
|
||||
// This reads the stylesheet as text rather than through a CSS parser, on purpose — the
|
||||
// rest of this suite has no dependencies and runs in a second, and a regex over
|
||||
// declarations is enough to catch the thing that actually happens, which is somebody
|
||||
// (me) typing a number instead of reaching for a token.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const CSS_PATH = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css");
|
||||
const css = fs.readFileSync(CSS_PATH, "utf8");
|
||||
|
||||
// Comments carry measurements and rationale — "measured at 342px tall on a phone" —
|
||||
// and none of that is a declaration. Strip them before looking for values.
|
||||
const declarations = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
// Everything after the token block. The :root block is where the raw values are
|
||||
// supposed to be.
|
||||
const body = declarations.slice(declarations.indexOf("#potBadgeAmount,"));
|
||||
|
||||
const valuesIn = (properties, source = body) => {
|
||||
const found = [];
|
||||
// The lookbehind rejects a hyphen as well as a word character, so `scroll-padding`
|
||||
// isn't read as `padding`. It caught the pot wizard's scroll-padding the moment
|
||||
// that went in — correctly by its own rules, wrongly in substance: those two values
|
||||
// are the measured heights of the dialog's sticky header and footer, a clearance
|
||||
// like the action bar's, not a step in the spacing rhythm.
|
||||
const re = new RegExp(`(?<![\\w-])(${properties.join("|")})\\s*:([^;}]*)`, "g");
|
||||
let m;
|
||||
while ((m = re.exec(source)) !== null) {
|
||||
const [, prop, value] = m;
|
||||
// calc() and env() do their own arithmetic on tokens; the tokens inside them
|
||||
// are still checked, because the regex below looks at bare numbers only.
|
||||
(value.match(/(?<![\w-])[0-9.]+(rem|px|em)\b/g) || []).forEach(v => found.push({ prop, value: v }));
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
describe("the spacing scale", () => {
|
||||
// Hairlines and structural clearances, each one deliberate:
|
||||
// 0px/1px/3px/4px/22px — borders, hairline offsets, and the 22px of room a seat
|
||||
// puck's name gives the dealer button beside it.
|
||||
// 5rem/9rem — how far the page has to scroll past the fixed action
|
||||
// bar. Not rhythm; clearance for a thing of a known size.
|
||||
const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "22px", "5rem", "9rem"]);
|
||||
|
||||
test("every gap, margin and padding comes off the scale", () => {
|
||||
const offenders = valuesIn(
|
||||
["padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
|
||||
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
|
||||
"gap", "row-gap", "column-gap"])
|
||||
.filter(v => !ALLOWED.has(v.value));
|
||||
|
||||
assert.deepEqual(offenders, [],
|
||||
"use var(--sp-0 … --sp-6), or add the value to ALLOWED with a reason");
|
||||
});
|
||||
|
||||
test("and the scale is the one the tokens define", () => {
|
||||
["--sp-0", "--sp-1", "--sp-2", "--sp-3", "--sp-4", "--sp-5", "--sp-6"]
|
||||
.forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the type scale", () => {
|
||||
// 16px — the size below which iOS zooms the page when a field is focused, so
|
||||
// every text input states it. Not a typographic choice.
|
||||
// 1.05em, 0.44rem — relative bumps inside a component, and the shrink-to-fit for
|
||||
// a six-figure stack on a 58px seat puck.
|
||||
const ALLOWED = new Set(["16px", "1.05em", "0.44rem"]);
|
||||
|
||||
test("every font-size comes off the scale", () => {
|
||||
const offenders = valuesIn(["font-size"]).filter(v => !ALLOWED.has(v.value));
|
||||
assert.deepEqual(offenders, [],
|
||||
"use var(--fs-2xs … --fs-2xl), or add the value to ALLOWED with a reason");
|
||||
});
|
||||
|
||||
test("and the scale is the one the tokens define", () => {
|
||||
["--fs-2xs", "--fs-xs", "--fs-sm", "--fs-base", "--fs-md", "--fs-lg", "--fs-xl", "--fs-2xl"]
|
||||
.forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the corner scale", () => {
|
||||
test("every border-radius comes off it", () => {
|
||||
const offenders = valuesIn(["border-radius"]).filter(v => v.value !== "2px");
|
||||
assert.deepEqual(offenders, [], "use var(--r-sm | --r-md | --r-lg | --r-pill)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tokens themselves", () => {
|
||||
test("every var() the stylesheet reaches for exists", () => {
|
||||
const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4)));
|
||||
// Published from JS at runtime rather than declared here — see actionBar.js for
|
||||
// the first two, render.js for the seat width and the chip's denomination colour,
|
||||
// which is picked per chip from the amount on it.
|
||||
const fromScript = new Set([
|
||||
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
|
||||
]);
|
||||
// Defined by the site's own stylesheet, not this one.
|
||||
const fromSite = new Set(["--font-mono"]);
|
||||
const missing = [...used].filter(t =>
|
||||
!fromScript.has(t) && !fromSite.has(t) && !new RegExp(`\\${t}\\s*:`).test(css));
|
||||
assert.deepEqual(missing, []);
|
||||
});
|
||||
|
||||
test("and the dark surfaces stay off pure black", () => {
|
||||
// Shadows are invisible on #000, so depth has to come from a lighter surface —
|
||||
// and white text on true black is the pairing that smears on an OLED phone,
|
||||
// which is what this tool is used on.
|
||||
const surfaces = [...css.matchAll(/--surface-\d+:\s*(#[0-9a-fA-F]{3,8})/g)].map(m => m[1].toLowerCase());
|
||||
assert.ok(surfaces.length >= 4, "the surface ramp should have four steps");
|
||||
surfaces.forEach(hex => {
|
||||
assert.notEqual(hex, "#000");
|
||||
assert.notEqual(hex, "#000000");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("numbers that change while the dealer watches", () => {
|
||||
test("are set in tabular figures", () => {
|
||||
// A "1" is narrower than an "8" in a proportional face, so a pot ticking from
|
||||
// $18 to $21 shuffles its digits sideways and the eye reads movement where
|
||||
// there is only arithmetic.
|
||||
assert.match(css, /font-variant-numeric:\s*tabular-nums/);
|
||||
["#potBadgeAmount", ".seatPuckStack", ".actionPot", ".standingsRow"]
|
||||
.forEach(sel => assert.ok(css.includes(sel),
|
||||
`${sel} should be in the tabular-figures list`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("controls that must not resize under a thumb", () => {
|
||||
test("the undo pill has a fixed width, not a content width", () => {
|
||||
// It names the last action, so its label changes on every tap. Measured across
|
||||
// one hand before this: 148, 110, 109, 108, 109, 110, 120.
|
||||
const rule = css.match(/\.actionUndo\s*\{[^}]*\}/);
|
||||
assert.ok(rule, ".actionUndo rule not found");
|
||||
assert.match(rule[0], /width:\s*[0-9.]+rem/);
|
||||
});
|
||||
|
||||
test("and its label is the part that ellipsizes", () => {
|
||||
const rule = css.match(/\.actionUndoLabel\s*\{[^}]*\}/);
|
||||
assert.ok(rule, ".actionUndoLabel rule not found");
|
||||
assert.match(rule[0], /text-overflow:\s*ellipsis/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user