Compare commits
226
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 | ||
|
|
be07f7504e | ||
|
|
7bfbae7301 | ||
|
|
b651683e63 | ||
|
|
d87cea572f | ||
|
|
5345427e0c | ||
|
|
76b054ee9d | ||
|
|
2db95cec34 | ||
|
|
9b69489c4e | ||
|
|
fc2a2e122b | ||
|
|
c3280aa6d3 | ||
|
|
d2ae34f530 | ||
|
|
7fcfb270a1 | ||
|
|
3dc1bc452e | ||
|
|
4884c32fc8 |
@@ -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"
|
||||
@@ -14,7 +14,9 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
# Pinned (not ubuntu-latest) to match the prod server's Ubuntu/glibc so the
|
||||
# compiled libchess_engine.so loads there. Keep this == the server's release.
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -24,9 +26,16 @@ jobs:
|
||||
with:
|
||||
dotnet-version: '8.0.x' # adjust if needed
|
||||
- name: Restore
|
||||
run: dotnet 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/
|
||||
|
||||
- name: Publish
|
||||
run: dotnet publish -c Release -o ./publish
|
||||
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -45,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
|
||||
@@ -54,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
|
||||
|
||||
@@ -19,6 +19,6 @@ jobs:
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
run: dotnet restore JoshHeaps.Net.slnf
|
||||
- name: Build
|
||||
run: dotnet build --no-restore
|
||||
run: dotnet build JoshHeaps.Net.slnf --no-restore
|
||||
|
||||
@@ -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
|
||||
@@ -19,10 +19,10 @@ jobs:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
run: dotnet restore JoshHeaps.Net.slnf
|
||||
|
||||
- name: Build solution
|
||||
run: dotnet build --no-restore
|
||||
run: dotnet build JoshHeaps.Net.slnf --no-restore
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: |
|
||||
|
||||
+7
-1
@@ -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
|
||||
@@ -360,4 +363,7 @@ MigrationBackup/
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
FodyWeavers.xsd
|
||||
|
||||
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
|
||||
native/**/build/
|
||||
+36
-2
@@ -1,26 +1,60 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.9.34728.123
|
||||
# Visual Studio Version 18
|
||||
VisualStudioVersion = 18.6.11822.322 stable
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.UiTests", "JoshHeaps.Net.UiTests\JoshHeaps.Net.UiTests.csproj", "{360264F4-8292-4EB3-B67D-98376C13438B}"
|
||||
EndProject
|
||||
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "chess_engine", "native\chess_engine\chess_engine\chess_engine.vcxproj", "{2579BBBC-1830-4342-BC10-0A4182DC84C7}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.ActiveCfg = Debug|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.Build.0 = Debug|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.Build.0 = Debug|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.ActiveCfg = Debug|Win32
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.Build.0 = Debug|Win32
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.ActiveCfg = Release|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.Build.0 = Release|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.ActiveCfg = Release|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.Build.0 = Release|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.ActiveCfg = Release|Win32
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.Build.0 = Release|Win32
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"solution": {
|
||||
"path": "JoshHeaps.Net.sln",
|
||||
"projects": [
|
||||
"JoshHeaps.Net\\JoshHeaps.Net.csproj",
|
||||
"JoshHeaps.Net.UiTests\\JoshHeaps.Net.UiTests.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,9 +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;
|
||||
|
||||
@@ -11,16 +11,15 @@ namespace JoshHeaps.Net.Controllers;
|
||||
[Route("api/[controller]")]
|
||||
public class ChessController(
|
||||
IChessService chessService,
|
||||
IHubContext<ChessHub> chessHub,
|
||||
IBackgroundTaskQueue queue) : ControllerBase
|
||||
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);
|
||||
@@ -30,38 +29,45 @@ public class ChessController(
|
||||
/// </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 = new(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 gameState.Computer.MakeMove(gameState, chessHub, chessService);
|
||||
await orchestrator.PlayAsync(gameState);
|
||||
});
|
||||
}
|
||||
|
||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -71,6 +77,55 @@ public class ChessController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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,
|
||||
string whiteEngine = "custom",
|
||||
string blackEngine = "custom",
|
||||
int? whiteSkill = null,
|
||||
int? blackSkill = null)
|
||||
{
|
||||
var config = new SelfPlayConfig(
|
||||
ParseEngineKind(whiteEngine), whiteSkill ?? difficulty,
|
||||
ParseEngineKind(blackEngine), blackSkill ?? difficulty);
|
||||
|
||||
var (gameId, _) = selfPlay.StartGame(config);
|
||||
|
||||
return Ok(new { GameId = 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>
|
||||
/// Joins the "pool" of chess players.
|
||||
/// Test code expects to receive a GUID for the player
|
||||
@@ -80,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();
|
||||
@@ -103,7 +158,7 @@ public class ChessController(
|
||||
isWhite = false;
|
||||
}
|
||||
|
||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -113,51 +168,72 @@ public class ChessController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List in-progress games for spectators: both sides present and the game not yet decided.
|
||||
/// </summary>
|
||||
[HttpGet("active")]
|
||||
public ActionResult GetActiveGames()
|
||||
{
|
||||
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))
|
||||
.Select(g => new
|
||||
{
|
||||
g.GameId,
|
||||
g.IsVsComputer,
|
||||
g.IsComputerVsComputer,
|
||||
WhiteEngine = g.WhiteEngineKind.ToString(),
|
||||
BlackEngine = g.BlackEngineKind.ToString(),
|
||||
CurrentPlayer = g.CurrentPlayer.ToString(),
|
||||
MoveCount = g.MoveHistory.Count,
|
||||
g.IsCheck
|
||||
})
|
||||
.OrderByDescending(g => g.MoveCount);
|
||||
|
||||
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,
|
||||
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>
|
||||
/// Make a move in the specified game.
|
||||
/// Make a move in the specified game.
|
||||
/// 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
|
||||
@@ -181,17 +257,73 @@ public class ChessController(
|
||||
if (!result.Success)
|
||||
return BadRequest(result);
|
||||
|
||||
if (result.IsCheckmate || result.IsStalemate)
|
||||
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
||||
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
|
||||
|
||||
if (isGameOver)
|
||||
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 (gameState.IsVsComputer && gameState.Computer is not null)
|
||||
queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService));
|
||||
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>
|
||||
/// Forfeit a game on behalf of the calling player, handing the win to the opponent.
|
||||
/// Used when a player abandons a game (e.g. starts a new one mid-game).
|
||||
/// </summary>
|
||||
[HttpPost("forfeit")]
|
||||
public async Task<ActionResult> Forfeit([FromBody] ForfeitDto forfeit)
|
||||
{
|
||||
if (!gameStore.TryGet(forfeit.GameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
|
||||
return Ok();
|
||||
|
||||
var isWhitePlayer = gameState.WhitePlayerId == forfeit.PlayerId;
|
||||
var isBlackPlayer = gameState.BlackPlayerId == forfeit.PlayerId;
|
||||
|
||||
if (!isWhitePlayer && !isBlackPlayer)
|
||||
return StatusCode(403, "You are not a player in this game.");
|
||||
|
||||
gameState.IsForfeited = true;
|
||||
gameState.Winner = isWhitePlayer ? PieceColor.Black : PieceColor.White;
|
||||
|
||||
await chessHub.Clients.Group(gameState.GameId.ToString())
|
||||
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
|
||||
|
||||
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export a game as PGN (works while the game is still in memory after it ends).
|
||||
/// </summary>
|
||||
[HttpGet("{gameId}/pgn")]
|
||||
public ActionResult GetPgn(Guid gameId)
|
||||
{
|
||||
if (!gameStore.TryGet(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
return Content(gameState.ToPgn(), "application/x-chess-pgn");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -200,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);
|
||||
@@ -214,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)
|
||||
@@ -226,39 +358,4 @@ public class ChessController(
|
||||
|
||||
return Ok(allMoves);
|
||||
}
|
||||
|
||||
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}");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>53ed685c-bdff-4306-8cc2-9fbe55c85713</UserSecretsId>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -28,4 +29,16 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Builds the native engine and refreshes Resources\chess_engine.dll (via its post-build copy)
|
||||
only inside Visual Studio, which has the C++ tools. The dotnet CLI can't build a .vcxproj,
|
||||
so this is skipped by `dotnet build`/`dotnet publish` (incl. the Linux CI, which deploys the
|
||||
libchess_engine.so instead). ReferenceOutputAssembly=false: build-ordering only, not a
|
||||
managed reference to the native DLL. -->
|
||||
<ItemGroup Condition="'$(BuildingInsideVisualStudio)' == 'true'">
|
||||
<ProjectReference Include="..\native\chess_engine\chess_engine\chess_engine.vcxproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<Private>false</Private>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
public class ForfeitDto
|
||||
{
|
||||
public Guid GameId { get; set; }
|
||||
public Guid PlayerId { get; set; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
@@ -28,10 +29,25 @@ public class GameState
|
||||
public bool IsCheck { get; set; }
|
||||
public bool IsCheckmate { get; set; }
|
||||
public bool IsStalemate { get; set; }
|
||||
public bool IsThreefoldRepetition { get; set; }
|
||||
public bool IsForfeited { get; set; } = false;
|
||||
|
||||
// The color that won, when the game ended by forfeit (null while the game is live).
|
||||
public PieceColor? Winner { get; set; }
|
||||
|
||||
// Keep a history of moves if desired
|
||||
public List<string> MoveHistory { get; set; }
|
||||
|
||||
// Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection.
|
||||
public List<string> PositionHistory { get; set; }
|
||||
|
||||
// Moves in standard algebraic notation (SAN), in order, for PGN export.
|
||||
public List<string> SanHistory { get; set; }
|
||||
|
||||
// Half-moves since the last capture or pawn move (the FEN 50-move clock). Also tells
|
||||
// us how many trailing PositionHistory entries belong to the current repetition window.
|
||||
public int HalfmoveClock { get; set; }
|
||||
|
||||
// A list of all pieces to quickly reference them (optional but convenient).
|
||||
// Alternatively, you can iterate the Board array.
|
||||
public List<ChessPiece> Pieces { get; set; }
|
||||
@@ -42,8 +58,22 @@ public class GameState
|
||||
public Guid BlackPlayerId { get; set; }
|
||||
|
||||
public bool IsVsComputer { get; set; } = false;
|
||||
public bool IsComputerVsComputer { get; set; } = false;
|
||||
|
||||
public Stockfish? 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;
|
||||
@@ -54,5 +84,7 @@ public class GameState
|
||||
Board = new ChessPiece[8, 8];
|
||||
Pieces = [];
|
||||
MoveHistory = [];
|
||||
PositionHistory = [];
|
||||
SanHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -7,4 +7,5 @@ public class MoveResultDto
|
||||
public bool IsCheck { get; set; }
|
||||
public bool IsCheckmate { get; set; }
|
||||
public bool IsStalemate { get; set; }
|
||||
public bool IsThreefoldRepetition { get; set; }
|
||||
}
|
||||
|
||||
@@ -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,16 +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>
|
||||
<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>
|
||||
|
||||
@@ -57,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"]" />
|
||||
}
|
||||
@@ -61,8 +61,10 @@
|
||||
<h2 class="section-title">Demos</h2>
|
||||
<div id="buttonWrapper">
|
||||
<button class="demoButton" onclick="window.location.href='/chess'">Play Chess</button>
|
||||
<button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
|
||||
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
|
||||
<button class="demoButton" onclick="window.location.href='/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>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.WatchModel
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
ViewData["Title"] = "Watch Chess";
|
||||
}
|
||||
|
||||
<div id="watchHeader">
|
||||
<h1>Live Chess</h1>
|
||||
<p id="watchStatus">Loading games…</p>
|
||||
<div id="watchControls">
|
||||
<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>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/signalr/signalr.min.js"></script>
|
||||
<script src="~/js/ChessScripts/Spectate.js"></script>
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
|
||||
<link rel="stylesheet" href="~/css/chess/spectate.css?v=@ViewData["cssVersion"]" />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages
|
||||
{
|
||||
public class WatchModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -22,9 +23,24 @@ builder.Services.AddSingleton<IBlogService, BlogService>();
|
||||
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.
|
||||
@@ -37,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");
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"nativeDebugging": true,
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5200",
|
||||
@@ -20,6 +21,7 @@
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"nativeDebugging": true,
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7118;http://localhost:5200",
|
||||
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>The available chess engine implementations.</summary>
|
||||
public enum ChessEngineKind
|
||||
{
|
||||
Stockfish,
|
||||
Custom,
|
||||
|
||||
/// <summary>The custom engine with the reinforcement-learned piece-square evaluation.</summary>
|
||||
CustomLearned
|
||||
}
|
||||
|
||||
/// <summary>Configuration selecting which <see cref="IChessEngine"/> to use.</summary>
|
||||
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,
|
||||
ILearnedWeightsStore weightsStore) : IChessEngineFactory
|
||||
{
|
||||
private readonly ChessEngineKind _kind = options.Value.Engine;
|
||||
|
||||
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}'.")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using System.Text;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public static class ChessEngineHelpers
|
||||
{
|
||||
public static MoveDto ToMoveDto(
|
||||
this string uci,
|
||||
GameState gameState,
|
||||
Guid playerId)
|
||||
{
|
||||
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
|
||||
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
|
||||
|
||||
var piece = gameState.Board[fRow, fCol]
|
||||
?? throw new Exception("No piece at source square");
|
||||
|
||||
PieceType? promo = uci.Length == 5 ? uci[4] switch
|
||||
{
|
||||
'q' => PieceType.Queen,
|
||||
'r' => PieceType.Rook,
|
||||
'b' => PieceType.Bishop,
|
||||
'n' => PieceType.Knight,
|
||||
_ => null
|
||||
} : null;
|
||||
|
||||
return new MoveDto
|
||||
{
|
||||
GameId = gameState.GameId,
|
||||
PlayerId = playerId,
|
||||
PieceId = piece.Id,
|
||||
TargetRow = tRow,
|
||||
TargetCol = tCol,
|
||||
PromotionChoice = promo,
|
||||
SourceCol = fCol,
|
||||
SourceRow = fRow,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
|
||||
/// Only piece placement + active colour + castling are computed; the rest use
|
||||
/// safe defaults (-, 0, 1). That is all the engine needs.
|
||||
/// </summary>
|
||||
public static string ToFen(this GameState gs)
|
||||
{
|
||||
var sb = new StringBuilder(64);
|
||||
|
||||
/* 1) piece placement */
|
||||
for (int row = 0; row < 8; row++)
|
||||
{
|
||||
int empty = 0;
|
||||
|
||||
for (int col = 0; col < 8; col++)
|
||||
{
|
||||
var p = gs.Board[row, col];
|
||||
|
||||
if (p is null)
|
||||
{
|
||||
empty++;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (empty > 0) { sb.Append(empty); empty = 0; }
|
||||
sb.Append(ToFenChar(p)); // ← unchanged helper
|
||||
}
|
||||
}
|
||||
|
||||
if (empty > 0) sb.Append(empty);
|
||||
if (row < 7) sb.Append('/');
|
||||
}
|
||||
|
||||
/* 2) active colour */
|
||||
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
|
||||
|
||||
/* 3) castling rights (from GameState flags) */
|
||||
sb.Append(GetCastlingFlags(gs));
|
||||
|
||||
/* 4) en-passant target square */
|
||||
sb.Append(' ');
|
||||
sb.Append(gs.EnPassantTarget.HasValue
|
||||
? Alg(gs.EnPassantTarget.Value)
|
||||
: "-");
|
||||
|
||||
/* 5-6) half-move clock + full-move number */
|
||||
int fullMoves = gs.MoveHistory.Count / 2 + 1;
|
||||
sb.Append(' ').Append(gs.HalfmoveClock).Append(' ').Append(fullMoves);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The prior positions since the last irreversible move (capture/pawn move), as
|
||||
/// completed FEN strings, oldest first and excluding the current position. This is
|
||||
/// the repetition window the engine needs to detect threefold/50-move draws that a
|
||||
/// single FEN can't express.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> RepetitionHistory(this GameState gs)
|
||||
{
|
||||
int clock = gs.HalfmoveClock;
|
||||
int count = gs.PositionHistory.Count;
|
||||
int start = count - 1 - clock; // PositionHistory ends with the current position
|
||||
|
||||
if (clock <= 0 || start < 0)
|
||||
return Array.Empty<string>();
|
||||
|
||||
var fens = new List<string>(clock);
|
||||
|
||||
for (int i = start; i < count - 1; i++)
|
||||
fens.Add(gs.PositionHistory[i] + " 0 1"); // complete the 4-field key into a parseable FEN
|
||||
|
||||
return fens;
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
private static string GetCastlingFlags(GameState gs)
|
||||
{
|
||||
var flags = new StringBuilder(4);
|
||||
|
||||
if (gs.WhiteCanCastleKingside) flags.Append('K');
|
||||
if (gs.WhiteCanCastleQueenside) flags.Append('Q');
|
||||
if (gs.BlackCanCastleKingside) flags.Append('k');
|
||||
if (gs.BlackCanCastleQueenside) flags.Append('q');
|
||||
|
||||
return flags.Length == 0 ? "-" : flags.ToString();
|
||||
}
|
||||
|
||||
private static string Alg(Position p)
|
||||
{
|
||||
char file = (char)('a' + p.Col);
|
||||
int rank = 8 - p.Row;
|
||||
return $"{file}{rank}";
|
||||
}
|
||||
|
||||
private static char ToFenChar(ChessPiece p) => p switch
|
||||
{
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.White } => 'P',
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.Black } => 'p',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.White } => 'N',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.Black } => 'n',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.White } => 'B',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.Black } => 'b',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.White } => 'R',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.Black } => 'r',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.White } => 'Q',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.Black } => 'q',
|
||||
{ Type: PieceType.King, Color: PieceColor.White } => 'K',
|
||||
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(p))
|
||||
};
|
||||
}
|
||||
@@ -22,12 +22,14 @@ public class ChessService : IChessService
|
||||
|
||||
gameState.Pieces.Clear();
|
||||
gameState.MoveHistory.Clear();
|
||||
gameState.SanHistory.Clear();
|
||||
|
||||
gameState.WhiteCanCastleKingside = true;
|
||||
gameState.WhiteCanCastleQueenside = true;
|
||||
gameState.BlackCanCastleKingside = true;
|
||||
gameState.BlackCanCastleQueenside = true;
|
||||
gameState.EnPassantTarget = null;
|
||||
gameState.HalfmoveClock = 0;
|
||||
|
||||
SetupBlackPieces(gameState);
|
||||
SetupWhitePieces(gameState);
|
||||
@@ -35,6 +37,9 @@ public class ChessService : IChessService
|
||||
gameState.CurrentPlayer = PieceColor.White;
|
||||
|
||||
UpdateCheckStatus(gameState);
|
||||
|
||||
gameState.PositionHistory.Clear();
|
||||
gameState.PositionHistory.Add(PositionKey(gameState));
|
||||
}
|
||||
|
||||
private static void SetupBlackPieces(GameState gs)
|
||||
@@ -137,12 +142,23 @@ public class ChessService : IChessService
|
||||
if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol))
|
||||
return new MoveResultDto { Success = false, Message = "Illegal move." };
|
||||
|
||||
// SAN is built before the move (needs the pre-move board for captures/disambiguation);
|
||||
// the check/mate suffix is appended after UpdateCheckStatus.
|
||||
var sanBase = BuildSan(gameState, piece, targetPos, moveDto);
|
||||
|
||||
PerformMove(gameState, piece, targetPos, moveDto);
|
||||
|
||||
UpdateCheckStatus(gameState);
|
||||
|
||||
var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
|
||||
gameState.MoveHistory.Add(notation);
|
||||
gameState.SanHistory.Add(sanBase + (gameState.IsCheckmate ? "#" : gameState.IsCheck ? "+" : ""));
|
||||
|
||||
var positionKey = PositionKey(gameState);
|
||||
gameState.PositionHistory.Add(positionKey);
|
||||
|
||||
if (gameState.PositionHistory.Count(k => k == positionKey) >= 3)
|
||||
gameState.IsThreefoldRepetition = true;
|
||||
|
||||
return new MoveResultDto
|
||||
{
|
||||
@@ -150,14 +166,95 @@ public class ChessService : IChessService
|
||||
Message = "Move successful.",
|
||||
IsCheck = gameState.IsCheck,
|
||||
IsCheckmate = gameState.IsCheckmate,
|
||||
IsStalemate = gameState.IsStalemate
|
||||
IsStalemate = gameState.IsStalemate,
|
||||
IsThreefoldRepetition = gameState.IsThreefoldRepetition
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the standard-algebraic notation for a move from the position BEFORE it is
|
||||
/// applied (the check/mate suffix is added by the caller afterward).
|
||||
/// </summary>
|
||||
private string BuildSan(GameState gs, ChessPiece piece, Position target, MoveDto moveDto)
|
||||
{
|
||||
var from = piece.Position;
|
||||
|
||||
if (piece.Type == PieceType.King && Math.Abs(target.Col - from.Col) == 2)
|
||||
return target.Col > from.Col ? "O-O" : "O-O-O";
|
||||
|
||||
bool targetOccupied = gs.Board[target.Row, target.Col] != null;
|
||||
bool isEnPassant = piece.Type == PieceType.Pawn && from.Col != target.Col && !targetOccupied;
|
||||
bool isCapture = targetOccupied || isEnPassant;
|
||||
string dest = SquareName(target);
|
||||
|
||||
if (piece.Type == PieceType.Pawn)
|
||||
{
|
||||
var san = isCapture ? $"{FileChar(from.Col)}x{dest}" : dest;
|
||||
|
||||
bool promotes = (piece.Color == PieceColor.White && target.Row == 0)
|
||||
|| (piece.Color == PieceColor.Black && target.Row == 7);
|
||||
|
||||
if (promotes)
|
||||
san += "=" + PieceLetter(moveDto.PromotionChoice ?? PieceType.Queen);
|
||||
|
||||
return san;
|
||||
}
|
||||
|
||||
return $"{PieceLetter(piece.Type)}{Disambiguation(gs, piece, target)}{(isCapture ? "x" : "")}{dest}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SAN disambiguation: when another piece of the same type and color can also reach the
|
||||
/// target, qualify the origin by file, else rank, else both.
|
||||
/// </summary>
|
||||
private string Disambiguation(GameState gs, ChessPiece piece, Position target)
|
||||
{
|
||||
var rivals = gs.Pieces
|
||||
.Where(p => p.Id != piece.Id && p.Type == piece.Type && p.Color == piece.Color && p.Position.Row >= 0)
|
||||
.Where(p => GetLegalMovesForPiece(gs, p.Id).Any(m => m.Row == target.Row && m.Col == target.Col))
|
||||
.ToList();
|
||||
|
||||
if (rivals.Count == 0)
|
||||
return "";
|
||||
|
||||
if (rivals.All(p => p.Position.Col != piece.Position.Col))
|
||||
return FileChar(piece.Position.Col).ToString();
|
||||
|
||||
if (rivals.All(p => p.Position.Row != piece.Position.Row))
|
||||
return (8 - piece.Position.Row).ToString();
|
||||
|
||||
return $"{FileChar(piece.Position.Col)}{8 - piece.Position.Row}";
|
||||
}
|
||||
|
||||
private static char FileChar(int col) => (char)('a' + col);
|
||||
|
||||
private static string SquareName(Position p) => $"{FileChar(p.Col)}{8 - p.Row}";
|
||||
|
||||
private static string PieceLetter(PieceType type) => type switch
|
||||
{
|
||||
PieceType.Knight => "N",
|
||||
PieceType.Bishop => "B",
|
||||
PieceType.Rook => "R",
|
||||
PieceType.Queen => "Q",
|
||||
PieceType.King => "K",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The repetition signature of a position: the first four FEN fields — piece placement,
|
||||
/// side to move, castling rights, and en-passant target. Move counters are excluded.
|
||||
/// </summary>
|
||||
private static string PositionKey(GameState gs)
|
||||
{
|
||||
var fields = gs.ToFen().Split(' ');
|
||||
return string.Join(' ', fields.Take(4));
|
||||
}
|
||||
|
||||
private static void PerformMove(GameState gs, ChessPiece piece, Position targetPos, MoveDto moveDto)
|
||||
{
|
||||
var oldPos = piece.Position;
|
||||
var captured = gs.Board[targetPos.Row, targetPos.Col];
|
||||
var isPawnMove = piece.Type == PieceType.Pawn; // captured before promotion can change Type
|
||||
|
||||
HandleEnPassantIfNeeded(gs, piece, targetPos, ref captured);
|
||||
|
||||
@@ -179,6 +276,9 @@ public class ChessService : IChessService
|
||||
|
||||
UpdateCastlingRights(gs, piece, oldPos);
|
||||
|
||||
// Reset the 50-move clock on captures and pawn moves (irreversible); else advance it.
|
||||
gs.HalfmoveClock = (isPawnMove || captured != null) ? 0 : gs.HalfmoveClock + 1;
|
||||
|
||||
gs.CurrentPlayer = gs.CurrentPlayer == PieceColor.White
|
||||
? PieceColor.Black
|
||||
: PieceColor.White;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Engine-agnostic glue for computer moves: get a move from the engine, apply it
|
||||
/// through the rules service, and broadcast it to the game's clients.
|
||||
/// </summary>
|
||||
public sealed class ComputerMoveOrchestrator(
|
||||
IHubContext<ChessHub> chessHub,
|
||||
IChessService chessService,
|
||||
ILearnedWeightsStore weightsStore) : IComputerMoveOrchestrator
|
||||
{
|
||||
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));
|
||||
|
||||
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, state.ToDto());
|
||||
|
||||
return (move, result);
|
||||
}
|
||||
|
||||
private static Guid CurrentPlayerId(GameState state) =>
|
||||
state.CurrentPlayer == PieceColor.White ? state.WhitePlayerId : state.BlackPlayerId;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
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.
|
||||
/// The boundary contract is FEN string in, UCI move string out — identical to Stockfish.
|
||||
/// </summary>
|
||||
public sealed partial class CustomChessEngine : IChessEngine
|
||||
{
|
||||
private readonly EngineSafeHandle _handle;
|
||||
|
||||
public int Skill { get; }
|
||||
|
||||
public CustomChessEngine(int skill = 20, EngineVariant variant = EngineVariant.Classic, string? weightsPath = null)
|
||||
{
|
||||
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).");
|
||||
|
||||
_handle = new EngineSafeHandle(handle);
|
||||
}
|
||||
|
||||
public Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens) =>
|
||||
Task.Run(() => GetBestMove(fen, string.Join('\n', historyFens)));
|
||||
|
||||
private unsafe string GetBestMove(string fen, string history)
|
||||
{
|
||||
const int bufferLength = 16; // longest UCI move is 5 chars ("e7e8q") + NUL
|
||||
byte* buffer = stackalloc byte[bufferLength];
|
||||
|
||||
var added = false;
|
||||
|
||||
try
|
||||
{
|
||||
_handle.DangerousAddRef(ref added);
|
||||
var code = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, history, buffer, bufferLength);
|
||||
|
||||
if (code != 0)
|
||||
throw new InvalidOperationException($"Native chess engine failed to produce a move for FEN '{fen}' (engine_best_move returned {code}).");
|
||||
|
||||
return Marshal.PtrToStringUTF8((IntPtr)buffer)
|
||||
?? throw new InvalidOperationException("Native chess engine returned an empty move.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (added)
|
||||
_handle.DangerousRelease();
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_handle.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Guarantees the native handle is released exactly once via engine_destroy.</summary>
|
||||
private sealed class EngineSafeHandle : SafeHandle
|
||||
{
|
||||
public EngineSafeHandle(IntPtr handle) : base(IntPtr.Zero, ownsHandle: true) => SetHandle(handle);
|
||||
|
||||
public override bool IsInvalid => handle == IntPtr.Zero;
|
||||
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
NativeMethods.engine_destroy(handle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
internal static partial class NativeMethods
|
||||
{
|
||||
private const string LibName = "chess_engine";
|
||||
|
||||
static NativeMethods() =>
|
||||
NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, Resolve);
|
||||
|
||||
private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
|
||||
{
|
||||
if (libraryName != LibName)
|
||||
return IntPtr.Zero;
|
||||
|
||||
var fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? "chess_engine.dll"
|
||||
: "libchess_engine.so";
|
||||
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Resources", fileName);
|
||||
|
||||
if (NativeLibrary.TryLoad(path, out var handle))
|
||||
return handle;
|
||||
|
||||
return NativeLibrary.Load(libraryName, assembly, searchPath);
|
||||
}
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial IntPtr engine_create(string? options);
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static unsafe partial int engine_best_move(IntPtr engine, string fen, string history, byte* outBuffer, int outLength);
|
||||
|
||||
[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,50 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using System.Text;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public static class PgnExporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Render a game as PGN: the standard seven-tag header plus the SAN movetext and result.
|
||||
/// </summary>
|
||||
public static string ToPgn(this GameState gs)
|
||||
{
|
||||
var result = ResultTag(gs);
|
||||
var name = gs.IsComputerVsComputer ? "Computer" : null;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("[Event \"JoshHeaps.Net Chess\"]");
|
||||
sb.AppendLine("[Site \"joshheaps.net\"]");
|
||||
sb.AppendLine($"[Date \"{DateTime.Now:yyyy.MM.dd}\"]");
|
||||
sb.AppendLine($"[White \"{name ?? "White"}\"]");
|
||||
sb.AppendLine($"[Black \"{name ?? "Black"}\"]");
|
||||
sb.AppendLine($"[Result \"{result}\"]");
|
||||
sb.AppendLine();
|
||||
|
||||
for (int i = 0; i < gs.SanHistory.Count; i++)
|
||||
{
|
||||
if (i % 2 == 0)
|
||||
sb.Append(i / 2 + 1).Append(". ");
|
||||
|
||||
sb.Append(gs.SanHistory[i]).Append(' ');
|
||||
}
|
||||
|
||||
sb.Append(result);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ResultTag(GameState gs)
|
||||
{
|
||||
if (gs.IsCheckmate)
|
||||
return gs.CurrentPlayer == PieceColor.White ? "0-1" : "1-0";
|
||||
|
||||
if (gs.IsStalemate || gs.IsThreefoldRepetition)
|
||||
return "1/2-1/2";
|
||||
|
||||
if (gs.IsForfeited)
|
||||
return gs.Winner == PieceColor.White ? "1-0" : "0-1";
|
||||
|
||||
return "*";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,19 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public sealed class Stockfish : IAsyncDisposable
|
||||
public sealed class Stockfish : IChessEngine
|
||||
{
|
||||
private readonly Process _p;
|
||||
private readonly StreamWriter _stdin;
|
||||
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
|
||||
private readonly int _skill;
|
||||
|
||||
public int Skill => _skill;
|
||||
|
||||
public Stockfish(int skill = 20, int hash = 256)
|
||||
{
|
||||
_skill = skill;
|
||||
@@ -28,13 +25,13 @@ public sealed class Stockfish : IAsyncDisposable
|
||||
: "stockfish-ubuntu-x86-64-sse41-popcnt"; // default: Linux
|
||||
|
||||
string exePath = Path.Combine(baseDir, "Resources", fileName);
|
||||
|
||||
|
||||
if (!File.Exists(exePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Stockfish executable not found at {exePath}. " +
|
||||
"Ensure the file is present in the Resources folder of your project.");
|
||||
}
|
||||
|
||||
|
||||
Console.Write(exePath);
|
||||
|
||||
_p = new Process
|
||||
@@ -77,8 +74,9 @@ public sealed class Stockfish : IAsyncDisposable
|
||||
WaitFor("readyok").GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task<string> GetBestMoveAsync(string fen)
|
||||
public async Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens)
|
||||
{
|
||||
// historyFens is unused: Stockfish tracks repetition from the position it's given.
|
||||
Send($"position fen {fen}");
|
||||
Send($"go depth {_skill}");
|
||||
string? best = null;
|
||||
@@ -102,152 +100,26 @@ public sealed class Stockfish : IAsyncDisposable
|
||||
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();
|
||||
_p.Dispose();
|
||||
}
|
||||
|
||||
public async Task MakeMove(GameState state, IHubContext<ChessHub> chessHub, IChessService chessService)
|
||||
{
|
||||
var move = await GetBestMoveAsync(state.ToFen());
|
||||
|
||||
var moveDto = move.ToMoveDto(
|
||||
state,
|
||||
state.CurrentPlayer == PieceColor.White
|
||||
? state.WhitePlayerId
|
||||
: state.BlackPlayerId);
|
||||
|
||||
var result = chessService.MakeMove(state, moveDto);
|
||||
|
||||
await chessHub.Clients.Group(state.GameId.ToString()).SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), moveDto, result);
|
||||
}
|
||||
}
|
||||
|
||||
public static class StockfishHelpers
|
||||
{
|
||||
public static MoveDto ToMoveDto(
|
||||
this string uci,
|
||||
GameState gameState,
|
||||
Guid playerId)
|
||||
{
|
||||
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
|
||||
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
|
||||
|
||||
var piece = gameState.Board[fRow, fCol]
|
||||
?? throw new Exception("No piece at source square");
|
||||
|
||||
PieceType? promo = uci.Length == 5 ? uci[4] switch
|
||||
try
|
||||
{
|
||||
'q' => PieceType.Queen,
|
||||
'r' => PieceType.Rook,
|
||||
'b' => PieceType.Bishop,
|
||||
'n' => PieceType.Knight,
|
||||
_ => null
|
||||
} : null;
|
||||
|
||||
return new MoveDto
|
||||
await _p.WaitForExitAsync().WaitAsync(_exitTimeout);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
GameId = gameState.GameId,
|
||||
PlayerId = playerId,
|
||||
PieceId = piece.Id,
|
||||
TargetRow = tRow,
|
||||
TargetCol = tCol,
|
||||
PromotionChoice = promo,
|
||||
SourceCol = fCol,
|
||||
SourceRow = fRow,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
|
||||
/// Only piece placement + active colour + castling are computed; the rest use
|
||||
/// safe defaults (-, 0, 1). That is all Stockfish needs.
|
||||
/// </summary>
|
||||
public static string ToFen(this GameState gs)
|
||||
{
|
||||
var sb = new StringBuilder(64);
|
||||
|
||||
/* 1) piece placement */
|
||||
for (int row = 0; row < 8; row++)
|
||||
{
|
||||
int empty = 0;
|
||||
|
||||
for (int col = 0; col < 8; col++)
|
||||
{
|
||||
var p = gs.Board[row, col];
|
||||
|
||||
if (p is null)
|
||||
{
|
||||
empty++;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (empty > 0) { sb.Append(empty); empty = 0; }
|
||||
sb.Append(ToFenChar(p)); // ← unchanged helper
|
||||
}
|
||||
}
|
||||
|
||||
if (empty > 0) sb.Append(empty);
|
||||
if (row < 7) sb.Append('/');
|
||||
if (!_p.HasExited)
|
||||
_p.Kill(entireProcessTree: true);
|
||||
}
|
||||
|
||||
/* 2) active colour */
|
||||
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
|
||||
|
||||
/* 3) castling rights (from GameState flags) */
|
||||
sb.Append(GetCastlingFlags(gs));
|
||||
|
||||
/* 4) en-passant target square */
|
||||
sb.Append(' ');
|
||||
sb.Append(gs.EnPassantTarget.HasValue
|
||||
? Alg(gs.EnPassantTarget.Value)
|
||||
: "-");
|
||||
|
||||
/* 5-6) half-move clock + full-move number */
|
||||
int fullMoves = gs.MoveHistory.Count / 2 + 1;
|
||||
sb.Append(" 0 ").Append(fullMoves);
|
||||
|
||||
return sb.ToString();
|
||||
_p.Dispose();
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
private static string GetCastlingFlags(GameState gs)
|
||||
{
|
||||
var flags = new StringBuilder(4);
|
||||
|
||||
if (gs.WhiteCanCastleKingside) flags.Append('K');
|
||||
if (gs.WhiteCanCastleQueenside) flags.Append('Q');
|
||||
if (gs.BlackCanCastleKingside) flags.Append('k');
|
||||
if (gs.BlackCanCastleQueenside) flags.Append('q');
|
||||
|
||||
return flags.Length == 0 ? "-" : flags.ToString();
|
||||
}
|
||||
|
||||
private static string Alg(Position p)
|
||||
{
|
||||
char file = (char)('a' + p.Col);
|
||||
int rank = 8 - p.Row;
|
||||
return $"{file}{rank}";
|
||||
}
|
||||
|
||||
private static char ToFenChar(ChessPiece p) => p switch
|
||||
{
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.White } => 'P',
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.Black } => 'p',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.White } => 'N',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.Black } => 'n',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.White } => 'B',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.Black } => 'b',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.White } => 'R',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.Black } => 'r',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.White } => 'Q',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.Black } => 'q',
|
||||
{ Type: PieceType.King, Color: PieceColor.White } => 'K',
|
||||
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(p))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// An AI move-selection engine (e.g. Stockfish or the custom native engine).
|
||||
/// Selects a move for a position; it does not enforce rules or broadcast updates.
|
||||
/// Implementations own unmanaged resources, hence <see cref="IAsyncDisposable"/>.
|
||||
/// </summary>
|
||||
public interface IChessEngine : IAsyncDisposable
|
||||
{
|
||||
/// <summary>The engine's playing strength / search depth.</summary>
|
||||
int Skill { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the engine's chosen move in UCI long-algebraic form (e.g. "e2e4", "e7e8q").
|
||||
/// </summary>
|
||||
/// <param name="fen">The current position as a FEN string.</param>
|
||||
/// <param name="historyFens">
|
||||
/// Prior positions since the last irreversible move, oldest first, excluding the
|
||||
/// current one — lets the engine detect threefold/50-move draws the FEN can't carry.
|
||||
/// May be empty.
|
||||
/// </param>
|
||||
/// <returns>The selected move as a UCI string.</returns>
|
||||
Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Creates <see cref="IChessEngine"/> instances, choosing the implementation from configuration.
|
||||
/// </summary>
|
||||
public interface IChessEngineFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// 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);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// 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 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>
|
||||
/// <returns>The applied move and its result.</returns>
|
||||
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);
|
||||
}
|
||||
@@ -5,5 +5,8 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ChessEngine": {
|
||||
"Engine": "Custom"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,8 @@
|
||||
"BlogApi": {
|
||||
"BaseUrl": "https://media.joshheaps.net",
|
||||
"InvalidateKey": "CHANGE_ME"
|
||||
},
|
||||
"ChessEngine": {
|
||||
"Engine": "Custom"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,137 +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;
|
||||
}
|
||||
/* ---- 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
html, body {
|
||||
background-color: #2b2c30;
|
||||
color: #d6d6d6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#watchHeader {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem 0;
|
||||
}
|
||||
|
||||
#watchHeader h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#watchStatus {
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
#watchControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
#startCpuVsCpu {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#startCpuVsCpu:hover {
|
||||
background-color: #a5e0f2;
|
||||
}
|
||||
|
||||
#backToPlay {
|
||||
color: #8cd5ed;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#backToPlay:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#gamesFeed {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gameCard {
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gameResult {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
color: #8cd5ed;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.copyPgnBtn {
|
||||
display: block;
|
||||
margin: 0.75rem auto 0;
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copyPgnBtn:hover {
|
||||
background-color: #a5e0f2;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.fullscreen-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gameCard.fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
cursor: default;
|
||||
background-color: #2b2c30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gameCard.fullscreen .miniBoard {
|
||||
width: min(90vh, 90vw);
|
||||
height: min(90vh, 90vw);
|
||||
}
|
||||
|
||||
.gameCard.fullscreen .backButton {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gameCardHeader {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.miniBoard {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
grid-template-rows: repeat(8, 1fr);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -14,6 +14,20 @@ const ChessAPI = {
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async getPgn(gameId) {
|
||||
const response = await fetch(`/api/chess/${gameId}/pgn`);
|
||||
if (!response.ok) throw new Error("PGN unavailable");
|
||||
return await response.text();
|
||||
},
|
||||
|
||||
async forfeit(gameId, playerId) {
|
||||
await fetch("/api/chess/forfeit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ GameId: gameId, PlayerId: playerId })
|
||||
});
|
||||
},
|
||||
|
||||
async getLegalMoves(pieceId) {
|
||||
const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`);
|
||||
if (!response.ok) throw new Error("API failed");
|
||||
@@ -32,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) {
|
||||
@@ -71,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);
|
||||
@@ -101,9 +115,13 @@ const ChessAPI = {
|
||||
} else if (moveResult.isStalemate) {
|
||||
alert("🤝 Stalemate!");
|
||||
gameOver = true;
|
||||
} else if (moveResult.isThreefoldRepetition) {
|
||||
alert("🤝 Draw by threefold repetition!");
|
||||
gameOver = true;
|
||||
}
|
||||
|
||||
if (gameOver) {
|
||||
showCopyPgn(GameState.currentGameId);
|
||||
await ChessSignalR.leaveGame();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
@@ -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,12 @@ 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) => {
|
||||
await this.handleGameOver(gameId, winner, reason);
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -24,20 +28,28 @@ 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);
|
||||
},
|
||||
|
||||
async notifyMoveMade(moveDto, moveResult) {
|
||||
if (this.connection) {
|
||||
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
|
||||
}
|
||||
async handleGameOver(gameId, winner, reason) {
|
||||
if (gameId !== GameState.currentGameId) return;
|
||||
|
||||
const youWon = (winner === "White") === GameState.currentPlayerIsWhite;
|
||||
|
||||
if (reason === "forfeit")
|
||||
alert(youWon ? "🏳️ Your opponent forfeited — you win!" : "🏳️ You forfeited this game.");
|
||||
|
||||
showCopyPgn(gameId);
|
||||
await this.leaveGame();
|
||||
},
|
||||
|
||||
async leaveGame() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
const Spectate = {
|
||||
connection: null,
|
||||
games: new Map(), // gameId -> { isVsComputer, isComputerVsComputer, result }
|
||||
pgns: new Map(), // gameId -> PGN text (prefetched when a game finishes)
|
||||
|
||||
pieceTypeNames: ["Pawn", "Rook", "Knight", "Bishop", "Queen", "King"],
|
||||
|
||||
async init() {
|
||||
this.connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("/chessHub")
|
||||
.configureLogging(signalR.LogLevel.Warning)
|
||||
.build();
|
||||
|
||||
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto, _moveResult, state) =>
|
||||
this.handleMoveUpdate(gameId, moveDto, state));
|
||||
|
||||
this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId));
|
||||
|
||||
try {
|
||||
await this.connection.start();
|
||||
} catch (err) {
|
||||
console.error("❌ SignalR failed to start:", err);
|
||||
}
|
||||
|
||||
await this.refreshGames();
|
||||
await this.loadAutoTrainCount();
|
||||
setInterval(() => this.refreshGames(), 5000);
|
||||
},
|
||||
|
||||
// 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 {
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
async refreshGames() {
|
||||
let games;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/chess/active");
|
||||
games = await response.json();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIds = new Set(games.map(g => g.gameId));
|
||||
|
||||
for (const gameId of [...this.games.keys()])
|
||||
if (!activeIds.has(gameId))
|
||||
await this.removeGame(gameId);
|
||||
|
||||
for (const game of games)
|
||||
if (this.games.has(game.gameId))
|
||||
this.updateHeader(game.gameId, game.currentPlayer, game.moveCount, game.isCheck);
|
||||
else
|
||||
await this.addGame(game);
|
||||
|
||||
const status = document.getElementById("watchStatus");
|
||||
status.textContent = games.length === 0
|
||||
? "No games are being played right now."
|
||||
: `${games.length} game${games.length === 1 ? "" : "s"} in progress`;
|
||||
},
|
||||
|
||||
async addGame(game) {
|
||||
this.games.set(game.gameId, {
|
||||
isVsComputer: game.isVsComputer,
|
||||
isComputerVsComputer: game.isComputerVsComputer,
|
||||
whiteEngine: game.whiteEngine,
|
||||
blackEngine: game.blackEngine
|
||||
});
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "gameCard";
|
||||
card.id = `card-${game.gameId}`;
|
||||
card.onclick = () => this.enterFullscreen(game.gameId);
|
||||
|
||||
const back = document.createElement("button");
|
||||
back.className = "backButton";
|
||||
back.textContent = "← Back";
|
||||
back.onclick = (event) => this.exitFullscreen(game.gameId, event);
|
||||
card.appendChild(back);
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "gameCardHeader";
|
||||
header.id = `header-${game.gameId}`;
|
||||
header.textContent = this.headerText(this.games.get(game.gameId), game.currentPlayer, game.moveCount, game.isCheck);
|
||||
card.appendChild(header);
|
||||
|
||||
const board = document.createElement("div");
|
||||
board.className = "miniBoard";
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const square = document.createElement("div");
|
||||
square.id = `sq-${game.gameId}-${i}`;
|
||||
square.className = `chessSquare ${(i + Math.floor(i / 8)) % 2 === 0 ? "light" : "dark"}`;
|
||||
board.appendChild(square);
|
||||
}
|
||||
|
||||
card.appendChild(board);
|
||||
document.getElementById("gamesFeed").appendChild(card);
|
||||
|
||||
await this.connection.invoke("JoinWebsocketGroup", game.gameId).catch(() => { });
|
||||
await this.renderGame(game.gameId);
|
||||
},
|
||||
|
||||
async removeGame(gameId) {
|
||||
this.games.delete(gameId);
|
||||
this.pgns.delete(gameId);
|
||||
document.getElementById(`card-${gameId}`)?.remove();
|
||||
await this.connection.invoke("LeaveWebsocketGroup", gameId).catch(() => { });
|
||||
},
|
||||
|
||||
async renderGame(gameId) {
|
||||
const response = await fetch(`/api/chess/${gameId}`);
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
this.renderFromState(gameId, await response.json());
|
||||
},
|
||||
|
||||
renderFromState(gameId, state) {
|
||||
const result = this.resultTextFromState(state);
|
||||
const stored = this.games.get(gameId);
|
||||
|
||||
if (stored) stored.result = result;
|
||||
|
||||
this.renderPieces(gameId, state.pieces);
|
||||
this.updateHeader(gameId, state.currentPlayer, state.moveHistory.length, state.isCheck);
|
||||
this.setResult(gameId, result);
|
||||
},
|
||||
|
||||
async handleMoveUpdate(gameId, moveDto, state) {
|
||||
if (!this.games.has(gameId)) return;
|
||||
|
||||
// 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);
|
||||
},
|
||||
|
||||
renderPieces(gameId, pieces) {
|
||||
this.clearBoard(gameId);
|
||||
|
||||
pieces.forEach(piece => {
|
||||
const square = document.getElementById(`sq-${gameId}-${piece.row * 8 + piece.col}`);
|
||||
|
||||
if (!square) return;
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = this.pieceImageUrl(piece);
|
||||
img.alt = piece.type;
|
||||
img.className = "chessPiece";
|
||||
img.draggable = false;
|
||||
square.appendChild(img);
|
||||
});
|
||||
},
|
||||
|
||||
clearBoard(gameId) {
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const square = document.getElementById(`sq-${gameId}-${i}`);
|
||||
|
||||
if (!square) continue;
|
||||
|
||||
square.innerHTML = "";
|
||||
square.classList.remove("previous-start", "previous-end");
|
||||
}
|
||||
},
|
||||
|
||||
highlightMove(gameId, moveDto) {
|
||||
document.getElementById(`sq-${gameId}-${moveDto.sourceRow * 8 + moveDto.sourceCol}`)?.classList.add("previous-start");
|
||||
document.getElementById(`sq-${gameId}-${moveDto.targetRow * 8 + moveDto.targetCol}`)?.classList.add("previous-end");
|
||||
},
|
||||
|
||||
updateHeader(gameId, currentPlayer, moveCount, isCheck) {
|
||||
const stored = this.games.get(gameId);
|
||||
const header = document.getElementById(`header-${gameId}`);
|
||||
|
||||
if (stored && header)
|
||||
header.textContent = this.headerText(stored, currentPlayer, moveCount, isCheck);
|
||||
},
|
||||
|
||||
gameLabel(stored) {
|
||||
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`;
|
||||
|
||||
const check = isCheck ? " • check" : "";
|
||||
return `${this.gameLabel(stored)} · move ${moveCount} · ${currentPlayer} to move${check}`;
|
||||
},
|
||||
|
||||
resultTextFromState(state) {
|
||||
if (state.isCheckmate)
|
||||
return `${state.currentPlayer === "White" ? "Black" : "White"} wins by checkmate`;
|
||||
if (state.isStalemate)
|
||||
return "Draw — stalemate";
|
||||
if (state.isThreefoldRepetition)
|
||||
return "Draw — threefold repetition";
|
||||
return null;
|
||||
},
|
||||
|
||||
setResult(gameId, text) {
|
||||
const card = document.getElementById(`card-${gameId}`);
|
||||
|
||||
if (!card) return;
|
||||
|
||||
let banner = card.querySelector(".gameResult");
|
||||
|
||||
if (!text) {
|
||||
banner?.remove();
|
||||
card.querySelector(".copyPgnBtn")?.remove();
|
||||
card.classList.remove("over");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!banner) {
|
||||
banner = document.createElement("div");
|
||||
banner.className = "gameResult";
|
||||
card.appendChild(banner);
|
||||
}
|
||||
|
||||
banner.textContent = text;
|
||||
card.classList.add("over");
|
||||
this.addCopyPgn(gameId, card);
|
||||
},
|
||||
|
||||
addCopyPgn(gameId, card) {
|
||||
if (card.querySelector(".copyPgnBtn")) return;
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "copyPgnBtn";
|
||||
btn.textContent = "Copy PGN";
|
||||
btn.onclick = (event) => { event.stopPropagation(); this.copyPgn(gameId); };
|
||||
card.appendChild(btn);
|
||||
|
||||
// Prefetch now (while the game is still in memory) so copy works during the
|
||||
// brief window before the finished game is cleaned up.
|
||||
fetch(`/api/chess/${gameId}/pgn`)
|
||||
.then(r => r.ok ? r.text() : null)
|
||||
.then(t => { if (t) this.pgns.set(gameId, t); })
|
||||
.catch(() => { });
|
||||
},
|
||||
|
||||
async copyPgn(gameId) {
|
||||
let pgn = this.pgns.get(gameId);
|
||||
|
||||
if (!pgn) {
|
||||
try {
|
||||
const r = await fetch(`/api/chess/${gameId}/pgn`);
|
||||
if (r.ok) pgn = await r.text();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (!pgn) {
|
||||
alert("PGN is no longer available for this game.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(pgn);
|
||||
alert("📋 PGN copied to clipboard!");
|
||||
} catch {
|
||||
alert("Couldn't access the clipboard. Here's the PGN:\n\n" + pgn);
|
||||
}
|
||||
},
|
||||
|
||||
enterFullscreen(gameId) {
|
||||
document.getElementById(`card-${gameId}`)?.classList.add("fullscreen");
|
||||
document.body.classList.add("fullscreen-open");
|
||||
},
|
||||
|
||||
exitFullscreen(gameId, event) {
|
||||
event?.stopPropagation();
|
||||
document.getElementById(`card-${gameId}`)?.classList.remove("fullscreen");
|
||||
document.body.classList.remove("fullscreen-open");
|
||||
},
|
||||
|
||||
pieceImageUrl(piece) {
|
||||
const color = piece.color === 0 ? "White" : "Black";
|
||||
return `/images/Chess Images/${color}${this.pieceTypeNames[piece.type]}.svg`;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("load", () => Spectate.init());
|
||||
|
||||
console.log("Spectate.js loaded");
|
||||
@@ -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");
|
||||
@@ -1,5 +1,65 @@
|
||||
let lastPgn = null;
|
||||
|
||||
async function showCopyPgn(gameId) {
|
||||
try {
|
||||
lastPgn = await ChessAPI.getPgn(gameId);
|
||||
const btn = document.getElementById("copyPgnBtn");
|
||||
if (btn) btn.style.display = "";
|
||||
} catch (err) {
|
||||
console.warn("Could not load PGN.", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPgn() {
|
||||
if (!lastPgn) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(lastPgn);
|
||||
alert("📋 PGN copied to clipboard!");
|
||||
} catch {
|
||||
alert("Couldn't access the clipboard. Here's the PGN:\n\n" + lastPgn);
|
||||
}
|
||||
}
|
||||
|
||||
function resetCopyPgn() {
|
||||
lastPgn = null;
|
||||
const btn = document.getElementById("copyPgnBtn");
|
||||
if (btn) btn.style.display = "none";
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
if (!gameId || !playerId) return;
|
||||
|
||||
try {
|
||||
await ChessAPI.forfeit(gameId, playerId);
|
||||
} catch (err) {
|
||||
console.warn("Could not forfeit previous game.", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewGame() {
|
||||
closeMenu();
|
||||
await ChessSignalR.stopConnection();
|
||||
await forfeitCurrentGame();
|
||||
resetCopyPgn();
|
||||
|
||||
try {
|
||||
const gameData = await ChessAPI.joinGame();
|
||||
@@ -16,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);
|
||||
@@ -24,11 +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();
|
||||
@@ -42,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);
|
||||
@@ -62,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
@@ -0,0 +1,759 @@
|
||||
# R&D Findings: Swappable C++ Chess Engine via P/Invoke
|
||||
|
||||
**Team:** Architect, Backend Engineer, Performance Engineer, DevOps Engineer (orchestrated by team lead)
|
||||
**Date:** 2026-05-28
|
||||
**Status:** Complete — engine *infrastructure* design only; the C++ search logic is intentionally left as an empty, compilable stub for the user to implement.
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
We investigated how to host a custom chess engine written in C++ (compiled to `chess_engine.dll` for local Windows debugging and `libchess_engine.so` for the Linux server) behind a C# "middleman" wrapper that shares an interface with the existing `Stockfish.cs`, so the two are swappable by config. The key conclusion: the existing engine already uses a **string contract (FEN in → UCI move out)**, so the cleanest design mirrors it exactly — an opaque-handle `extern "C"` ABI, a `[LibraryImport]` P/Invoke wrapper, and a new `IChessEngine` interface that both `Stockfish` and the new `CustomChessEngine` implement, selected by a factory reading `appsettings`. The recommended next step is to land the **infrastructure** (interface, factory, P/Invoke wrapper, CMake project, empty C++ stub, build/packaging) and verify the round trip end-to-end with the stub returning a placeholder move — *before* any chess logic is written.
|
||||
|
||||
> **Naming reconciliation:** the specialists used varying names for the native lib (`ChessEngine`, `chessengine`, `chess_engine`). This document standardizes on **logical name `chess_engine`** → `chess_engine.dll` (Windows) / `libchess_engine.so` (Linux), matching the Backend and DevOps proposals. Adjust the snippets from the Architect/Performance sections accordingly.
|
||||
|
||||
### High-Level Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Managed["C# / .NET 8"]
|
||||
CTRL[ChessController] --> FAC[IChessEngineFactory]
|
||||
FAC -->|reads appsettings| OPT[ChessEngineOptions]
|
||||
FAC -->|Stockfish| SF[Stockfish : IChessEngine]
|
||||
FAC -->|Custom| CE[CustomChessEngine : IChessEngine]
|
||||
CTRL --> ORCH[IComputerMoveOrchestrator]
|
||||
ORCH -->|GetBestMoveAsync fen| IFACE[/IChessEngine/]
|
||||
SF -.implements.-> IFACE
|
||||
CE -.implements.-> IFACE
|
||||
ORCH --> RULES[IChessService rules]
|
||||
ORCH --> HUB[ChessHub SignalR]
|
||||
end
|
||||
subgraph Native["C++ shared library"]
|
||||
CE -->|P/Invoke once per move| ABI["extern C ABI<br/>engine_best_move(fen, out)"]
|
||||
ABI --> SEARCH["self-contained search<br/>bitboards, zero managed callbacks"]
|
||||
end
|
||||
SF -->|child process stdio| SFEXE[stockfish-*.exe]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Findings by Area
|
||||
|
||||
### Architect: Engine Abstraction & Swap Mechanism
|
||||
|
||||
#### Current State
|
||||
|
||||
Two distinct concerns are easy to confuse:
|
||||
|
||||
- **`IChessService` / `ChessService`** — the *rules engine* (move legality, check/mate). NOT the thing being swapped. `JoshHeaps.Net\Services\Interfaces\IChessService.cs:5-11`, `ChessService.cs:6`.
|
||||
- **`Stockfish`** — the *AI move-selection engine* (the thing being swapped). `Services\Implementations\Stockfish.cs:13`.
|
||||
|
||||
`Stockfish` is a concrete `sealed` class with **no interface**, carrying three mixed responsibilities:
|
||||
|
||||
1. **Engine lifecycle/IO** — spawns a child process, UCI handshake, `GetBestMoveAsync(fen)`. `Stockfish.cs:13-111`.
|
||||
2. **Orchestration** — `MakeMove(GameState, IHubContext<ChessHub>, IChessService)` gets a move, converts it, calls the rules service, broadcasts over SignalR. `Stockfish.cs:113-126`. Engine-agnostic glue.
|
||||
3. **FEN/UCI translation** — `StockfishHelpers.ToFen(...)` / `ToMoveDto(...)`. `Stockfish.cs:129-253`. Also engine-agnostic.
|
||||
|
||||
Coupling points where the concrete type leaks:
|
||||
|
||||
- `GameState.Computer` typed as concrete `Stockfish?` — domain model → implementation (inverted dependency). `Models\GameState.cs:46`, `using ...Implementations;` at `GameState.cs:1`.
|
||||
- Controller hand-constructs the engine: `gameState.Computer = new(difficulty);` `Controllers\ChessController.cs:45`. No DI, no abstraction.
|
||||
- Orchestration via concrete instance: `gameState.Computer.MakeMove(...)` at `ChessController.cs:60` and `:192`.
|
||||
- Disposal via concrete type: `await game.Computer.DisposeAsync();` `ChessController.cs:248`.
|
||||
- `Stockfish` is **not** registered in DI; `Program.cs:21-23` registers other services but the engine is `new`-ed inline per game.
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class GameState {
|
||||
+Stockfish? Computer
|
||||
+bool IsVsComputer
|
||||
}
|
||||
class ChessController
|
||||
class Stockfish {
|
||||
+GetBestMoveAsync(fen) Task~string~
|
||||
+MakeMove(state, hub, chessService) Task
|
||||
+DisposeAsync() ValueTask
|
||||
}
|
||||
class StockfishHelpers {
|
||||
+ToFen(GameState)$ string
|
||||
+ToMoveDto(uci, state, playerId)$ MoveDto
|
||||
}
|
||||
class IChessService { <<interface>> }
|
||||
class ChessService
|
||||
|
||||
IChessService <|.. ChessService
|
||||
ChessController --> GameState : new()s Stockfish into
|
||||
GameState *-- Stockfish : owns concrete
|
||||
ChessController ..> Stockfish : MakeMove() / DisposeAsync()
|
||||
Stockfish ..> StockfishHelpers
|
||||
Stockfish ..> IChessService
|
||||
Stockfish ..> ChessHub : broadcasts
|
||||
note for GameState "Models depends on\nServices.Implementations — inverted"
|
||||
note for Stockfish "Orchestration lives inside engine —\nwould duplicate across 2 engines"
|
||||
```
|
||||
|
||||
#### Findings
|
||||
|
||||
1. **No interface → cannot swap.** `Stockfish` is `sealed`, concrete-only (`Stockfish.cs:13`). Adding `CustomChessEngine` today means touching `GameState`, the controller, and disposal. *Impact:* an `IChessEngine` abstraction is the core requirement.
|
||||
2. **Domain model depends on a concrete service.** `GameState.cs:46` + `using ...Implementations` at `GameState.cs:1`. *Impact:* must become `IChessEngine?` (or leave the model entirely).
|
||||
3. **Engine hand-constructed in controller, no DI.** `ChessController.cs:45`. *Impact:* engine selection can't be config-driven; this is the seam for a factory.
|
||||
4. **Orchestration lives in the engine.** `Stockfish.MakeMove(...)` (`Stockfish.cs:113-126`) is not Stockfish-specific. *Impact:* copying it into `CustomChessEngine` duplicates rules-call + broadcast wiring; lift it to an orchestrator.
|
||||
5. **FEN/UCI translation is engine-agnostic** (`Stockfish.cs:169`, `:131`). *Impact:* keep shared (rename to `ChessEngineHelpers`), don't duplicate.
|
||||
6. **Engine lifecycle is game-scoped, not DI-scoped.** Created in `CreateGame` (`ChessController.cs:45`), disposed in the game-removal timer (`ChessController.cs:247-248`); the constructor spawns a process and blocks on UCI handshake (`Stockfish.cs:54,72,77`). *Impact:* a plain singleton won't fit — use a **per-game factory** producing disposable instances.
|
||||
7. **`MakeMove` is fire-and-forget returning non-generic `Task`** (`Stockfish.cs:113`). *Impact:* when lifted, the orchestrator should return the `(MoveDto, MoveResultDto)` it produced (per CodingStyle "Void Avoidance").
|
||||
|
||||
#### Suggested Approach
|
||||
|
||||
Three roles: `IChessEngine` (swappable contract), `IChessEngineFactory` (per-game creation from config), `IComputerMoveOrchestrator` (lifted glue).
|
||||
|
||||
```csharp
|
||||
// Services/Interfaces/IChessEngine.cs
|
||||
public interface IChessEngine : IAsyncDisposable
|
||||
{
|
||||
int Skill { get; }
|
||||
// UCI long-algebraic, e.g. "e2e4", "e7e8q"
|
||||
Task<string> GetBestMoveAsync(string fen, CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
```csharp
|
||||
// Services/Implementations/Stockfish.cs (minimal change)
|
||||
public sealed class Stockfish : IChessEngine // was: IAsyncDisposable
|
||||
{
|
||||
public int Skill => _skill;
|
||||
public Task<string> GetBestMoveAsync(string fen, CancellationToken ct = default) { /* existing body */ }
|
||||
public ValueTask DisposeAsync() { /* unchanged */ }
|
||||
// DELETE MakeMove(...) -> moves to IComputerMoveOrchestrator
|
||||
}
|
||||
// StockfishHelpers -> rename to ChessEngineHelpers in Services/Implementations/ChessEngineHelpers.cs (body unchanged)
|
||||
```
|
||||
|
||||
```csharp
|
||||
// Services/Interfaces/IChessEngineFactory.cs
|
||||
public interface IChessEngineFactory { IChessEngine Create(int skill); }
|
||||
|
||||
// Services/Implementations/ChessEngineFactory.cs
|
||||
public sealed class ChessEngineFactory(IOptions<ChessEngineOptions> options) : IChessEngineFactory
|
||||
{
|
||||
private readonly ChessEngineKind _kind = options.Value.Engine;
|
||||
public IChessEngine Create(int skill) => _kind switch
|
||||
{
|
||||
ChessEngineKind.Custom => new CustomChessEngine(skill),
|
||||
ChessEngineKind.Stockfish => new Stockfish(skill),
|
||||
_ => throw new InvalidOperationException($"Unknown engine '{_kind}'.")
|
||||
};
|
||||
}
|
||||
public enum ChessEngineKind { Stockfish, Custom }
|
||||
public sealed class ChessEngineOptions
|
||||
{
|
||||
public const string SectionName = "ChessEngine";
|
||||
public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish;
|
||||
}
|
||||
```
|
||||
|
||||
```csharp
|
||||
// Program.cs (near :21-23)
|
||||
builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName));
|
||||
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
|
||||
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
|
||||
```
|
||||
|
||||
```jsonc
|
||||
// appsettings.json — flip to "Custom" to swap (override per-env in appsettings.Development.json)
|
||||
{ "ChessEngine": { "Engine": "Stockfish" } }
|
||||
```
|
||||
|
||||
```csharp
|
||||
// Services/Implementations/ComputerMoveOrchestrator.cs (lifted from Stockfish.MakeMove, engine-agnostic)
|
||||
public interface IComputerMoveOrchestrator
|
||||
{
|
||||
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine, CancellationToken ct = default);
|
||||
}
|
||||
public sealed class ComputerMoveOrchestrator(IHubContext<ChessHub> chessHub, IChessService chessService) : IComputerMoveOrchestrator
|
||||
{
|
||||
public async Task<(MoveDto, MoveResultDto)> PlayAsync(GameState state, IChessEngine engine, CancellationToken ct = default)
|
||||
{
|
||||
var uci = await engine.GetBestMoveAsync(state.ToFen(), ct);
|
||||
var move = uci.ToMoveDto(state, state.CurrentPlayer == PieceColor.White ? state.WhitePlayerId : state.BlackPlayerId);
|
||||
var result = chessService.MakeMove(state, move);
|
||||
await chessHub.Clients.Group(state.GameId.ToString())
|
||||
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, ct);
|
||||
return (move, result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`GameState.Computer` recommendation:** keep the reference on `GameState` but retype to `IChessEngine?` (fixes Finding 2) — do *not* go full service-injection. Rationale: the engine is a stateful, per-game, disposable resource whose lifetime is managed by `ScheduleRemoveGame` (`ChessController.cs:247-248`); live games live in the controller's static `ConcurrentDictionary` (`ChessController.cs:20`), not DI. Only the engine's *construction* (factory) and *orchestration* (orchestrator) move out.
|
||||
|
||||
```csharp
|
||||
// ChessController changes (inject factory + orchestrator into the primary ctor at :12-15)
|
||||
gameState.Computer = engineFactory.Create(difficulty); // was :45 new(difficulty)
|
||||
await orchestrator.PlayAsync(gameState, gameState.Computer); // was :60
|
||||
if (gameState.IsVsComputer && gameState.Computer is not null) // was :192
|
||||
queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!));
|
||||
await game.Computer.DisposeAsync(); // :248 unchanged (IChessEngine : IAsyncDisposable)
|
||||
```
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class IChessEngine {
|
||||
<<interface>>
|
||||
+int Skill
|
||||
+GetBestMoveAsync(fen, ct) Task~string~
|
||||
+DisposeAsync() ValueTask
|
||||
}
|
||||
class Stockfish
|
||||
class CustomChessEngine { -P/Invoke chess_engine (.dll/.so) }
|
||||
class IChessEngineFactory { <<interface>> +Create(skill) IChessEngine }
|
||||
class ChessEngineFactory
|
||||
class ChessEngineOptions { +ChessEngineKind Engine }
|
||||
class IComputerMoveOrchestrator { <<interface>> +PlayAsync(state, engine, ct) }
|
||||
class ComputerMoveOrchestrator
|
||||
class GameState { +IChessEngine? Computer }
|
||||
class ChessController
|
||||
class ChessEngineHelpers
|
||||
|
||||
IChessEngine <|.. Stockfish
|
||||
IChessEngine <|.. CustomChessEngine
|
||||
IChessEngineFactory <|.. ChessEngineFactory
|
||||
IComputerMoveOrchestrator <|.. ComputerMoveOrchestrator
|
||||
ChessEngineFactory ..> ChessEngineOptions : reads config
|
||||
ChessEngineFactory ..> Stockfish : creates
|
||||
ChessEngineFactory ..> CustomChessEngine : creates
|
||||
ChessController ..> IChessEngineFactory
|
||||
ChessController ..> IComputerMoveOrchestrator
|
||||
GameState o-- IChessEngine : holds abstraction
|
||||
ComputerMoveOrchestrator ..> IChessEngine
|
||||
ComputerMoveOrchestrator ..> ChessEngineHelpers
|
||||
ComputerMoveOrchestrator ..> IChessService
|
||||
note for GameState "Now depends on Interfaces (DIP fixed)"
|
||||
note for CustomChessEngine "C++ body empty; P/Invoke contract only"
|
||||
```
|
||||
|
||||
#### Open Questions
|
||||
|
||||
1. Does the custom engine speak FEN-in / UCI-out? (Assumed yes — confirm before locking the interface.)
|
||||
2. Per-game instance vs shared singleton for the in-process native engine (a `.dll`/`.so` may be cheap enough to share, unlike a Stockfish process).
|
||||
3. Skill/options surface — `int skill` vs an `EngineOptions` object if tunables diverge from Stockfish's `(skill, hash)` (`Stockfish.cs:20`).
|
||||
4. Cancellation/timeout — interface has a `CancellationToken` but nothing wires it today (`BackgroundTaskQueue.cs:11` fire-and-forgets).
|
||||
5. Concurrency — `GetBestMoveAsync` is not re-entrant per instance (single stdout `Channel`, `Stockfish.cs:17`); per-game instances make this moot today.
|
||||
|
||||
---
|
||||
|
||||
### Backend Engineer: Native Interop & I/O Contract
|
||||
|
||||
#### Current State
|
||||
|
||||
The load-bearing method is `GetBestMoveAsync(string fen)` at `Stockfish.cs:80-95`: writes `position fen <fen>` + `go depth N` to stdin (`:82-83`), reads stdout until a line starts with `bestmove` (`:88`), returns the second token — a raw UCI string like `e2e4` / `e7e8q` (`:90`). Input is produced by `ToFen()` (`Stockfish.cs:169-214`); output is consumed by `ToMoveDto()` (`Stockfish.cs:131-162`, parses chars `uci[0..4]` into `MoveDto`, `MoveDto.cs:3-20`). Consumer: `ChessController.cs:191-192` queues `gameState.Computer.MakeMove(...)`.
|
||||
|
||||
**There is no native interop today.** A repo-wide grep for `DllImport|LibraryImport|NativeLibrary|Marshal|extern` returns only an unrelated hit in vendored `jquery.js`. Project targets `net8.0`, `Nullable=enable`, `ImplicitUsings=enable` (`JoshHeaps.Net.csproj:3-7`).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as ChessController
|
||||
participant SF as Stockfish (C#)
|
||||
participant P as stockfish.exe (subprocess)
|
||||
C->>SF: MakeMove(state) / GetBestMoveAsync(fen)
|
||||
Note over SF: state.ToFen() builds FEN
|
||||
SF->>P: stdin "position fen <FEN>"
|
||||
SF->>P: stdin "go depth N"
|
||||
P-->>SF: stdout "info ..."
|
||||
P-->>SF: stdout "bestmove e2e4"
|
||||
Note over SF: Split(' ')[1] => "e2e4"
|
||||
SF-->>C: "e2e4" (UCI)
|
||||
Note over C: uci.ToMoveDto(state) => MoveDto
|
||||
```
|
||||
|
||||
#### Findings
|
||||
|
||||
1. **The boundary is already a pure string pair** (`Stockfish.cs:80,90`). *Impact:* the native ABI should mirror it exactly — `const char* fen` in, `char*` UCI out. No struct marshalling needed.
|
||||
2. **No `IChessEngine` abstraction; consumers bind the concrete type** (`GameState.cs:46`, `ChessController.cs:191-192`). *Impact:* the middleman implements the Architect's interface; the `GameState` retype is a cross-cutting dependency.
|
||||
3. **`MakeMove` mixes engine + SignalR/board concerns** (`Stockfish.cs:113-126`). *Impact:* keep it shared (orchestrator), not per-engine. The native-specific surface of `CustomChessEngine` is only `GetBestMoveAsync`.
|
||||
4. **The UCI string is the contract anchor.** As long as the native engine emits a 4-or-5-char UCI move, the whole downstream pipeline (`MoveDto` → `IChessService.MakeMove` → SignalR) is unchanged (`Stockfish.cs:131,169`).
|
||||
5. **Resource-shipping pattern is established** (`csproj:14-18` copies `Resources/**`). *Impact:* the `.dll`/`.so` ship the same way.
|
||||
|
||||
#### Suggested Approach
|
||||
|
||||
**Contract: strings (FEN in / UCI out), not a binary struct.** It is byte-identical to today's contract (so `ToFen`/`ToMoveDto` are untouched); FEN/UCI are stable ASCII (no layout/packing/endianness/enum-width to keep in sync); the user only writes string parsing in C++; per-move data is tiny. **Buffer-ownership rule:** the C# caller owns the output buffer; the engine only writes into it and never allocates returned strings — sidesteps cross-allocator free bugs.
|
||||
|
||||
```c
|
||||
// native/chess_engine/include/chess_engine.h
|
||||
#ifndef CHESS_ENGINE_H
|
||||
#define CHESS_ENGINE_H
|
||||
#include <stddef.h>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#ifdef CHESS_ENGINE_BUILD
|
||||
#define CHESS_API __declspec(dllexport)
|
||||
#else
|
||||
#define CHESS_API __declspec(dllimport)
|
||||
#endif
|
||||
#define CHESS_CALL __cdecl
|
||||
#else
|
||||
#define CHESS_API __attribute__((visibility("default")))
|
||||
#define CHESS_CALL
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct ChessEngine* EngineHandle; // opaque; host never dereferences
|
||||
|
||||
enum {
|
||||
CHESS_OK = 0, CHESS_ERR_NULL_HANDLE = -1, CHESS_ERR_BAD_FEN = -2,
|
||||
CHESS_ERR_NO_MOVE = -3, CHESS_ERR_BUFFER = -4, CHESS_ERR_INTERNAL = -5
|
||||
};
|
||||
|
||||
CHESS_API EngineHandle CHESS_CALL engine_create(const char* options); // options e.g. "skill=20;hash=256" or NULL
|
||||
CHESS_API int CHESS_CALL engine_set_option(EngineHandle, const char* name, const char* value);
|
||||
CHESS_API int CHESS_CALL engine_best_move(EngineHandle, const char* fen, char* out_buf, int out_len); // writes "e2e4\0"
|
||||
CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len);
|
||||
CHESS_API void CHESS_CALL engine_destroy(EngineHandle); // safe with NULL
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
```
|
||||
|
||||
```cpp
|
||||
// native/chess_engine/src/chess_engine.cpp — EMPTY stub; compiles, returns placeholder
|
||||
#define CHESS_ENGINE_BUILD
|
||||
#include "chess_engine.h"
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
struct ChessEngine { std::string options; }; // put TT, tables, etc. here later
|
||||
|
||||
static int copy_out(const char* src, char* out, int cap) {
|
||||
if (!out || cap <= 0) return CHESS_ERR_BUFFER;
|
||||
const size_t need = std::strlen(src) + 1;
|
||||
if (need > (size_t)cap) return CHESS_ERR_BUFFER;
|
||||
std::memcpy(out, src, need);
|
||||
return CHESS_OK;
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) {
|
||||
auto* e = new (std::nothrow) ChessEngine();
|
||||
if (e && options) e->options = options;
|
||||
return e;
|
||||
}
|
||||
CHESS_API int CHESS_CALL engine_set_option(EngineHandle e, const char*, const char*) {
|
||||
return e ? CHESS_OK : CHESS_ERR_NULL_HANDLE;
|
||||
}
|
||||
CHESS_API int CHESS_CALL engine_best_move(EngineHandle e, const char* fen, char* out, int cap) {
|
||||
if (!e) return CHESS_ERR_NULL_HANDLE;
|
||||
if (!fen || !*fen) return CHESS_ERR_BAD_FEN;
|
||||
// TODO: parse fen, search, produce a real UCI move.
|
||||
return copy_out("e2e4", out, cap); // placeholder
|
||||
}
|
||||
CHESS_API int CHESS_CALL engine_version(char* out, int cap) { return copy_out("custom-engine 0.0.1-stub", out, cap); }
|
||||
CHESS_API void CHESS_CALL engine_destroy(EngineHandle e) { delete e; }
|
||||
}
|
||||
```
|
||||
|
||||
**ABI notes:** `extern "C"` kills name mangling; `CHESS_API` = `__declspec(dllexport)` (MSVC, when `CHESS_ENGINE_BUILD` defined) or `__attribute__((visibility("default")))` (GCC/Clang, pair with `-fvisibility=hidden`); `CHESS_CALL` pins `__cdecl` on Windows, empty (SysV default) on Linux.
|
||||
|
||||
```csharp
|
||||
// Services/Implementations/CustomChessEngine.cs
|
||||
public sealed class CustomChessEngine : IChessEngine // IAsyncDisposable via IChessEngine
|
||||
{
|
||||
private readonly EngineSafeHandle _handle;
|
||||
|
||||
public int Skill { get; }
|
||||
public CustomChessEngine(int skill = 20)
|
||||
{
|
||||
Skill = skill;
|
||||
var raw = NativeMethods.engine_create($"skill={skill}");
|
||||
if (raw == IntPtr.Zero) throw new InvalidOperationException("engine_create returned null.");
|
||||
_handle = new EngineSafeHandle(raw);
|
||||
}
|
||||
|
||||
public Task<string> GetBestMoveAsync(string fen, CancellationToken ct = default)
|
||||
=> Task.Run(() => BestMove(fen), ct); // native call is sync + CPU-bound; offload off request thread
|
||||
|
||||
private string BestMove(string fen)
|
||||
{
|
||||
Span<byte> outBuf = stackalloc byte[16]; // UCI <= 5 chars + NUL
|
||||
bool added = false;
|
||||
try
|
||||
{
|
||||
_handle.DangerousAddRef(ref added);
|
||||
int rc;
|
||||
unsafe { fixed (byte* p = outBuf) rc = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, p, outBuf.Length); }
|
||||
ThrowIfError(rc);
|
||||
int nul = outBuf.IndexOf((byte)0);
|
||||
return System.Text.Encoding.ASCII.GetString(outBuf[..(nul < 0 ? outBuf.Length : nul)]);
|
||||
}
|
||||
finally { if (added) _handle.DangerousRelease(); }
|
||||
}
|
||||
|
||||
private static void ThrowIfError(int rc) { if (rc != 0) throw rc switch {
|
||||
-2 => new ArgumentException("CHESS_ERR_BAD_FEN"),
|
||||
-3 => new InvalidOperationException("No move (mate/stalemate)"),
|
||||
-4 => new InvalidOperationException("Output buffer too small"),
|
||||
_ => new InvalidOperationException($"Native engine error {rc}") }; }
|
||||
|
||||
public ValueTask DisposeAsync() { _handle.Dispose(); return ValueTask.CompletedTask; }
|
||||
|
||||
private sealed class EngineSafeHandle : SafeHandle
|
||||
{
|
||||
public EngineSafeHandle(IntPtr h) : base(IntPtr.Zero, true) => SetHandle(h);
|
||||
public override bool IsInvalid => handle == IntPtr.Zero;
|
||||
protected override bool ReleaseHandle() { NativeMethods.engine_destroy(handle); return true; }
|
||||
}
|
||||
|
||||
private static partial class NativeMethods
|
||||
{
|
||||
private const string Lib = "chess_engine"; // -> chess_engine.dll / libchess_engine.so
|
||||
static NativeMethods() => NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, Resolve);
|
||||
private static IntPtr Resolve(string name, Assembly asm, DllImportSearchPath? path)
|
||||
{
|
||||
if (name != Lib) return IntPtr.Zero;
|
||||
string file = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "chess_engine.dll" : "libchess_engine.so";
|
||||
string probe = Path.Combine(AppContext.BaseDirectory, "Resources", file);
|
||||
return File.Exists(probe) && NativeLibrary.TryLoad(probe, out var h) ? h : NativeLibrary.Load(name, asm, path);
|
||||
}
|
||||
|
||||
[LibraryImport(Lib, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
|
||||
internal static partial IntPtr engine_create(string? options);
|
||||
|
||||
[LibraryImport(Lib, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
|
||||
internal static unsafe partial int engine_best_move(IntPtr engine, string fen, byte* outBuf, int outLen);
|
||||
|
||||
[LibraryImport(Lib)]
|
||||
[UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
|
||||
internal static partial void engine_destroy(IntPtr engine);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why these C# choices:** `[LibraryImport]` (source-generated, AOT/trim-safe, no runtime IL stub, compile-time diagnostics) over `[DllImport]`; `StringMarshalling.Utf8` matches `const char*`; a `DllImportResolver` probes `Resources/` first then falls back to default search; `SafeHandle` guarantees `engine_destroy` runs exactly once; return codes map to typed exceptions; `Task.Run` adapts the sync native call to the async interface (for a real long search, prefer one dedicated long-running thread per instance over thread-pool churn).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as ChessController
|
||||
participant CE as CustomChessEngine (C#)
|
||||
participant TP as Task.Run (threadpool)
|
||||
participant N as chess_engine.dll/.so
|
||||
C->>CE: GetBestMoveAsync(state.ToFen())
|
||||
CE->>TP: offload sync native call
|
||||
Note over TP: stackalloc byte[16] out_buf (host-owned)
|
||||
TP->>N: engine_best_move(handle, fen, out_buf, 16)
|
||||
Note over N: parse FEN, search, write "e2e4\0"
|
||||
N-->>TP: CHESS_OK; out_buf filled
|
||||
TP-->>CE: "e2e4"
|
||||
CE-->>C: "e2e4" (same UCI as Stockfish)
|
||||
Note over C: uci.ToMoveDto(state) => MoveDto (unchanged)
|
||||
```
|
||||
|
||||
#### Open Questions
|
||||
|
||||
1. Should `MakeMove`/orchestration be on the interface or shared via the orchestrator? (Recommend orchestrator — aligns with Architect.)
|
||||
2. Who owns the `GameState.Computer` retype (Architect vs Backend)? Cross-cutting.
|
||||
3. Build/packaging: CMake + MSBuild copy vs commit binaries (see DevOps).
|
||||
4. Search timeout/cancellation: add `engine_stop(handle)` + token-aware wrapper, or fixed-depth like Stockfish's `go depth N` (`Stockfish.cs:83`)?
|
||||
5. `options` string format (`"key=value;..."`) vs per-option `engine_set_option`?
|
||||
6. Concurrency model — one move per instance at a time (current queue suggests yes)?
|
||||
|
||||
---
|
||||
|
||||
### Performance Engineer: Interop Boundary & Hot-Path Strategy
|
||||
|
||||
#### Current State
|
||||
|
||||
Two distinct things are being conflated:
|
||||
|
||||
1. **The Stockfish path is already fast and well-structured** — one warm process (`Stockfish.cs:40-54`), two text commands per move, block on stdout (`:82-93`). The cost is the *search* (`go depth {_skill}`, `:83`), not the pipe. **Stockfish is not slow.** It is also already a self-contained native search — the model to preserve.
|
||||
2. **The C# `ChessService` is the genuinely slow thing** and the real motivation:
|
||||
- **Object-graph board:** `GameState.Board` is `ChessPiece?[,]` (`GameState.cs:11`) of heap `ChessPiece` objects each with a `string Id` (`ChessPiece.cs:5`) — pointer chase / cache miss per square touch.
|
||||
- **Allocation per pseudo-move:** legality calls `CloneGameState` (`ChessService.cs:319,453-483`) allocating a new `GameState`, `ChessPiece[8,8]`, `List<string>`, and a `ChessPiece` per piece — *every candidate move*.
|
||||
- **LINQ in the inner loop:** `IsSquareAttacked` does `.Where(...).ToList()` + regenerates enemy moves (`ChessService.cs:396-407`); lookups by string id via `FirstOrDefault` (`:110,126`).
|
||||
- Fine for validating one human move; orders of magnitude away from a search loop. Correct thing to move to C++.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["ChessController.MakeMove :192"] --> B["queue.Queue (IBackgroundTaskQueue)"]
|
||||
B --> C["Stockfish.MakeMove :113"]
|
||||
C --> D["state.ToFen() ~70B :169"]
|
||||
D --> E["stdin 'position fen' + 'go depth N' :82-83"]
|
||||
E --> F["stdin pipe -> stockfish.exe"]
|
||||
F --> G["NATIVE SEARCH (seconds)<br/>millions of nodes, zero managed calls"]
|
||||
G --> H["stdout 'bestmove e2e4' :86-92"]
|
||||
H --> I["parse ~5B UCI -> MoveDto :117,136"]
|
||||
style G fill:#2d6a2d,color:#fff
|
||||
style F fill:#7a5c00,color:#fff
|
||||
style H fill:#7a5c00,color:#fff
|
||||
```
|
||||
|
||||
#### Findings
|
||||
|
||||
1. **The move boundary is provably not the hot path.** Per move: ~70-byte FEN in, ~5-byte UCI out — sub-microsecond marshalling vs a multi-second search; the boundary is ~6+ orders of magnitude cheaper than the work it gates. The string contract is correct and will never bottleneck. Production already proves it via a *heavier* transport (OS pipes).
|
||||
2. **Forbidden anti-pattern: a chatty per-node boundary.** A `LibraryImport` P/Invoke transition is ~1–2 ns, but a search visits millions of nodes/sec. A managed callback per node (move-gen/eval) adds a GC-tracked frame, write-barrier exposure, and loss of native inlining on the hottest loop — defeating the whole point. **Rule: the native search owns move-gen, make/unmake, and eval; zero managed callbacks below the once-per-move boundary.**
|
||||
3. **Board representation is native-internal, not a marshalling concern.** Use **bitboards** inside C++ (~12 `uint64_t` + occupancy/flags); make/unmake and attacks become `&`/`|`/shifts/`popcnt`/`tzcnt` instead of pointer-chasing + LINQ (`ChessService.cs:392-410`). None of it crosses the boundary — C# keeps its `GameState` graph for rendering/human-move validation; the engine rebuilds bitboards from the FEN.
|
||||
4. **Threading/async.** The search is CPU-bound/synchronous; the controller already enqueues on a background queue (`ChessController.cs:192`) so the SignalR thread isn't blocked. Wrap the blocking P/Invoke in `Task.Run`; results push back over `IHubContext<ChessHub>` as today (`Stockfish.cs:125`). **Parallel search (Lazy SMP) stays 100% native.** **Cancellation = one atomic flag:** `engine_stop()` sets `std::atomic<bool>` polled between nodes — crosses the boundary once on cancel, never per node.
|
||||
|
||||
| Operation | Approx. cost | Frequency |
|
||||
|---|---|---|
|
||||
| P/Invoke transition (blittable, `LibraryImport`) | ~1–2 ns | once per move |
|
||||
| Marshal ~70B FEN in + ~5B UCI out | < 1 µs | once per move |
|
||||
| `ToFen()` string build (`Stockfish.cs:169`) | low µs | once per move |
|
||||
| **Native search (`go depth N`)** | **~0.1–several s** | **once per move** |
|
||||
| Hypothetical managed eval callback **per node** | ~tens of ns × millions/sec | ❌ never — forbidden |
|
||||
|
||||
#### Suggested Approach
|
||||
|
||||
Cross the boundary **once per move**; keep the loop fully native.
|
||||
|
||||
```cpp
|
||||
// RECOMMENDED: self-contained native search
|
||||
extern "C" int engine_best_move(ChessEngine* e, const char* fen, char* out, int cap) {
|
||||
Position pos = parse_fen(fen); // build bitboards ONCE
|
||||
g_stop.store(false);
|
||||
Move best = search(pos, e->depth); // millions of nodes, NO callbacks out
|
||||
return write_uci(best, out, cap); // ~5 bytes back
|
||||
}
|
||||
```
|
||||
```cpp
|
||||
// FORBIDDEN: chatty boundary — do NOT do this
|
||||
int search(Position& pos, int depth) {
|
||||
for (Move m : managed_generate_moves(pos)) // P/Invoke OUT per node
|
||||
eval += managed_eval_callback(pos); // managed frame per node — death
|
||||
}
|
||||
```
|
||||
|
||||
**Optional blittable-struct contract — recommend DEFER.** If profiling ever showed FEN parse dominating (it won't at one call/move), you *could* pass a `[StructLayout(LayoutKind.Sequential)]` `NativePosition` (12 bitboards + flags) by `in`/`ref` (fully blittable, no marshalling). But it adds a second board-layout source-of-truth and couples C# to the engine's internals. **Ship the FEN/UCI string contract; don't build the struct path until a profiler proves it's needed.**
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["ChessController.MakeMove :192"] --> B["queue.Queue -> Task.Run (SignalR thread freed)"]
|
||||
B --> C["state.ToFen() ~70B"]
|
||||
C --> D{{"engine_best_move(handle, fen, buf)<br/>ONE P/Invoke crossing (~1-2ns + <1us marshal)"}}
|
||||
D --> E["NATIVE: parse_fen -> bitboards (x12 ulong)"]
|
||||
E --> F["NATIVE SEARCH LOOP<br/>make/unmake on bitboards, native eval<br/>Lazy SMP threads, poll atomic g_stop<br/>ZERO managed callbacks"]
|
||||
F --> G{{"return ~5B UCI move<br/>ONE crossing back"}}
|
||||
G --> H["parse UCI -> MoveDto -> SignalR (Stockfish.cs:117-125)"]
|
||||
I["engine_stop(handle)"] -. "once on cancel, NOT per node" .-> F
|
||||
style D fill:#1f4e79,color:#fff
|
||||
style G fill:#1f4e79,color:#fff
|
||||
style E fill:#2d6a2d,color:#fff
|
||||
style F fill:#2d6a2d,color:#fff
|
||||
```
|
||||
|
||||
**Build flags (coordinate with DevOps):** MSVC `/O2 /GL` + `/LTCG`, `/arch:AVX2` (matches the shipped AVX2 Stockfish, `Stockfish.cs:25`); GCC/Clang `-O3 -flto` with a baseline `-march` the server supports (the Linux Stockfish targets `sse41-popcnt`, `Stockfish.cs:28`) — avoid `-march=native` on a build host differing from the server.
|
||||
|
||||
#### Open Questions
|
||||
|
||||
1. **Deploy CPU baseline** — server's CPU floor dictates safe `-march`/`/arch` and whether `popcnt`/AVX2 bitboard intrinsics are guaranteed (Stockfish picks `avx2` Win / `sse41-popcnt` Linux, `Stockfish.cs:24-28`).
|
||||
2. Will the native engine fully replace or coexist with Stockfish? (Recommend a shared `IChessEngine`.)
|
||||
3. Engine lifetime/concurrency — one handle per game (like Stockfish today) vs reused across games (needs re-entrancy)?
|
||||
4. Search termination — fixed depth, nodes, or wall-clock (time-based makes `engine_stop` most useful)?
|
||||
5. Move-legality ownership — human moves validated by `ChessService.MakeMove` (`ChessController.cs:179`); two move generators risk divergence.
|
||||
|
||||
---
|
||||
|
||||
### DevOps Engineer: Cross-Platform Native Build & Packaging
|
||||
|
||||
#### Current State
|
||||
|
||||
- **Packaging:** `JoshHeaps.Net.csproj:14-18` copies the whole Resources folder as `Content` with `CopyToOutputDirectory=PreserveNewest` → lands in `bin\<cfg>\net8.0\Resources\` and `publish\Resources\`. Binaries are **committed** (`Resources\stockfish-windows-x86-64-avx2.exe`, `Resources\stockfish-ubuntu-x86-64-sse41-popcnt`).
|
||||
- **Runtime load:** `Stockfish.cs:23` `AppContext.BaseDirectory`; `:24-28` OS filename switch; `:30` `Path.Combine(baseDir,"Resources",fileName)`; `:32-36` existence check. Stockfish is a **child process** (`:40-54`), so there is no native-library load path today.
|
||||
- **CI/deploy is Linux-only:** `.github\workflows\deploy.yml:17` `runs-on: ubuntu-latest`, `dotnet publish -c Release` (`:29`), `rsync -az --delete publish/` to the server (`:55-58`), systemd restart (`:60-64`). PR build also `ubuntu-latest` (`dotnet.yml:13`). **A Windows `.dll` can never be produced on the runner — it must be committed.**
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Resources/stockfish-windows-*.exe (committed)"] --> C
|
||||
B["Resources/stockfish-ubuntu-* (committed)"] --> C
|
||||
C["csproj Content Resources/** PreserveNewest (csproj:14-18)"]
|
||||
C --> D["dotnet publish (ubuntu-latest) deploy.yml:29"]
|
||||
D --> E["publish/Resources/*"]
|
||||
E --> F["rsync to Linux server deploy.yml:55-58"]
|
||||
F --> G["Runtime Stockfish.cs:23,30 BaseDirectory + Resources/fileName"]
|
||||
G --> H["Process.Start(exePath) — child process, not P/Invoke :40-54"]
|
||||
```
|
||||
|
||||
#### Findings
|
||||
|
||||
1. **The existing Content glob already covers new Resources files** (`csproj:14-18`) — dropping the native libs in `Resources/` flows them to output/publish with zero csproj change required (explicit items optional for clarity).
|
||||
2. **Build host is Linux-only** (`deploy.yml:17`, `dotnet.yml:13`) — the Windows `.dll` MUST be committed; an MSBuild→CMake target only helps on a developer's Windows box.
|
||||
3. **Stockfish uses a child process, not P/Invoke** (`Stockfish.cs:40-54`) — no existing `DllImport`/`NativeLibrary` precedent; the resolver story is net-new.
|
||||
4. **Resources path is hardcoded `Path.Combine(baseDir,"Resources",...)`** (`Stockfish.cs:30`), but P/Invoke's default search does NOT look in a `Resources` subfolder. *Impact:* register a `DllImportResolver` pointing at `Resources/` **or** place the lib at the output root. Biggest divergence from the Stockfish pattern.
|
||||
5. **No CMake/C++ scaffolding exists** — greenfield; recommend `native/chess_engine/` at repo root, outside the csproj compile globs.
|
||||
6. **glibc/libstdc++ ABI risk** — the committed `.so` is built on a dev/CI machine but runs on the rsync'd server (`deploy.yml:55`); a newer build-host libstdc++/glibc → runtime load failure. Build against a server-matching baseline or static-link libstdc++.
|
||||
|
||||
#### Suggested Approach
|
||||
|
||||
**Recommendation: build natively per-platform and commit both artifacts into `Resources/`, mirroring Stockfish.** The repo already commits platform binaries, the build host is Linux-only, and committing keeps deploy a pure `dotnet publish`. An optional opt-in MSBuild target can rebuild the matching-platform artifact locally, but must never be the deploy's source of truth.
|
||||
|
||||
```cmake
|
||||
# native/chess_engine/CMakeLists.txt
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(chess_engine LANGUAGES CXX)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
add_library(chess_engine SHARED src/chess_engine.cpp)
|
||||
target_include_directories(chess_engine PUBLIC include)
|
||||
target_compile_definitions(chess_engine PRIVATE CHESS_ENGINE_BUILD)
|
||||
|
||||
# Windows -> chess_engine.dll ; Linux -> libchess_engine.so
|
||||
set_target_properties(chess_engine PROPERTIES OUTPUT_NAME chess_engine POSITION_INDEPENDENT_CODE ON)
|
||||
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
|
||||
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)
|
||||
|
||||
if (MSVC)
|
||||
target_compile_options(chess_engine PRIVATE
|
||||
$<$<CONFIG:Release>:/O2 /GL /DNDEBUG /arch:AVX2>
|
||||
$<$<CONFIG:Debug>:/Od /Zi>) # /Zi => .pdb for mixed-mode debugging
|
||||
target_link_options(chess_engine PRIVATE $<$<CONFIG:Release>:/LTCG> $<$<CONFIG:Debug>:/DEBUG>)
|
||||
else()
|
||||
# Portable server baseline; do NOT use -march=native (build host may differ -> SIGILL).
|
||||
target_compile_options(chess_engine PRIVATE
|
||||
$<$<CONFIG:Release>:-O3 -flto -DNDEBUG -march=x86-64-v2> # ~SSE4.2; confirm server floor
|
||||
$<$<CONFIG:Debug>:-O0 -g>)
|
||||
endif()
|
||||
```
|
||||
|
||||
The export macro (`CHESS_API`, defined under `CHESS_ENGINE_BUILD`) and `extern "C"` live in the Backend Engineer's `chess_engine.h`.
|
||||
|
||||
```bash
|
||||
# Windows (Developer PowerShell, MSVC)
|
||||
cmake -S native/chess_engine -B native/chess_engine/build -A x64
|
||||
cmake --build native/chess_engine/build --config Release # -> build/Release/chess_engine.dll (+ .pdb)
|
||||
# Linux (ideally in an ubuntu:22.04 container matching the server)
|
||||
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build native/chess_engine/build # -> build/libchess_engine.so
|
||||
```
|
||||
Copy each artifact into `JoshHeaps.Net/Resources/` and commit — same lifecycle as the Stockfish binaries.
|
||||
|
||||
**Optional local-only CMake build target** (gated `BuildNativeEngine=true`, off by default, OS-conditioned, never gates deploy):
|
||||
```xml
|
||||
<Target Name="BuildNativeWindows" BeforeTargets="BeforeBuild"
|
||||
Condition="'$(BuildNativeEngine)'=='true' AND '$(OS)'=='Windows_NT'">
|
||||
<Exec Command="cmake -S native\chess_engine -B native\chess_engine\build -A x64" />
|
||||
<Exec Command="cmake --build native\chess_engine\build --config $(Configuration)" />
|
||||
<Copy SourceFiles="native\chess_engine\build\$(Configuration)\chess_engine.dll" DestinationFolder="Resources\" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
<Target Name="BuildNativeLinux" BeforeTargets="BeforeBuild"
|
||||
Condition="'$(BuildNativeEngine)'=='true' AND '$(OS)'!='Windows_NT'">
|
||||
<Exec Command="cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release" />
|
||||
<Exec Command="cmake --build native/chess_engine/build" />
|
||||
<Copy SourceFiles="native/chess_engine/build/libchess_engine.so" DestinationFolder="Resources/" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
```
|
||||
|
||||
**Runtime load — recommendation: `DllImportResolver` pointing at `Resources/`** (co-locates with Stockfish, survives single-file publish). This matches the Backend Engineer's `NativeMethods` resolver. Keep the native libs **only** in `Resources/` (rely on the existing `Content` glob) — do not also `<Link>` them to the output root, to avoid two copies that drift.
|
||||
|
||||
**Local debugging (mixed-mode):** CMake `Debug` emits `/Zi` + `/DEBUG` → `chess_engine.pdb`; ship it next to the `.dll` for local debug builds only (Debug-only `None` item, never committed/deployed). In Visual Studio enable **Project Properties → Debug → Enable native code debugging** to step from C# P/Invoke into C++.
|
||||
|
||||
**Linux deployment:** `libchess_engine.so` ships via `Resources/**` → `publish/` → rsync automatically. It's `dlopen`'d (no `chmod +x` needed) but must be world-readable. It links `libstdc++`/`glibc` — build against a baseline ≤ the server's, or **static-link** (`-static-libstdc++ -static-libgcc`) to remove the version coupling (safest for a committed binary).
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Build["Build (per-platform)"]
|
||||
W["Windows dev: cmake --build Release<br/>=> chess_engine.dll (+pdb in Debug)"]
|
||||
L["Linux (ubuntu:22.04 container): cmake --build Release<br/>=> libchess_engine.so (-static-libstdc++)"]
|
||||
end
|
||||
W --> R["Commit into JoshHeaps.Net/Resources/ (as stockfish-* today)"]
|
||||
L --> R
|
||||
R --> CSP["csproj Content Resources/** (csproj:14-18) -> output/Resources/"]
|
||||
CSP --> PUB["dotnet publish (ubuntu-latest) deploy.yml:29 — pure, no toolchain"]
|
||||
PUB --> RS["rsync publish/ to server deploy.yml:55-58"]
|
||||
RS --> RES["DllImportResolver: BaseDirectory + Resources/ + OS filename switch (mirrors Stockfish.cs:23-30)"]
|
||||
RES --> PI["P/Invoke [LibraryImport(chess_engine)] -> NativeLibrary.Load"]
|
||||
style W fill:#1d4ed8,color:#fff
|
||||
style L fill:#15803d,color:#fff
|
||||
style PI fill:#92400e,color:#fff
|
||||
```
|
||||
|
||||
#### Open Questions
|
||||
|
||||
1. **Server glibc/libstdc++ version unknown** — need `ldd --version` + `strings libstdc++.so.6 | grep GLIBCXX`; otherwise build in `ubuntu:22.04` or static-link libstdc++.
|
||||
2. **Commit binaries vs build `.so` in CI?** Recommend commit both (consistent, pure deploy); alternative is a Linux native-build step in `deploy.yml` for a reproducible/ABI-correct `.so`.
|
||||
3. **Target CPU baseline** — assumed `-march=x86-64-v2` to match `sse41-popcnt`; confirm the server floor; `-march=native` unsafe for committed/CI binaries.
|
||||
4. **`IChessEngine` doesn't exist yet** — prerequisite from the Architect (out of scope for build, flagged).
|
||||
5. **`.pdb` policy** — local-only Debug symbols recommended; confirm.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Arch["Architect: abstraction"]
|
||||
IFACE[/IChessEngine/] --> FAC[Factory + appsettings]
|
||||
end
|
||||
subgraph Backend["Backend: contract"]
|
||||
ABI["extern C ABI<br/>FEN -> UCI"]
|
||||
end
|
||||
subgraph Perf["Performance"]
|
||||
ONCE["once-per-move boundary<br/>native search"]
|
||||
end
|
||||
subgraph DevOps["DevOps"]
|
||||
PKG["commit .dll/.so in Resources<br/>DllImportResolver"]
|
||||
end
|
||||
IFACE -.->|"GetBestMoveAsync(fen) shape<br/>must match ABI"| ABI
|
||||
ABI -.->|"string contract = thin boundary"| ONCE
|
||||
ONCE -.->|"build flags -O3/AVX2/LTO"| PKG
|
||||
PKG -.->|"resolver finds lib for CustomChessEngine"| IFACE
|
||||
```
|
||||
|
||||
1. **The string contract ties all four areas together.** `IChessEngine.GetBestMoveAsync(string fen) → string` (Architect) is the exact shape of `engine_best_move(const char* fen, char* out)` (Backend), which is what makes the boundary thin (Performance) and keeps `ToFen`/`ToMoveDto` untouched. If anyone changes to a struct contract, all four must change. Evidence: `Stockfish.cs:80,90,131,169`.
|
||||
2. **`GameState.Computer` retype is owned by the Architect but unblocks Backend.** `GameState.cs:46` must become `IChessEngine?` before `CustomChessEngine` can be slotted in. Both specialists flagged it.
|
||||
3. **Native-lib naming must be consistent end to end.** The logical name `chess_engine` (C# `[LibraryImport]`/resolver), the CMake `OUTPUT_NAME chess_engine`, and the committed filenames `chess_engine.dll` / `libchess_engine.so` must all agree. The specialists used different names — standardized here.
|
||||
4. **`Resources/` placement + resolver is the load contract.** DevOps's `DllImportResolver` (probing `BaseDirectory/Resources`) and Backend's `NativeMethods.Resolve` are the same mechanism and must be written once (in `CustomChessEngine`/`NativeMethods`). Evidence: `Stockfish.cs:23,30`, `csproj:14-18`.
|
||||
5. **Build optimization is a shared Performance/DevOps concern.** `-O3 -flto` / `/O2 /GL /LTCG`, `/arch:AVX2`, and a safe Linux `-march` baseline live in the CMakeLists (DevOps) but are motivated by the hot-loop requirement (Performance).
|
||||
6. **Move-generation single-source-of-truth.** Human moves stay validated by C# `ChessService` (`ChessController.cs:179`); the native engine has its own generator. Two generators risk divergence — consider exposing native `perft` later for cross-validation.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Severity | Likelihood | Mitigation | Related Files |
|
||||
|---|---|---|---|---|
|
||||
| Linux `.so` fails to load (glibc/libstdc++ mismatch) | High | Med | Build in `ubuntu:22.04` container or static-link libstdc++; capture server `ldd --version` | `deploy.yml:55-58`, CMakeLists |
|
||||
| Native crash takes down the ASP.NET process | High | Med | The empty stub returns codes, never throws across the boundary; validate FEN in C# first; consider process isolation if instability appears | `chess_engine.cpp`, `CustomChessEngine.cs` |
|
||||
| Windows `.dll` can't be built in CI (Linux runner) | Med | High (by design) | Commit the `.dll` like the Stockfish `.exe`; optional opt-in local MSBuild target | `deploy.yml:17`, `csproj:14-18` |
|
||||
| `-march`/`/arch` too aggressive → SIGILL on server | Med | Med | Use a confirmed server baseline; never `-march=native` for committed/CI binaries | CMakeLists, `Stockfish.cs:24-28` |
|
||||
| P/Invoke can't find the lib (Resources subfolder not searched) | Med | High without resolver | `DllImportResolver` probing `BaseDirectory/Resources` | `CustomChessEngine.cs`, `Stockfish.cs:30` |
|
||||
| Handle leak / double-free across boundary | Med | Low | `SafeHandle` + caller-owned out buffers + `delete nullptr`-safe `engine_destroy` | `CustomChessEngine.cs`, `chess_engine.cpp` |
|
||||
| Concurrent `GetBestMoveAsync` on one non-reentrant handle | Low | Low | One handle per game (as today); document non-reentrancy | `GameState.cs:46`, `ChessController.cs:192` |
|
||||
| Two move generators (C# rules vs native) diverge | Med | Med | Keep C# as legality authority; add native `perft` for cross-check later | `ChessService.cs`, native |
|
||||
|
||||
## Recommendations
|
||||
|
||||
Ordered by priority:
|
||||
|
||||
1. **Introduce `IChessEngine` and retrofit `Stockfish`** (Architect §). Add the interface, make `Stockfish` implement it, rename `StockfishHelpers` → `ChessEngineHelpers`, retype `GameState.Computer` to `IChessEngine?`. Low effort, unblocks everything. *Supported by Findings A1–A2, B2.*
|
||||
2. **Lift orchestration out of the engine** into `IComputerMoveOrchestrator` (Architect §; Backend Finding 3). Removes duplication before a second engine exists. Low effort.
|
||||
3. **Add the factory + `appsettings` swap** (Architect §). `IChessEngineFactory` + `ChessEngineOptions`, registered in `Program.cs`. The swap mechanism the mission asks for. Low effort.
|
||||
4. **Stand up the native project + empty stub** (Backend + DevOps §). `native/chess_engine/` with `chess_engine.h`, the compilable `chess_engine.cpp` placeholder, and `CMakeLists.txt`. Medium effort (build setup, not logic).
|
||||
5. **Write `CustomChessEngine` P/Invoke wrapper** (Backend §) implementing `IChessEngine`, with `[LibraryImport]`, `DllImportResolver` → `Resources/`, `SafeHandle`, and `Task.Run` async adaptation. Medium effort.
|
||||
6. **Build + commit both artifacts; verify the round trip** (DevOps §). Build `.dll` on Windows / `.so` on Linux (container), commit to `Resources/`, flip `appsettings` to `Custom`, confirm the stub's `e2e4` flows through `ToMoveDto` → SignalR end to end. Medium effort. **Do this before writing any chess logic.**
|
||||
7. **Then implement the C++ engine** (user) — bitboards, search, eval, all native, honoring the "zero managed callbacks per node" rule (Performance §). The infrastructure above makes this a pure C++ task behind a stable contract.
|
||||
|
||||
## Appendix: All Referenced Files
|
||||
|
||||
| File | Referenced By | Context |
|
||||
|---|---|---|
|
||||
| `Services/Implementations/Stockfish.cs` | Architect, Backend, Performance, DevOps | The engine to mirror; `GetBestMoveAsync`/`MakeMove`/`ToFen`/`ToMoveDto`, process launch, Resources path |
|
||||
| `Services/Interfaces/IChessService.cs` | Architect, Backend | Rules engine (separate concern, not swapped) |
|
||||
| `Services/Implementations/ChessService.cs` | Architect, Performance | C# move-gen/legality — the genuinely slow code being replaced |
|
||||
| `Models/GameState.cs` | Architect, Backend, Performance | `Computer` coupling (`:46`), board model (`:11`) |
|
||||
| `Models/ChessPiece.cs`, `Position.cs`, `MoveDto.cs`, `Enums.cs` | Backend, Performance | Data shapes; UCI→`MoveDto` parsing |
|
||||
| `Controllers/ChessController.cs` | Architect, Backend, Performance | Engine construction (`:45`), invocation (`:60,192`), disposal (`:248`), game registry (`:20`) |
|
||||
| `Program.cs` | Architect | DI registration site (`:21-23`) |
|
||||
| `appsettings.json` | Architect | Swap config section |
|
||||
| `Services/Interfaces/IBackgroundTaskQueue.cs` | Performance | Background move execution |
|
||||
| `JoshHeaps.Net.csproj` | Backend, DevOps | `Content Resources/**` copy (`:14-18`), TFM/Nullable (`:3-7`) |
|
||||
| `Resources/stockfish-*` | DevOps | Committed-binary precedent for `.dll`/`.so` |
|
||||
| `.github/workflows/deploy.yml`, `dotnet.yml` | DevOps | Linux-only CI, publish + rsync deploy |
|
||||
| `native/chess_engine/include/chess_engine.h` (new) | Backend, DevOps | extern "C" ABI + export macro |
|
||||
| `native/chess_engine/src/chess_engine.cpp` (new) | Backend | Empty compilable stub |
|
||||
| `native/chess_engine/CMakeLists.txt` (new) | DevOps, Performance | Shared-lib build + optimization flags |
|
||||
| `Services/Implementations/CustomChessEngine.cs` (new) | Backend, Architect | P/Invoke middleman implementing `IChessEngine` |
|
||||
| `Services/Interfaces/IChessEngine.cs` + `IChessEngineFactory.cs` (new) | Architect | Swappable contract + factory |
|
||||
| `Services/Implementations/ChessEngineFactory.cs` + `ComputerMoveOrchestrator.cs` (new) | Architect | Config-driven selection + lifted orchestration |
|
||||
@@ -0,0 +1,42 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(chess_engine LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
# Shared library: chess_engine.dll (Windows) / libchess_engine.so (Linux).
|
||||
add_library(chess_engine SHARED
|
||||
src/chess_engine.cpp
|
||||
src/bitboard.cpp
|
||||
src/zobrist.cpp
|
||||
src/position.cpp
|
||||
src/movegen.cpp
|
||||
src/uci.cpp
|
||||
src/perft.cpp)
|
||||
target_include_directories(chess_engine PUBLIC include)
|
||||
target_compile_definitions(chess_engine PRIVATE CHESS_ENGINE_BUILD)
|
||||
|
||||
set_target_properties(chess_engine PROPERTIES
|
||||
OUTPUT_NAME chess_engine
|
||||
POSITION_INDEPENDENT_CODE ON) # -fPIC on Linux (required for .so)
|
||||
|
||||
# Export only the symbols marked with the CHESS_API macro.
|
||||
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
|
||||
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)
|
||||
|
||||
if (MSVC)
|
||||
target_compile_options(chess_engine PRIVATE
|
||||
$<$<CONFIG:Release>:/O2 /GL /DNDEBUG /arch:AVX2>
|
||||
$<$<CONFIG:Debug>:/Od /Zi>) # /Zi => .pdb for mixed-mode debugging
|
||||
target_link_options(chess_engine PRIVATE
|
||||
$<$<CONFIG:Release>:/LTCG>
|
||||
$<$<CONFIG:Debug>:/DEBUG>)
|
||||
else()
|
||||
# Portable server baseline. Do NOT use -march=native: the build host may have
|
||||
# instructions the server lacks (SIGILL at runtime). Bump only once the server
|
||||
# CPU floor is confirmed.
|
||||
target_compile_options(chess_engine PRIVATE
|
||||
$<$<CONFIG:Release>:-O3 -flto -DNDEBUG -march=x86-64-v2>
|
||||
$<$<CONFIG:Debug>:-O0 -g>)
|
||||
endif()
|
||||
@@ -0,0 +1,115 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Times the search before vs after killer-move ordering.
|
||||
|
||||
.DESCRIPTION
|
||||
Compiles two standalone bench binaries from the current engine source — one with killer
|
||||
ordering disabled (the pre-killer baseline, via /DBENCH_DISABLE_KILLERS) and one with it
|
||||
on — then runs each benchmark position in its own process (cold transposition table) and
|
||||
reports wall-clock search time for both, plus the speedup. Timing is measured inside the
|
||||
engine around engine_best_move, so process startup isn't counted. Each position is run
|
||||
-Reps times and the fastest run is kept, to cut scheduling noise.
|
||||
|
||||
.PARAMETER Skill
|
||||
Search difficulty / max depth. Default 8.
|
||||
|
||||
.PARAMETER Positions
|
||||
Which position indices to run (0=kiwipete, 1=ruy, 2=sicilian). Default: all.
|
||||
|
||||
.PARAMETER Reps
|
||||
Runs per position per variant; the minimum time is reported. Default 3.
|
||||
|
||||
.PARAMETER ShowDepths
|
||||
Also print every per-depth line the engine emits.
|
||||
|
||||
.EXAMPLE
|
||||
.\bench.ps1
|
||||
.\bench.ps1 -Skill 9 -Reps 5 -Positions 0,2
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$Skill = 8,
|
||||
[int[]]$Positions,
|
||||
[int]$Reps = 3,
|
||||
[switch]$ShowDepths
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = $PSScriptRoot
|
||||
$src = Join-Path $root 'src'
|
||||
$inc = Join-Path $root 'include'
|
||||
$build = Join-Path $root 'build'
|
||||
if (-not (Test-Path $build)) { New-Item -ItemType Directory -Path $build | Out-Null }
|
||||
|
||||
# --- enter a VS dev shell so cl is on PATH ---
|
||||
$devShell = "D:\Program Files\Visual Studio 2026\Common7\Tools\Launch-VsDevShell.ps1"
|
||||
if (-not (Test-Path $devShell)) { throw "VS dev shell not found at $devShell" }
|
||||
& $devShell -Arch amd64 -HostArch amd64 -SkipAutomaticLocation | Out-Null
|
||||
|
||||
$engineSources = 'chess_engine.cpp','bitboard.cpp','zobrist.cpp','position.cpp','movegen.cpp','uci.cpp' |
|
||||
ForEach-Object { Join-Path $src $_ }
|
||||
$benchMain = Join-Path $root 'test\bench_main.cpp'
|
||||
|
||||
function Build-Variant([string]$exe, [string[]]$extraDefs) {
|
||||
$clArgs = @('/nologo','/std:c++17','/O2','/EHsc','/arch:AVX2','/DCHESS_ENGINE_BUILD','/DNDEBUG') +
|
||||
$extraDefs + @("/I$inc","/I$src", $benchMain) + $engineSources + @("/Fe:$exe", "/Fo:$build\")
|
||||
& cl @clArgs | Out-Null
|
||||
if (-not (Test-Path $exe)) { throw "compile failed: $exe" }
|
||||
}
|
||||
|
||||
$exeBefore = Join-Path $build 'bench_before.exe' # killers disabled (pre-killer baseline)
|
||||
$exeAfter = Join-Path $build 'bench_after.exe' # killers enabled (current)
|
||||
|
||||
Write-Host "Compiling both variants..." -ForegroundColor Cyan
|
||||
Build-Variant $exeBefore @('/DBENCH_DISABLE_KILLERS')
|
||||
Build-Variant $exeAfter @()
|
||||
|
||||
$nodePattern = '^depth (\d+) nodes (\d+) best (\S+) score (-?\d+)$'
|
||||
$timePattern = 'time_ms=(\d+)'
|
||||
|
||||
function Run-One([string]$exe, [int]$pos) {
|
||||
$nodes = 0; $best = '?'; $bestMs = [long]::MaxValue
|
||||
for ($r = 0; $r -lt $Reps; $r++) {
|
||||
$err = [System.IO.Path]::GetTempFileName()
|
||||
$out = [System.IO.Path]::GetTempFileName()
|
||||
Start-Process -FilePath $exe -ArgumentList $pos,$Skill -NoNewWindow -Wait `
|
||||
-RedirectStandardError $err -RedirectStandardOutput $out | Out-Null
|
||||
$lines = Get-Content $err
|
||||
Remove-Item $err,$out -Force -ErrorAction SilentlyContinue
|
||||
if ($ShowDepths) { $lines | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } }
|
||||
|
||||
$deepest = $lines | Select-String $nodePattern | Select-Object -Last 1
|
||||
if ($deepest) { $nodes = [long]$deepest.Matches.Groups[2].Value; $best = $deepest.Matches.Groups[3].Value }
|
||||
$tm = $lines | Select-String $timePattern | Select-Object -Last 1
|
||||
if ($tm) { $ms = [long]$tm.Matches.Groups[1].Value; if ($ms -lt $bestMs) { $bestMs = $ms } }
|
||||
}
|
||||
return [pscustomobject]@{ Nodes = $nodes; Best = $best; Ms = $bestMs }
|
||||
}
|
||||
|
||||
if (-not $Positions) { $Positions = 0,1,2 }
|
||||
$names = @('kiwipete','ruy','sicilian')
|
||||
$rows = @()
|
||||
|
||||
foreach ($i in $Positions) {
|
||||
$b = Run-One $exeBefore $i
|
||||
$a = Run-One $exeAfter $i
|
||||
$speedup = if ($a.Ms -gt 0) { [math]::Round($b.Ms / $a.Ms, 2) } else { 0 }
|
||||
$rows += [pscustomobject]@{
|
||||
Position = $names[$i]
|
||||
'ms (before)' = $b.Ms
|
||||
'ms (after)' = $a.Ms
|
||||
Speedup = "${speedup}x"
|
||||
'nodes before' = $b.Nodes
|
||||
'nodes after' = $a.Nodes
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Skill $Skill, best of $Reps runs (before = no killers, after = killers)" -ForegroundColor Cyan
|
||||
$rows | Format-Table -AutoSize
|
||||
|
||||
$tb = ($rows | Measure-Object -Property 'ms (before)' -Sum).Sum
|
||||
$ta = ($rows | Measure-Object -Property 'ms (after)' -Sum).Sum
|
||||
$tot = if ($ta -gt 0) { [math]::Round($tb / $ta, 2) } else { 0 }
|
||||
Write-Host ("TOTAL {0} ms -> {1} ms ({2}x faster)" -f $tb, $ta, $tot)
|
||||
@@ -0,0 +1,179 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Debug|Win32">
|
||||
<Configuration>Debug</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|Win32">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Debug|x64">
|
||||
<Configuration>Debug</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|x64">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>18.0</VCProjectVersion>
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{2579bbbc-1830-4342-bc10-0a4182dc84c7}</ProjectGuid>
|
||||
<RootNamespace>chessengine</RootNamespace>
|
||||
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
<PlatformToolset>v145</PlatformToolset>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<PlatformToolset>v145</PlatformToolset>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
<PlatformToolset>v145</PlatformToolset>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<PlatformToolset>v145</PlatformToolset>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<ImportGroup Label="ExtensionSettings">
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="Shared">
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<PropertyGroup Label="UserMacros" />
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>WIN32;_DEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp20</LanguageStandard>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<AdditionalIncludeDirectories>$(ProjectDir)..\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<EnableUAC>false</EnableUAC>
|
||||
</Link>
|
||||
<Manifest>
|
||||
<EnableSegmentHeap>true</EnableSegmentHeap>
|
||||
</Manifest>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>WIN32;NDEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp20</LanguageStandard>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<AdditionalIncludeDirectories>$(ProjectDir)..\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<EnableUAC>false</EnableUAC>
|
||||
</Link>
|
||||
<Manifest>
|
||||
<EnableSegmentHeap>true</EnableSegmentHeap>
|
||||
</Manifest>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>_DEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp20</LanguageStandard>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<AdditionalIncludeDirectories>$(ProjectDir)..\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<EnableUAC>false</EnableUAC>
|
||||
</Link>
|
||||
<Manifest>
|
||||
<EnableSegmentHeap>true</EnableSegmentHeap>
|
||||
</Manifest>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>NDEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp20</LanguageStandard>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<AdditionalIncludeDirectories>$(ProjectDir)..\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<EnableUAC>false</EnableUAC>
|
||||
</Link>
|
||||
<Manifest>
|
||||
<EnableSegmentHeap>true</EnableSegmentHeap>
|
||||
</Manifest>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="..\src\chess_engine.cpp" />
|
||||
<ClCompile Include="..\src\bitboard.cpp" />
|
||||
<ClCompile Include="..\src\zobrist.cpp" />
|
||||
<ClCompile Include="..\src\position.cpp" />
|
||||
<ClCompile Include="..\src\movegen.cpp" />
|
||||
<ClCompile Include="..\src\uci.cpp" />
|
||||
<ClCompile Include="..\src\perft.cpp" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="..\include\chess_engine.h" />
|
||||
<ClInclude Include="..\src\types.h" />
|
||||
<ClInclude Include="..\src\bitboard.h" />
|
||||
<ClInclude Include="..\src\zobrist.h" />
|
||||
<ClInclude Include="..\src\position.h" />
|
||||
<ClInclude Include="..\src\movegen.h" />
|
||||
<ClInclude Include="..\src\uci.h" />
|
||||
<ClInclude Include="..\src\perft.h" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
<ImportGroup Label="ExtensionTargets">
|
||||
</ImportGroup>
|
||||
<Target Name="CopyEngineToResources" AfterTargets="Build">
|
||||
<!-- Copy the DLL plus its .pdb so native breakpoints bind when the .NET host loads the engine. -->
|
||||
<Copy SourceFiles="$(TargetPath);$(TargetDir)$(TargetName).pdb" DestinationFolder="$(ProjectDir)..\..\..\JoshHeaps.Net\Resources\" SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup>
|
||||
<Filter Include="Source Files">
|
||||
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
|
||||
<Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions>
|
||||
</Filter>
|
||||
<Filter Include="Header Files">
|
||||
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
|
||||
<Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions>
|
||||
</Filter>
|
||||
<Filter Include="Resource Files">
|
||||
<UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier>
|
||||
<Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions>
|
||||
</Filter>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="..\src\chess_engine.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="..\src\bitboard.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="..\src\zobrist.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="..\src\position.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="..\src\movegen.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="..\src\uci.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="..\src\perft.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="..\include\chess_engine.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\src\types.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\src\bitboard.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\src\zobrist.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\src\position.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\src\movegen.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\src\uci.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\src\perft.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,118 @@
|
||||
/* chess_engine.h - C ABI for a swappable chess engine.
|
||||
*
|
||||
* Contract: FEN string in, UCI move string out (e.g. "e2e4", "e7e8q").
|
||||
* The C# host (CustomChessEngine) owns all buffers. The engine NEVER allocates
|
||||
* memory that the host must free. Functions are thread-compatible per-handle only:
|
||||
* do NOT call two functions on the SAME handle concurrently. Different handles
|
||||
* are independent.
|
||||
*/
|
||||
#ifndef CHESS_ENGINE_H
|
||||
#define CHESS_ENGINE_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/* ---- Export / calling-convention macro (MSVC + GCC/Clang) ---- */
|
||||
#if defined(_WIN32)
|
||||
#ifdef CHESS_ENGINE_BUILD
|
||||
#define CHESS_API __declspec(dllexport)
|
||||
#else
|
||||
#define CHESS_API __declspec(dllimport)
|
||||
#endif
|
||||
#define CHESS_CALL __cdecl /* explicit; matches C# CallingConvention.Cdecl */
|
||||
#else
|
||||
#define CHESS_API __attribute__((visibility("default")))
|
||||
#define CHESS_CALL /* SysV default; no decoration needed */
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" { /* prevent C++ name mangling */
|
||||
#endif
|
||||
|
||||
/* Opaque handle. The host treats this as a token and never dereferences it.
|
||||
* 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,
|
||||
CHESS_ERR_NULL_HANDLE = -1, /* handle was null/invalid */
|
||||
CHESS_ERR_BAD_FEN = -2, /* fen failed to parse */
|
||||
CHESS_ERR_NO_MOVE = -3, /* no legal move (mate/stalemate) */
|
||||
CHESS_ERR_BUFFER = -4, /* out_buf too small for the move + NUL */
|
||||
CHESS_ERR_INTERNAL = -5 /* unexpected engine failure */
|
||||
};
|
||||
|
||||
/* Create an engine instance.
|
||||
* options: optional null-terminated UTF-8 config string (may be NULL),
|
||||
* e.g. "skill=20;hash=256". Parse however you like; ignore for now.
|
||||
* returns: a valid EngineHandle, or NULL on allocation failure. */
|
||||
CHESS_API EngineHandle CHESS_CALL engine_create(const char* options);
|
||||
|
||||
/* Set a single option by name (optional; may no-op for now).
|
||||
* returns CHESS_OK or a negative code. */
|
||||
CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine,
|
||||
const char* name,
|
||||
const char* value);
|
||||
|
||||
/* Compute the best move for the given position.
|
||||
* engine : handle from engine_create.
|
||||
* fen : null-terminated UTF-8 FEN of the position to move from.
|
||||
* history : optional null-terminated UTF-8 list of the prior positions since the
|
||||
* last irreversible move (capture/pawn move), one FEN per line, oldest
|
||||
* first, NOT including `fen`. Lets the engine detect threefold/50-move
|
||||
* draws that the FEN alone can't carry. May be NULL or empty.
|
||||
* out_buf : host-owned buffer the engine writes the UCI move into,
|
||||
* as a null-terminated ASCII string (e.g. "e2e4\0").
|
||||
* out_len : capacity of out_buf in bytes (host passes >= 8).
|
||||
* returns CHESS_OK on success (out_buf now holds the move), else negative.
|
||||
* MUST NOT write more than out_len bytes including the NUL terminator. */
|
||||
CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
|
||||
const char* fen,
|
||||
const char* history,
|
||||
char* out_buf,
|
||||
int out_len);
|
||||
|
||||
/* Write the engine version string into out_buf (null-terminated).
|
||||
* returns CHESS_OK or CHESS_ERR_BUFFER. */
|
||||
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
|
||||
#endif /* CHESS_ENGINE_H */
|
||||
@@ -0,0 +1,146 @@
|
||||
#include "bitboard.h"
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
namespace chess {
|
||||
|
||||
Bitboard PawnAttacks[COLOR_NB][SQUARE_NB];
|
||||
Bitboard KnightAttacks[SQUARE_NB];
|
||||
Bitboard KingAttacks[SQUARE_NB];
|
||||
|
||||
Magic BishopMagics[SQUARE_NB];
|
||||
Magic RookMagics[SQUARE_NB];
|
||||
|
||||
// Backing storage the magics index into (fancy-magic sizes).
|
||||
static Bitboard RookTable[102400];
|
||||
static Bitboard BishopTable[5248];
|
||||
|
||||
namespace {
|
||||
|
||||
int file_distance(Square a, Square b) {
|
||||
return std::abs(int(file_of(a)) - int(file_of(b)));
|
||||
}
|
||||
|
||||
// Slow, edge-aware ray attack used only to build the tables.
|
||||
Bitboard sliding_attack(const int* dirs, Square sq, Bitboard occ) {
|
||||
Bitboard attacks = 0;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
Square prev = sq;
|
||||
int t = int(sq) + dirs[i];
|
||||
while (t >= 0 && t < 64 && file_distance(Square(t), prev) <= 1) {
|
||||
attacks |= square_bb(Square(t));
|
||||
if (occ & square_bb(Square(t))) break;
|
||||
prev = Square(t);
|
||||
t += dirs[i];
|
||||
}
|
||||
}
|
||||
return attacks;
|
||||
}
|
||||
|
||||
// Relevant-occupancy mask: the ray squares excluding board edges.
|
||||
Bitboard slider_mask(const int* dirs, Square sq) {
|
||||
Bitboard edges = ((RANK_1_BB | RANK_8_BB) & ~rank_bb(rank_of(sq)))
|
||||
| ((FILE_A_BB | FILE_H_BB) & ~file_bb(file_of(sq)));
|
||||
return sliding_attack(dirs, sq, 0) & ~edges;
|
||||
}
|
||||
|
||||
// Deterministic xorshift PRNG (fixed seed -> reproducible magics).
|
||||
struct PRNG {
|
||||
uint64_t s;
|
||||
explicit PRNG(uint64_t seed) : s(seed) {}
|
||||
uint64_t next() {
|
||||
s ^= s >> 12; s ^= s << 25; s ^= s >> 27;
|
||||
return s * 2685821657736338717ULL;
|
||||
}
|
||||
// Few set bits -> better magic candidates.
|
||||
uint64_t sparse() { return next() & next() & next(); }
|
||||
};
|
||||
|
||||
void init_magics(const int* dirs, Magic magics[], Bitboard table[]) {
|
||||
PRNG rng(0x9E3779B97F4A7C15ull); // fixed seed -> reproducible magics
|
||||
|
||||
Bitboard occupancy[4096];
|
||||
Bitboard reference[4096];
|
||||
int epoch[4096] = {};
|
||||
int currentEpoch = 0;
|
||||
|
||||
size_t offset = 0;
|
||||
for (int sq = 0; sq < 64; ++sq) {
|
||||
Magic& m = magics[sq];
|
||||
m.mask = slider_mask(dirs, Square(sq));
|
||||
m.shift = 64 - popcount(m.mask);
|
||||
m.attacks = table + offset;
|
||||
|
||||
// Enumerate every subset of the mask (Carry-Rippler).
|
||||
Bitboard b = 0;
|
||||
int size = 0;
|
||||
do {
|
||||
occupancy[size] = b;
|
||||
reference[size] = sliding_attack(dirs, Square(sq), b);
|
||||
++size;
|
||||
b = (b - m.mask) & m.mask;
|
||||
} while (b);
|
||||
|
||||
// Search for a magic that maps subsets to indices collision-free
|
||||
// (collisions are fine only when the attack set is identical).
|
||||
for (;;) {
|
||||
Bitboard magic;
|
||||
do {
|
||||
magic = rng.sparse();
|
||||
} while (popcount((m.mask * magic) >> 56) < 6);
|
||||
|
||||
m.magic = magic;
|
||||
++currentEpoch;
|
||||
bool ok = true;
|
||||
for (int i = 0; i < size; ++i) {
|
||||
unsigned idx = m.index(occupancy[i]);
|
||||
if (epoch[idx] < currentEpoch) {
|
||||
epoch[idx] = currentEpoch;
|
||||
m.attacks[idx] = reference[i];
|
||||
} else if (m.attacks[idx] != reference[i]) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) break;
|
||||
}
|
||||
|
||||
offset += size;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void init_bitboards() {
|
||||
for (int s = 0; s < 64; ++s) {
|
||||
Bitboard b = square_bb(Square(s));
|
||||
|
||||
PawnAttacks[WHITE][s] = ((b & ~FILE_H_BB) << 9) | ((b & ~FILE_A_BB) << 7);
|
||||
PawnAttacks[BLACK][s] = ((b & ~FILE_A_BB) >> 9) | ((b & ~FILE_H_BB) >> 7);
|
||||
|
||||
const int knightDirs[8] = { 17, 15, 10, 6, -6, -10, -15, -17 };
|
||||
Bitboard kn = 0;
|
||||
for (int d : knightDirs) {
|
||||
int t = s + d;
|
||||
if (t >= 0 && t < 64 && file_distance(Square(t), Square(s)) <= 2)
|
||||
kn |= square_bb(Square(t));
|
||||
}
|
||||
KnightAttacks[s] = kn;
|
||||
|
||||
const int kingDirs[8] = { 8, -8, 1, -1, 9, 7, -7, -9 };
|
||||
Bitboard kg = 0;
|
||||
for (int d : kingDirs) {
|
||||
int t = s + d;
|
||||
if (t >= 0 && t < 64 && file_distance(Square(t), Square(s)) <= 1)
|
||||
kg |= square_bb(Square(t));
|
||||
}
|
||||
KingAttacks[s] = kg;
|
||||
}
|
||||
|
||||
const int rookDirs[4] = { 8, -8, 1, -1 };
|
||||
const int bishopDirs[4] = { 9, 7, -7, -9 };
|
||||
init_magics(rookDirs, RookMagics, RookTable);
|
||||
init_magics(bishopDirs, BishopMagics, BishopTable);
|
||||
}
|
||||
|
||||
} // namespace chess
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user